# 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.

- kind: technique
- category: organizing-data
- canonical: https://aipatterns.dev/refactoring/techniques/encapsulate-collection
- inspired-by: https://refactoring.guru/encapsulate-collection

## 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

```ts
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

```ts
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.

## Related

- Fixes [Data Class](https://aipatterns.dev/refactoring/smells/data-class.md) and [Inappropriate Intimacy](https://aipatterns.dev/refactoring/smells/inappropriate-intimacy.md).
- A special case of [Encapsulate Field](https://aipatterns.dev/refactoring/techniques/encapsulate-field.md).
- Required for keeping both sides consistent in [Change Unidirectional Association to Bidirectional](https://aipatterns.dev/refactoring/techniques/change-unidirectional-association-to-bidirectional.md).

## 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.
