Repository: compose-spec/compose-go
Pull request #518 (2023-12-27 16:18:43)

Make public functions of Project type immutable
⚠️ Breaking changes ⚠️ 

**What I did**

- Add `github.com/mitchellh/copystructure` to make deep copy of `Project` struct
- Make all `Project` public functions which mutate `Project` state immutabl, returning a new instance of `Project` and usage a common `With` prefix for each of them
- Replace the `Project.WithProject` function by a `Project.ForEachProject` function
- Remove the unused `types.set` type

**Related issue**
https://docker.atlassian.net/browse/COMP-380

ndeloof: > mitchellh is no longer doing Go dev

.. which is sad as we rely on mapstructure for parsing. Maybe we should revisit this to only rely on yaml.Unmarshall.
 We could implement `deepClone` without such a reflexion framework, by plain hand-written clone code
milas: Yeah...at least `mapstructure` does seem to be maintained with recent commits and is very popular. In either case, I'm not super worried – we can also always fork them (or move to a community fork) if the need arises
milas: I fixed the DCO status and set the option in the repo settings so it doesn't happen again with accepting PR comments:

<img width="900" alt="Screenshot 2024-01-10 at 11 20 11 AM" src="https://github.com/compose-spec/compose-go/assets/841263/d2729c54-d1a9-4653-8985-b2c349b148af">

glours: > I fixed the DCO status and set the option in the repo settings so it doesn't happen again with accepting PR comments:
> 
> <img alt="Screenshot 2024-01-10 at 11 20 11 AM" width="900" src="https://private-user-images.githubusercontent.com/841263/295632928-d2729c54-d1a9-4653-8985-b2c349b148af.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MDQ5MDQyMzEsIm5iZiI6MTcwNDkwMzkzMSwicGF0aCI6Ii84NDEyNjMvMjk1NjMyOTI4LWQyNzI5YzU0LWQxYTktNDY1My04OTg1LWIyYzM0OWIxNDhhZi5wbmc_WC1BbXotQWxnb3JpdGhtPUFXUzQtSE1BQy1TSEEyNTYmWC1BbXotQ3JlZGVudGlhbD1BS0lBVkNPRFlMU0E1M1BRSzRaQSUyRjIwMjQwMTEwJTJGdXMtZWFzdC0xJTJGczMlMkZhd3M0X3JlcXVlc3QmWC1BbXotRGF0ZT0yMDI0MDExMFQxNjI1MzFaJlgtQW16LUV4cGlyZXM9MzAwJlgtQW16LVNpZ25hdHVyZT05MWMxMzFlZDM3MDAxYjcxZDRmNWY3YTg0YzJlOTc4MDZmNzgwZmVkZjYwMDQyMDA1OWIwZGI2NzNkNjRmOWUwJlgtQW16LVNpZ25lZEhlYWRlcnM9aG9zdCZhY3Rvcl9pZD0wJmtleV9pZD0wJnJlcG9faWQ9MCJ9.j96ciJHNDKXq7Dmc3xMJVe4IQJfCVqOtvsQthnSTIw0">

Arff I just push force without pulling your changes my bad 🤦 


