Refactoring techniqueOrganizing Data
Encapsulate Collection
Stop a getter from handing out a mutable internal collection; return a read-only view and add methods to add and remove items.
Problem
A class keeps a list or set and its getter returns the internal instance. Clients push, splice and clear it directly, bypassing any rules the class should enforce, and the class can't tell when its contents change.
Solution
Return a read-only view or a copy from the getter, and add explicit add and remove methods that enforce the rules. Remove any setter that replaces the whole collection.
Before
class Shift {
public staff: string[] = [];
constructor(readonly id: string, readonly maxStaff: number) {}
}
// scheduling UI
shift.staff.push(nurseId);
// importer
shift.staff = rows.map((r) => r.employeeId);
// report
const overbooked = shift.staff.length > shift.maxStaff;After
class Shift {
private readonly _staff = new Set<string>();
constructor(readonly id: string, readonly maxStaff: number) {}
get staff(): ReadonlySet<string> {
return this._staff;
}
assign(employeeId: string): void {
if (this._staff.size >= this.maxStaff) {
throw new Error(`shift ${this.id} is full`);
}
this._staff.add(employeeId);
}
unassign(employeeId: string): void {
this._staff.delete(employeeId);
}
}
// scheduling UI
shift.assign(nurseId);
// importer
rows.forEach((r) => shift.assign(r.employeeId));Why
- The staffing limit is enforced on every path, including bulk import.
- Overbooking is impossible, so the report check is gone.
- The class can add logging, events or audit trails in one place.
Steps
- Add
addandremovemethods (named for the domain:assign,unassign) that modify the internal collection. - Initialize the field to an empty collection so the methods never see
undefined. - Replace client mutations (
push,splice,delete) with the new methods. Run the tests. - Replace whole-collection assignment with a loop over
add, or a dedicated method (replaceStaff(ids)) that validates. Remove the setter. - Change the getter to return a read-only type, a frozen copy, or an iterator. Run the tests.
Watch out
- A
ReadonlySetorReadonlyArraytype in TypeScript is a compile-time guard only. A determined caller can cast it. Return a copy if the boundary is untrusted or crosses plugin code. - Returning a copy on each call costs allocations; avoid calling the getter in tight loops.
- Behavior change: the old code allowed states the new methods reject, such as an overbooked shift. Check existing data and imports.
- ORMs often need a mutable collection field to map relations. Keep the mapped field private and expose the read-only view.
- Inverse: none; exposing the raw collection again is the smell.
Related
- Fixes Data Class and Inappropriate Intimacy.
- A special case of Encapsulate Field.
- Required for keeping both sides consistent in Change Unidirectional Association to Bidirectional.
Agent checklist
- No code outside the class mutates the collection directly.
- The getter returns a read-only view or copy.
- Add and remove methods enforce the class's invariants.
- Whole-collection setters are removed or validated.
- Existing data that violates new invariants was found and handled.
Treats these smells
See also
- Encapsulate FieldMake a public field private and give clients accessor methods, so the class controls how its data is read and changed.
- Change Unidirectional Association to BidirectionalAdd a back-reference when each of two classes needs to navigate to the other, and keep both sides in sync through one owner.
- Move MethodMove a method to the class whose data it uses most, and leave a delegating call or update the callers.
Original writing and examples. Topic coverage follows Refactoring.Guru, which explains this topic with its own illustrations.