Practical guide
The getter that lets a consumer modify the module
A seemingly innocent Go getter returns its internal slice, letting consumers change the module and making previously returned results change unexpectedly.
A collection offers two simple messages:
type Collection struct {
items []string
}
func (c *Collection) Add(item string) {
c.items = append(c.items, item)
}
func (c *Collection) Items() []string {
return c.items
}Items looks like a read-only getter. The name does not contain Set, the consumer receives a value, and the items field remains private.
However, a slice does not contain its elements directly. It refers to an underlying array. By returning the internal slice, the module shares its state:
collection := &Collection{}
collection.Add("first")
items := collection.Items()
items[0] = "changed from outside"
fmt.Println(collection.Items()[0])
// changed from outsideThe consumer has modified Collection without sending it a message. Add is no longer the only conversation that changes the content. Any write through the result of Items can change it too.
The visible API says one thing; memory ownership allows another.
Two owners for the same data#
The problem is not limited to the fact that a consumer can behave badly. The provider can also change a result that the consumer believed to be theirs.
collection := &Collection{}
collection.Add("first")
collection.Add("second")
snapshot := collection.Items()
collection.items[0] = "updated internally"
fmt.Println(snapshot[0])
// updated internallyThe variable is called snapshot, but it is not a photograph. It is another window to the same array.
Now neither party can reason locally:
Collectiondoes not track all changes to its state;- the consumer does not know how long the received result will remain true;
- a test can pass or fail depending on which side retains capacity or performs an
append; - changing the internal representation requires discovering who depended on that shared memory.
It is not a detail exclusive to Go. The same bug appears when returning maps, pointers, mutable lists or internal objects in other languages. Go makes it especially easy to miss because the slice is copied upon return, but its underlying array can still be shared.
Decide what the result means#
Before choosing a solution, a contract question must be answered:
Does the consumer receive an independent result or temporary access to the live state of the module?
The two conversations may be valid, but they are not interchangeable.
If the consumer needs a stable observation that it can store, compare, or pass elsewhere, it needs an autonomous result. If it truly needs to observe later changes, the API should express that as a subscription, an iterator with lifetime rules, or an explicit live view. A getter returning []string communicates none of those restrictions.
For this collection we choose the first guarantee:
Items() returns the items present when the call begins.
Modifying the result does not modify the Collection.
Subsequent Collection changes do not modify the delivered result.The smallest implementation is to copy:
func (c *Collection) Items() []string {
return append([]string(nil), c.items...)
}The consumer now owns the returned slice and Collection keeps its own.
The test must check both directions#
A test that only compares the initial content does not detect the problem:
func TestItemsReturnsCurrentItems(t *testing.T) {
collection := &Collection{}
collection.Add("first")
got := collection.Items()
if !slices.Equal(got, []string{"first"}) {
t.Fatalf("unexpected items: %v", got)
}
}It passes whether the result is independent or shares the internal array. The contrast appears when either side changes after delivery:
func TestItemsReturnsAnIndependentResult(t *testing.T) {
collection := &Collection{}
collection.Add("first")
got := collection.Items()
got[0] = "changed outside"
current := collection.Items()
if current[0] != "first" {
t.Fatalf("consumer changed collection: %v", current)
}
}And in the other direction:
func TestPreviousResultDoesNotChangeWithCollection(t *testing.T) {
collection := &Collection{}
collection.Add("first")
previous := collection.Items()
collection.Rename(0, "renamed internally")
if previous[0] != "first" {
t.Fatalf("previous result changed: %v", previous)
}
}The tests do not require append, copy or a specific representation. A persistent collection or immutable snapshot could also fulfill the contract.
A shallow copy can still share state#
Copying the outer slice is not enough when its elements contain mutable references:
type Item struct {
Labels []string
}
func (c *Collection) Items() []Item {
return append([]Item(nil), c.items...)
}The result has a new outer slice, but each Item.Labels can still point to the same array that the module holds. The same goes for maps and pointers inside copied structs.
The copy must reach the level where there is shared mutability:
func cloneItem(item Item) Item {
return Item{
Labels: append([]string(nil), item.Labels...),
}
}
func (c *Collection) Items() []Item {
result := make([]Item, len(c.items))
for i, item := range c.items {
result[i] = cloneItem(item)
}
return result
}Not every value needs to be copied by default. Ownership must match the promise. An immutable value can be shared. An explicit transfer can avoid a copy. A large result can be traversed through its own API. The dangerous choice is leaving ownership implicit.
The autonomy foundation does not ask us to isolate every value. It asks each module and result to preserve a boundary that lets both sides reason without accidental coordination.
A result is autonomous when receiving it also means knowing who can change it.