Patch:
diff --git a/go.mod b/go.mod
index e6a921e..97be758 100644
--- a/go.mod
+++ b/go.mod
@@ -22,6 +22,8 @@ require (
 
 require (
 	github.com/davecgh/go-spew v1.1.1 // indirect
+	github.com/mitchellh/copystructure v1.2.0 // indirect
+	github.com/mitchellh/reflectwalk v1.0.2 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
 	github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
diff --git a/go.sum b/go.sum
index d61ccb7..6f3c68c 100644
--- a/go.sum
+++ b/go.sum
@@ -12,8 +12,12 @@ github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
 github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
+github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
+github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
 github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
 github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
+github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
 github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
diff --git a/loader/loader.go b/loader/loader.go
index fc0f354..e075668 100644
--- a/loader/loader.go
+++ b/loader/loader.go
@@ -451,10 +451,12 @@ func load(ctx context.Context, configDetails types.ConfigDetails, opts *Options,
 		}
 	}
 
-	project.ApplyProfiles(opts.Profiles)
+	if project, err = project.WithProfiles(opts.Profiles); err != nil {
+		return nil, err
+	}
 
 	if !opts.SkipResolveEnvironment {
-		err := project.ResolveServicesEnvironment(opts.discardEnvFiles)
+		project, err = project.WithServicesEnvironmentResolved(opts.discardEnvFiles)
 		if err != nil {
 			return nil, err
 		}
diff --git a/types/project.go b/types/project.go
index 7c03560..a50fd9b 100644
--- a/types/project.go
+++ b/types/project.go
@@ -27,6 +27,7 @@ import (
 	"github.com/compose-spec/compose-go/v2/dotenv"
 	"github.com/compose-spec/compose-go/v2/utils"
 	"github.com/distribution/reference"
+	"github.com/mitchellh/copystructure"
 	godigest "github.com/opencontainers/go-digest"
 	"github.com/pkg/errors"
 	"golang.org/x/sync/errgroup"
@@ -34,6 +35,8 @@ import (
 )
 
 // Project is the result of loading a set of compose files
+// Since v2, Project are managed as immutable objects.
+// Each public functions which mutate Project state now return a copy of the original Project with the expected changes.
 type Project struct {
 	Name       string     `yaml:"name,omitempty" json:"name,omitempty"`
 	WorkingDir string     `yaml:"-" json:"-"`
@@ -183,10 +186,10 @@ func (p *Project) AllServices() Services {
 	return all
 }
 
-type ServiceFunc func(name string, service ServiceConfig) error
+type ServiceFunc func(name string, service *ServiceConfig) error
 
-// WithServices run ServiceFunc on each service and dependencies according to DependencyPolicy
-func (p *Project) WithServices(names []string, fn ServiceFunc, options ...DependencyOption) error {
+// ForEachService runs ServiceFunc on each service and dependencies according to DependencyPolicy
+func (p *Project) ForEachService(names []string, fn ServiceFunc, options ...DependencyOption) error {
 	if len(options) == 0 {
 		// backward compatibility
 		options = []DependencyOption{IncludeDependencies}
@@ -240,7 +243,7 @@ func (p *Project) withServices(names []string, fn ServiceFunc, seen map[string]b
 				return err
 			}
 		}
-		if err := fn(name, service); err != nil {
+		if err := fn(name, service.deepCopy()); err != nil {
 			return err
 		}
 	}
@@ -290,54 +293,64 @@ func (s ServiceConfig) HasProfile(profiles []string) bool {
 	return false
 }
 
-// ApplyProfiles disables service which don't match selected profiles
-func (p *Project) ApplyProfiles(profiles []string) {
+// WithProfiles disables services which don't match selected profiles
+// It returns a new Project instance with the changes and keep the original Project unchanged
+func (p *Project) WithProfiles(profiles []string) (*Project, error) {
+	newProject := p.deepCopy()
 	for _, p := range profiles {
 		if p == "*" {
-			return
+			return newProject, nil
 		}
 	}
 	enabled := Services{}
 	disabled := Services{}
-	for name, service := range p.AllServices() {
+	for name, service := range newProject.AllServices() {
 		if service.HasProfile(profiles) {
 			enabled[name] = service
 		} else {
 			disabled[name] = service
 		}
 	}
-	p.Services = enabled
-	p.DisabledServices = disabled
-	p.Profiles = profiles
+	newProject.Services = enabled
+	newProject.DisabledServices = disabled
+	newProject.Profiles = profiles
+	return newProject, nil
 }
 
-// EnableServices ensure services are enabled and activate profiles accordingly
-func (p *Project) EnableServices(names ...string) error {
+// WithServicesEnabled ensures services are enabled and activate profiles accordingly
+// It returns a new Project instance with the changes and keep the original Project unchanged
+func (p *Project) WithServicesEnabled(names ...string) (*Project, error) {
+	newProject := p.deepCopy()
 	if len(names) == 0 {
-		return nil
+		return newProject, nil
 	}
 
 	profiles := append([]string{}, p.Profiles...)
 	for _, name := range names {
-		if _, ok := p.Services[name]; ok {
+		if _, ok := newProject.Services[name]; ok {
 			// already enabled
 			continue
 		}
 		service := p.DisabledServices[name]
 		profiles = append(profiles, service.Profiles...)
 	}
-	p.ApplyProfiles(profiles)
+	newProject, err := newProject.WithProfiles(profiles)
+	if err != nil {
+		return newProject, err
+	}
 
-	return p.ResolveServicesEnvironment(true)
+	return newProject.WithServicesEnvironmentResolved(true)
 }
 
 // WithoutUnnecessaryResources drops networks/volumes/secrets/configs that are not referenced by active services
-func (p *Project) WithoutUnnecessaryResources() {
+// It returns a new Project instance with the changes and keep the original Project unchanged
+func (p *Project) WithoutUnnecessaryResources() *Project {
+	newProject := p.deepCopy()
 	requiredNetworks := map[string]struct{}{}
 	requiredVolumes := map[string]struct{}{}
 	requiredSecrets := map[string]struct{}{}
 	requiredConfigs := map[string]struct{}{}
-	for _, s := range p.Services {
+	for _, s := range newProject.Services {
 		for k := range s.Networks {
 			requiredNetworks[k] = struct{}{}
 		}
@@ -366,7 +379,7 @@ func (p *Project) WithoutUnnecessaryResources() {
 			networks[k] = value
 		}
 	}
-	p.Networks = networks
+	newProject.Networks = networks
 
 	volumes := Volumes{}
 	for k := range requiredVolumes {
@@ -374,7 +387,7 @@ func (p *Project) WithoutUnnecessaryResources() {
 			volumes[k] = value
 		}
 	}
-	p.Volumes = volumes
+	newProject.Volumes = volumes
 
 	secrets := Secrets{}
 	for k := range requiredSecrets {
@@ -382,7 +395,7 @@ func (p *Project) WithoutUnnecessaryResources() {
 			secrets[k] = value
 		}
 	}
-	p.Secrets = secrets
+	newProject.Secrets = secrets
 
 	configs := Configs{}
 	for k := range requiredConfigs {
@@ -390,7 +403,8 @@ func (p *Project) WithoutUnnecessaryResources() {
 			configs[k] = value
 		}
 	}
-	p.Configs = configs
+	newProject.Configs = configs
+	return newProject
 }
 
 type DependencyOption func(options *withServicesOptions)
@@ -407,25 +421,27 @@ func IgnoreDependencies(options *withServicesOptions) {
 	options.dependencyPolicy = ignoreDependencies
 }
 
-// ForServices restrict the project model to selected services and dependencies
-func (p *Project) ForServices(names []string, options ...DependencyOption) error {
+// WithSelectedServices restricts the project model to selected services and dependencies
+// It returns a new Project instance with the changes and keep the original Project unchanged
+func (p *Project) WithSelectedServices(names []string, options ...DependencyOption) (*Project, error) {
+	newProject := p.deepCopy()
 	if len(names) == 0 {
 		// All services
-		return nil
+		return newProject, nil
 	}
 
 	set := utils.NewSet[string]()
-	err := p.WithServices(names, func(name string, service ServiceConfig) error {
+	err := p.ForEachService(names, func(name string, service *ServiceConfig) error {
 		set.Add(name)
 		return nil
 	}, options...)
 	if err != nil {
-		return err
+		return nil, err
 	}
 
 	// Disable all services which are not explicit target or dependencies
 	enabled := Services{}
-	for name, s := range p.Services {
+	for name, s := range newProject.Services {
 		if _, ok := set[name]; ok {
 			// remove all dependencies but those implied by explicitly selected services
 			dependencies := s.DependsOn
@@ -437,32 +453,45 @@ func (p *Project) ForServices(names []string, options ...DependencyOption) error
 			s.DependsOn = dependencies
 			enabled[name] = s
 		} else {
-			p.DisableService(s)
+			newProject = newProject.WithServicesDisabled(name)
 		}
 	}
-	p.Services = enabled
-	return nil
+	newProject.Services = enabled
+	return newProject, nil
 }
 
-func (p *Project) DisableService(service ServiceConfig) {
-	// We should remove all dependencies which reference the disabled service
-	for i, s := range p.Services {
-		if _, ok := s.DependsOn[service.Name]; ok {
-			delete(s.DependsOn, service.Name)
-			p.Services[i] = s
-		}
+// WithServicesDisabled removes from the project model the given services and their references in all dependencies
+// It returns a new Project instance with the changes and keep the original Project unchanged
+func (p *Project) WithServicesDisabled(names ...string) *Project {
+	newProject := p.deepCopy()
+	if len(names) == 0 {
+		return newProject
+	}
+	if newProject.DisabledServices == nil {
+		newProject.DisabledServices = Services{}
 	}
-	delete(p.Services, service.Name)
-	if p.DisabledServices == nil {
-		p.DisabledServices = Services{}
+	for _, name := range names {
+		// We should remove all dependencies which reference the disabled service
+		for i, s := range newProject.Services {
+			if _, ok := s.DependsOn[name]; ok {
+				delete(s.DependsOn, name)
+				newProject.Services[i] = s
+			}
+		}
+		if service, ok := newProject.Services[name]; ok {
+			newProject.DisabledServices[name] = service
+			delete(newProject.Services, name)
+		}
 	}
-	p.DisabledServices[service.Name] = service
+	return newProject
 }
 
-// ResolveImages updates services images to include digest computed by a resolver function
-func (p *Project) ResolveImages(resolver func(named reference.Named) (godigest.Digest, error)) error {
+// WithImagesResolved updates services images to include digest computed by a resolver function
+// It returns a new Project instance with the changes and keep the original Project unchanged
+func (p *Project) WithImagesResolved(resolver func(named reference.Named) (godigest.Digest, error)) (*Project, error) {
+	newProject := p.deepCopy()
 	eg := errgroup.Group{}
-	for i, s := range p.Services {
+	for i, s := range newProject.Services {
 		idx := i
 		service := s
 
@@ -488,11 +517,11 @@ func (p *Project) ResolveImages(resolver func(named reference.Named) (godigest.D
 			}
 
 			service.Image = named.String()
-			p.Services[idx] = service
+			newProject.Services[idx] = service
 			return nil
 		})
 	}
-	return eg.Wait()
+	return newProject, eg.Wait()
 }
 
 // MarshalYAML marshal Project into a yaml tree
@@ -533,10 +562,12 @@ func (p *Project) MarshalJSON() ([]byte, error) {
 	return json.Marshal(m)
 }
 
-// ResolveServicesEnvironment parse env_files set for services to resolve the actual environment map for services
-func (p Project) ResolveServicesEnvironment(discardEnvFiles bool) error {
-	for i, service := range p.Services {
-		service.Environment = service.Environment.Resolve(p.Environment.Resolve)
+// WithServicesEnvironmentResolved parses env_files set for services to resolve the actual environment map for services
+// It returns a new Project instance with the changes and keep the original Project unchanged
+func (p Project) WithServicesEnvironmentResolved(discardEnvFiles bool) (*Project, error) {
+	newProject := p.deepCopy()
+	for i, service := range newProject.Services {
+		service.Environment = service.Environment.Resolve(newProject.Environment.Resolve)
 
 		environment := MappingWithEquals{}
 		// resolve variables based on other files we already parsed, + project's environment
@@ -545,24 +576,24 @@ func (p Project) ResolveServicesEnvironment(discardEnvFiles bool) error {
 			if ok && v != nil {
 				return *v, ok
 			}
-			return p.Environment.Resolve(s)
+			return newProject.Environment.Resolve(s)
 		}
 
 		for _, envFile := range service.EnvFiles {
 			if _, err := os.Stat(envFile.Path); os.IsNotExist(err) {
 				if envFile.Required {
-					return errors.Wrapf(err, "env file %s not found", envFile.Path)
+					return nil, errors.Wrapf(err, "env file %s not found", envFile.Path)
 				}
 				continue
 			}
 			b, err := os.ReadFile(envFile.Path)
 			if err != nil {
-				return errors.Wrapf(err, "failed to load %s", envFile.Path)
+				return nil, errors.Wrapf(err, "failed to load %s", envFile.Path)
 			}
 
 			fileVars, err := dotenv.ParseWithLookup(bytes.NewBuffer(b), resolve)
 			if err != nil {
-				return errors.Wrapf(err, "failed to read %s", envFile.Path)
+				return nil, errors.Wrapf(err, "failed to read %s", envFile.Path)
 			}
 			environment.OverrideBy(Mapping(fileVars).ToMappingWithEquals())
 		}
@@ -572,7 +603,15 @@ func (p Project) ResolveServicesEnvironment(discardEnvFiles bool) error {
 		if discardEnvFiles {
 			service.EnvFiles = nil
 		}
-		p.Services[i] = service
+		newProject.Services[i] = service
 	}
-	return nil
+	return newProject, nil
+}
+
+func (p *Project) deepCopy() *Project {
+	instance, err := copystructure.Copy(p)
+	if err != nil {
+		panic(err)
+	}
+	return instance.(*Project)
 }
diff --git a/types/types.go b/types/types.go
index 3ec9223..103db08 100644
--- a/types/types.go
+++ b/types/types.go
@@ -23,6 +23,7 @@ import (
 	"strings"
 
 	"github.com/docker/go-connections/nat"
+	"github.com/mitchellh/copystructure"
 )
 
 // ServiceConfig is the configuration of one service
@@ -187,6 +188,14 @@ func (s *ServiceConfig) SetScale(scale int) {
 	}
 }
 
+func (s *ServiceConfig) deepCopy() *ServiceConfig {
+	instance, err := copystructure.Copy(s)
+	if err != nil {
+		panic(err)
+	}
+	return instance.(*ServiceConfig)
+}
+
 const (
 	// PullPolicyAlways always pull images
 	PullPolicyAlways = "always"
@@ -247,22 +256,6 @@ func (s ServiceConfig) GetDependents(p *Project) []string {
 	return dependent
 }
 
-type set map[string]struct{}
-
-func (s set) append(strs ...string) {
-	for _, str := range strs {
-		s[str] = struct{}{}
-	}
-}
-
-func (s set) toSlice() []string {
-	slice := make([]string, 0, len(s))
-	for v := range s {
-		slice = append(slice, v)
-	}
-	return slice
-}
-
 // BuildConfig is a type for build
 type BuildConfig struct {
 	Context            string                    `yaml:"context,omitempty" json:"context,omitempty"`


Test patch:
diff --git a/loader/loader_test.go b/loader/loader_test.go
index 76bedb9..4b9d6bc 100644
--- a/loader/loader_test.go
+++ b/loader/loader_test.go
@@ -2131,7 +2131,7 @@ func TestLoadServiceWithEnvFile(t *testing.T) {
 			},
 		},
 	}
-	err = p.ResolveServicesEnvironment(false)
+	p, err = p.WithServicesEnvironmentResolved(false)
 	assert.NilError(t, err)
 	service, err := p.GetService("test")
 	assert.NilError(t, err)
diff --git a/types/config_test.go b/types/config_test.go
index e0730b5..13962fb 100644
--- a/types/config_test.go
+++ b/types/config_test.go
@@ -23,7 +23,7 @@ import (
 )
 
 func Test_WithServices(t *testing.T) {
-	p := Project{
+	p := &Project{
 		Services: Services{
 			"service_1": ServiceConfig{
 				Name: "service_1",
@@ -49,12 +49,12 @@ func Test_WithServices(t *testing.T) {
 		},
 	}
 	order := []string{}
-	fn := func(name string, _ ServiceConfig) error {
+	fn := func(name string, _ *ServiceConfig) error {
 		order = append(order, name)
 		return nil
 	}
 
-	err := p.WithServices(nil, fn)
+	err := p.ForEachService(nil, fn)
 	assert.NilError(t, err)
 	assert.DeepEqual(t, order, []string{"service_2", "service_3", "service_1"})
 }
diff --git a/types/project_test.go b/types/project_test.go
index 5aed8da..7d226c2 100644
--- a/types/project_test.go
+++ b/types/project_test.go
@@ -28,11 +28,12 @@ import (
 
 func Test_ApplyProfiles(t *testing.T) {
 	p := makeProject()
-	p.ApplyProfiles([]string{"foo"})
+	p, err := p.WithProfiles([]string{"foo"})
+	assert.NilError(t, err)
 	assert.DeepEqual(t, p.ServiceNames(), []string{"service_1", "service_2", "service_6"})
 	assert.DeepEqual(t, p.DisabledServiceNames(), []string{"service_3", "service_4", "service_5"})
 
-	err := p.EnableServices("service_4")
+	p, err = p.WithServicesEnabled("service_4")
 	assert.NilError(t, err)
 
 	assert.DeepEqual(t, p.ServiceNames(), []string{"service_1", "service_2", "service_4", "service_5", "service_6"})
@@ -46,7 +47,7 @@ func Test_WithoutUnnecessaryResources(t *testing.T) {
 	p.Volumes["unused"] = VolumeConfig{}
 	p.Secrets["unused"] = SecretConfig{}
 	p.Configs["unused"] = ConfigObjConfig{}
-	p.WithoutUnnecessaryResources()
+	p = p.WithoutUnnecessaryResources()
 	if _, ok := p.Networks["unused"]; ok {
 		t.Fail()
 	}
@@ -63,7 +64,8 @@ func Test_WithoutUnnecessaryResources(t *testing.T) {
 
 func Test_NoProfiles(t *testing.T) {
 	p := makeProject()
-	p.ApplyProfiles(nil)
+	p, err := p.WithProfiles(nil)
+	assert.NilError(t, err)
 	assert.Equal(t, len(p.Services), 2)
 	assert.Equal(t, len(p.DisabledServices), 4)
 	assert.DeepEqual(t, p.ServiceNames(), []string{"service_1", "service_6"})
@@ -81,21 +83,21 @@ func Test_ServiceProfiles(t *testing.T) {
 
 func Test_ForServices(t *testing.T) {
 	p := makeProject()
-	err := p.ForServices([]string{"service_2"})
+	p, err := p.WithSelectedServices([]string{"service_2"})
 	assert.NilError(t, err)
 
 	assert.DeepEqual(t, p.DisabledServiceNames(), []string{"service_3", "service_4", "service_5", "service_6"})
 
 	// Should not load the dependency service_1 when explicitly loading service_6
 	p = makeProject()
-	err = p.ForServices([]string{"service_6"})
+	p, err = p.WithSelectedServices([]string{"service_6"})
 	assert.NilError(t, err)
 	assert.DeepEqual(t, p.DisabledServiceNames(), []string{"service_1", "service_2", "service_3", "service_4", "service_5"})
 }
 
 func Test_ForServicesIgnoreDependencies(t *testing.T) {
 	p := makeProject()
-	err := p.ForServices([]string{"service_2"}, IgnoreDependencies)
+	p, err := p.WithSelectedServices([]string{"service_2"}, IgnoreDependencies)
 	assert.NilError(t, err)
 
 	assert.Equal(t, len(p.DisabledServices), 5)
@@ -104,7 +106,7 @@ func Test_ForServicesIgnoreDependencies(t *testing.T) {
 	assert.Equal(t, len(service.DependsOn), 0)
 
 	p = makeProject()
-	err = p.ForServices([]string{"service_2", "service_3"}, IgnoreDependencies)
+	p, err = p.WithSelectedServices([]string{"service_2", "service_3"}, IgnoreDependencies)
 	assert.NilError(t, err)
 
 	assert.Equal(t, len(p.DisabledServices), 4)
@@ -120,12 +122,12 @@ func Test_ForServicesCycle(t *testing.T) {
 	service := p.Services["service_1"]
 	service.Links = []string{"service_2"}
 	p.Services["service_1"] = service
-	err := p.ForServices([]string{"service_2"})
+	p, err := p.WithSelectedServices([]string{"service_2"})
 	assert.NilError(t, err)
 }
 
-func makeProject() Project {
-	return Project{
+func makeProject() *Project {
+	return &Project{
 		Services: Services{
 			"service_1": ServiceConfig{
 				Name: "service_1",
@@ -197,7 +199,7 @@ func Test_ResolveImages(t *testing.T) {
 		service := p.Services["service_1"]
 		service.Image = test.image
 		p.Services["service_1"] = service
-		err := p.ResolveImages(resolver)
+		p, err := p.WithImagesResolved(resolver)
 		assert.NilError(t, err)
 		assert.Equal(t, p.Services["service_1"].Image, test.resolved)
 	}
@@ -206,7 +208,7 @@ func Test_ResolveImages(t *testing.T) {
 func TestWithServices(t *testing.T) {
 	p := makeProject()
 	var seen []string
-	err := p.WithServices([]string{"service_3"}, func(name string, _ ServiceConfig) error {
+	err := p.ForEachService([]string{"service_3"}, func(name string, _ *ServiceConfig) error {
 		seen = append(seen, name)
 		return nil
 	}, IncludeDependencies)
@@ -214,7 +216,7 @@ func TestWithServices(t *testing.T) {
 	assert.DeepEqual(t, seen, []string{"service_1", "service_2", "service_3"})
 
 	seen = []string{}
-	err = p.WithServices([]string{"service_1"}, func(name string, _ ServiceConfig) error {
+	err = p.ForEachService([]string{"service_1"}, func(name string, _ *ServiceConfig) error {
 		seen = append(seen, name)
 		return nil
 	}, IncludeDependents)
@@ -223,7 +225,7 @@ func TestWithServices(t *testing.T) {
 	assert.Check(t, utils.ArrayContains(seen, []string{"service_3", "service_4", "service_2", "service_1"}))
 
 	seen = []string{}
-	err = p.WithServices([]string{"service_1"}, func(name string, _ ServiceConfig) error {
+	err = p.ForEachService([]string{"service_1"}, func(name string, _ *ServiceConfig) error {
 		seen = append(seen, name)
 		return nil
 	}, IgnoreDependencies)
@@ -231,7 +233,7 @@ func TestWithServices(t *testing.T) {
 	assert.DeepEqual(t, seen, []string{"service_1"})
 
 	seen = []string{}
-	err = p.WithServices([]string{"service_4"}, func(name string, _ ServiceConfig) error {
+	err = p.ForEachService([]string{"service_4"}, func(name string, _ *ServiceConfig) error {
 		seen = append(seen, name)
 		return nil
 	}, IncludeDependencies)
diff --git a/types/types_test.go b/types/types_test.go
index 495b8fe..08df5c8 100644
--- a/types/types_test.go
+++ b/types/types_test.go
@@ -211,15 +211,6 @@ func assertContains(t *testing.T, portConfigs []ServicePortConfig, expected Serv
 	}
 }
 
-func TestSet(t *testing.T) {
-	s := make(set)
-	s.append("one")
-	s.append("two")
-	s.append("three")
-	s.append("two")
-	assert.Equal(t, len(s.toSlice()), 3)
-}
-
 type foo struct {
 	Bar string
 }