Skip to content

Encapsulate Collection

When an object owns a collection — a list, a set, a map — do not expose the raw container. Return a copy or a read-only view from the getter, and provide dedicated add and remove methods for changes. The owner becomes the single authority over its own contents, free to enforce invariants the moment anything is added or removed.

The smell is a getter that returns the live, mutable collection itself. The owning object thinks it controls its contents, but in truth any caller can hold the reference and push, splice, clear, or reorder behind the owner’s back. Invariants quietly rot: a class that promises “the order total always matches its line items” cannot keep that promise if outsiders can mutate the line-item list directly.

An Order whose items list is exposed raw, letting callers mutate it. After, the list is private; reads return a defensive copy and changes go through methods that can keep a derived total correct.

// Before
class Order {
items: LineItem[] = [];
}
const order = new Order();
order.items.push(item); // bypasses the owner entirely
// After
class Order {
#items: LineItem[] = [];
get items(): readonly LineItem[] {
return [...this.#items]; // defensive copy
}
addItem(item: LineItem): void {
this.#items.push(item);
}
removeItem(item: LineItem): void {
this.#items = this.#items.filter((i) => i !== item);
}
get total(): number {
return this.#items.reduce((sum, i) => sum + i.price, 0);
}
}
flowchart LR
  subgraph Before["Before"]
    A["caller"] -->|"gets raw list"| B[("items[]")]
    A -->|"push() / splice() directly"| B
  end
  subgraph After["After"]
    C["caller"] -->|"items() → read-only copy"| D[("items[] (private)")]
    C -->|"addItem() / removeItem()"| D
  end
  Before -.->|"Encapsulate Collection"| After
A raw mutable list becomes a guarded collection with add and remove methods
  1. Add explicit add and remove methods (or their equivalents) for the collection. Internally they mutate the private field.
  2. Find every external site that mutates the collection directly and reroute it through the new methods. Work in small batches.
  3. Run your tests after each batch.
  4. Change the getter so it no longer returns the live container. Return a copy, a read-only view, or an iterator — whatever your language offers to prevent outside mutation.
  5. Make the field itself private or unexported.
  6. Run your tests again. With every mutation already going through methods, nothing should break — and now the owner can enforce invariants in those methods.

Apply this whenever a class owns a collection that represents part of its state and especially when the collection participates in an invariant. Encapsulating it lets the owner validate additions, reject duplicates, keep a derived value (like a running total) correct, and prevent silent corruption from a distant caller.

The trade-off is the cost of copying on each read and the slightly larger interface. For a small collection read occasionally, the copy is negligible insurance. For a very large or hot-path collection, prefer a read-only view over a full copy where your language supports one, so you avoid the allocation while still blocking mutation.

What is the core rule of Encapsulate Collection?
Why is returning the live collection from a getter dangerous?
What should the add and remove methods make possible?
For a very large, hot-path collection, what is often preferable to a full copy on read?