Skip to content
aipatterns.dev

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

TypeScript
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

TypeScript
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

  1. Add add and remove methods (named for the domain: assign, unassign) that modify the internal collection.
  2. Initialize the field to an empty collection so the methods never see undefined.
  3. Replace client mutations (push, splice, delete) with the new methods. Run the tests.
  4. Replace whole-collection assignment with a loop over add, or a dedicated method (replaceStaff(ids)) that validates. Remove the setter.
  5. Change the getter to return a read-only type, a frozen copy, or an iterator. Run the tests.

Watch out

  • A ReadonlySet or ReadonlyArray type 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.

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

Original writing and examples. Topic coverage follows Refactoring.Guru, which explains this topic with its own illustrations.