Encapsulate Collection
Intent
Section titled “Intent”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
Section titled “The smell”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.
Before → After
Section titled “Before → After”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.
// Beforeclass Order { items: LineItem[] = [];}const order = new Order();order.items.push(item); // bypasses the owner entirely
// Afterclass 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); }}# Beforeclass Order: def __init__(self): self.items = []order = Order()order.items.append(item) # bypasses the owner entirely
# Afterclass Order: def __init__(self): self._items = []
@property def items(self): return tuple(self._items) # read-only snapshot
def add_item(self, item): self._items.append(item)
def remove_item(self, item): self._items.remove(item)
@property def total(self): return sum(i.price for i in self._items)// Beforetype Order struct { Items []LineItem // exported: anyone can mutate}order.Items = append(order.Items, item) // bypasses the owner
// Aftertype Order struct { items []LineItem // unexported}
func (o *Order) Items() []LineItem { out := make([]LineItem, len(o.items)) copy(out, o.items) // defensive copy return out}
func (o *Order) AddItem(item LineItem) { o.items = append(o.items, item)}
func (o *Order) Total() float64 { var sum float64 for _, i := range o.items { sum += i.Price } return sum}// Beforepub struct Order { pub items: Vec<LineItem>, // public: anyone can mutate}// order.items.push(item); // bypasses the owner
// Afterpub struct Order { items: Vec<LineItem>, // private}
impl Order { pub fn items(&self) -> &[LineItem] { &self.items // read-only borrow, not a mutable handle }
pub fn add_item(&mut self, item: LineItem) { self.items.push(item); }
pub fn remove_item(&mut self, index: usize) { self.items.remove(index); }
pub fn total(&self) -> f64 { self.items.iter().map(|i| i.price).sum() }}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 Mechanics
Section titled “Mechanics”- Add explicit
addandremovemethods (or their equivalents) for the collection. Internally they mutate the private field. - Find every external site that mutates the collection directly and reroute it through the new methods. Work in small batches.
- Run your tests after each batch.
- 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.
- Make the field itself private or unexported.
- Run your tests again. With every mutation already going through methods, nothing should break — and now the owner can enforce invariants in those methods.
When to use / trade-offs
Section titled “When to use / trade-offs”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.