EncapsulationGENERALLANGUAGE-SPECIFICPARADIGM-SPECIFIC

Encapsulation

Callers reach state through operations that can enforce a rule, rather than through the data itself — so the rule has somewhere to live.

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.

The question

Which parts of a module may a caller touch directly, and what does the module gain by refusing the rest?

The requirement

The cart must enforce quantity limits: at most ten of any single line item, and at most fifty items in total. Nobody should be able to get a cart past those limits by any route.

The obvious build

Make Cart a bag of data with a public items array. Every caller already knows what a cart is; letting them push, splice and filter it is honest, obvious, and needs no ceremony. When a limit is required, add the check at the place that adds items.

Why it breaks

The check goes into checkout, where the requirement arrived. The bulk-import job appends to items directly and never learns about the limit, so a cart of two hundred rows exists in production and nothing is technically broken until the warehouse reads it.

How it breaks as requirements change
  • The check goes into checkout, where the requirement arrived. The bulk-import job appends to items directly and never learns about the limit, so a cart of two hundred rows exists in production and nothing is technically broken until the warehouse reads it.
  • A second limit arrives — "no more than three of a restricted item" — and there is no single place to put it, so it goes into whichever caller reported the bug. Now the rule is in two places and they disagree about restricted items in the import path (Duplicate Knowledge).
  • Someone calls cart.items.sort() for a display concern and mutates the cart in place. It is not a bug today; it becomes one when the checkout starts assuming insertion order is stable.
  • The array being public means its *type* is a contract. Changing from an array to a keyed map to make quantity lookups cheap now touches all eleven callers, which is why nobody does it and the O(n) scan stays.
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

What limits the solution, and what must never stop being true

This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.

Constraints
  • The cart type is already used in eleven places — checkout, the admin tools, a bulk-import job, two test fixtures — and they all currently reach the item list directly.
  • The bulk-import job adds hundreds of items in a loop and is on a latency budget, so a per-item check has to be cheap.
  • TypeScript, so private is a compile-time convention and a determined caller can still cast around it. The boundary has to be worth respecting rather than merely enforced.
Invariants
  • No cart, at any moment another module can observe it, holds more than ten of a single item or more than fifty items in total.
  • The total shown to the customer is the sum of the lines actually in the cart — no path adds a line without the total noticing.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • Cart owns the answer to "what is in this cart" and every rule about what is allowed to be in one. Nothing else may decide that.
  • Callers own the *intent* — add this product, remove that line, show me the total — and own none of the mechanics.
  • The rendering layer owns presentation order, which is why it must be given a copy or a projection rather than the list itself.
Boundaries
  • The seam is the type, not the layer: everything that can change a cart goes through Cart's operations, and there is no second door.
  • The boundary is drawn around the invariant, because a boundary that does not contain a rule is only a naming convention (Where Invariants Live).
  • Reads cross the boundary too. A getter that hands back the live array is the same door in a friendlier coat (Exposing Too Much).

The door, and the door nobody closed

The difference between these two carts is not style. In the first, the limit rule can be enforced in exactly one place and every caller reaches it whether it knows about it or not. In the second, the rule is enforceable only in the callers that remember, and the set of callers grows.

Notice what the second version costs on the *read* side too. Returning the array means the caller can sort it, splice it, or hold it after the cart is gone — none of which anyone intends, and all of which will eventually happen.

Where the rule can live
The cart is a bag of data
export class Cart {
  items: CartLine[] = []
}

// checkout.ts
if (cart.items.length < 50) cart.items.push(line)

// bulk-import.ts   — written later, by someone else
for (const line of rows) cart.items.push(line)   // no limit, no idea there was one
The cart owns what a cart may be
export class Cart {
  #lines = new Map<ProductId, CartLine>()

  addItem(p: ProductId, qty: number): Result<void, CartLimit> {
    const next = (this.#lines.get(p)?.qty ?? 0) + qty
    if (next > 10) return err({ kind: 'per-item', product: p })
    if (this.count() + qty > 50) return err({ kind: 'total' })
    this.#lines.set(p, { product: p, qty: next })
    return ok()
  }

  removeItem(p: ProductId): void { this.#lines.delete(p) }
  total(prices: PriceList): Money { /* sums #lines */ }
  lines(): readonly CartLine[] { return [...this.#lines.values()] }
}

The rule now has an address. Every route that can change a cart passes through addItem, including the bulk-import job written a year later by someone who never read the ticket — and that is the actual mechanism by which the invariant survives staff turnover. The Map is a bonus the boundary made possible: with a public array the representation is a contract and cannot move.

What the module knows, and every reason it changes

Encapsulation is worth doing when the thing behind the boundary has rules. Run the responsibility check before you build the wall: if the unit knows nothing that needs protecting and has one reason to change, a plain record is the better design and the class is ceremony (Value Objects).

The changesWhen list below is short, and short is the finding. A cart that also knew about tax rates, stock levels and shipping zones would have four reasons to change, and its boundary would contain four unrelated rules that pull it in different directions (Single Responsibility, Carefully).

responsibilitiesCartCart, after the rules move inside
Knows
  • Which product lines it holds and in what quantity
  • The per-item and total quantity limits
  • How to compute its own total given a price list
Does
  • Accepts or refuses an item
  • Removes a line
  • Reports its lines as a snapshot the caller cannot mutate
Depends on
  • ProductId and Money — value types with no behaviour of their own
  • A PriceList passed in at the moment a total is asked for, never held
Changes when — 2 distinct reasons
  • A quantity rule changes
  • The notion of what a cart line is changes — a gift message, a subscription term

Two reasons to change, both about carts. The price list being a parameter rather than a field is what keeps pricing out of this list — the cart would otherwise change every time pricing did, and pricing changes constantly (Volatile Dependencies).

What it buys, and what it does not

SIMPLIFIEDThe change below is priced for a single-process, single-writer cart. Once two requests can hold the same cart, or a second service writes the same rows, the in-memory invariant is not the system invariant and the enforcement point moves to the database transaction — where the module boundary provides nothing at all.

The honest argument for encapsulation is narrow and strong: it makes rule changes cheap and representation changes possible. It is not an argument that the code is safer in general, and the change below shows exactly where the protection stops.

Operating Systems uses the same word for something else — a process cannot reach another process's memory because hardware forbids it, not because a convention discourages it. That is enforcement; this is structure. Worth knowing the difference before you describe a # field as a security boundary (Trust Boundaries).

Add "no more than three units of a restricted item"
The change

Regulated products get a per-customer cap of three units, checked whenever the cart changes and again at checkout.

Public `items` array; limits checked wherever someone remembered
CheckoutServiceBulkImportJobAdminCartEditorCartRestoreOnLoginPromotionApplier
testscheckout_testbulk_import_testadmin_testrestore_testpromotion_test
5 modules · 5 test files

Five write sites, and the expensive part is proving there is no sixth. Two of them will implement the rule slightly differently and the difference will be found by a compliance audit rather than a test.

`Cart` owns every mutation
Cart
testscart_testcheckout_integration_test
1 module · 2 test files

One edit inside addItem, one new case in the cart's own tests, and one integration test that the checkout surfaces the refusal. No caller changes, because no caller ever knew how a limit was checked.

what it cost Every caller now queues behind one module: the promotions team and the compliance team edit the same file and the same test suite in the same sprint. The cart also became a coordination point for reviews, and each new read a caller needs is now a request to the cart's owner rather than a line of code they write themselves.

How to build it

Most important first.

  • Give the module operations named for what the caller means: addItem, removeItem, total. The vocabulary is the interface, and it should be the domain's vocabulary rather than the data structure's (Naming and Domain Language).
  • Make the state private and unreachable — no getter returning the mutable collection, no field the caller can reassign (Exposing Too Much).
  • Put the limit check inside addItem, once, so every route into the cart passes it including the ones written next year.
  • Return the failure rather than throwing past the caller: a limit breach is an expected business outcome, not a bug (An Error Taxonomy That Survives Contact).
  • Give the bulk path its own operation — addItems(lines) — rather than telling it to loop. Batch intent deserves a batch operation, or the caller will route around you for performance (Cost-Aware Interfaces).

What the next change costs

The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.

Cost of the next change
  • Adding a third rule — "no restricted items after 22:00" — costs one edit inside Cart and one test, and no caller changes. Under the public array it costs an audit of every write site, plus the argument about which sites are supposed to be exempt.
  • Changing the representation from an array to a Map keyed by product id costs one module and zero callers. Under the public array it costs eleven modules and is therefore never done.
  • What did *not* get cheaper: adding a new *kind* of thing to a cart — a gift message, a subscription line — still changes the cart's own type and every operation that pattern-matches on line kind. Encapsulating state protects the rules, not the shape of the data.
What the recommended approach costs
  • Every legitimate new read becomes a new method. Callers that need something the module did not anticipate wait for you, and the pressure to add a generic escape hatch is constant and reasonable-sounding.
  • Copying on read costs allocation. For a cart it is nothing; for a hot path over a large collection it is the reason people expose internals, and it is a real argument rather than laziness (Allocation and Copies).
  • A narrow interface makes some things awkward on purpose. That awkwardness is information — but it is also friction that a team under deadline will route around unless someone explains why the door is shut.

What can go wrong

Failure modes
  • The operations exist and one caller still reaches the array, because a getItems() was added "just for the admin screen". One door left open is the same as no doors.
  • The invariant is enforced in addItem but not in the constructor, so a cart deserialized from a stale record starts life illegal (Invariant Leaks).
  • Encapsulation is achieved and nothing is hidden: the module exposes getItemArray, setItemArray and getItemCount, which is the data structure with a longer spelling (Information Hiding).
  • The mitigation itself fails under concurrency: two requests each read a legal cart, each add an item, and the stored result is illegal. In-process privacy says nothing about an interleaved write (The Thread-Safety Contract).
Dependencies, and their direction
  • Eleven modules now depend on Cart's operations rather than on its representation. The dependency got narrower, not smaller, and narrow is the property that matters.
  • Cart depends on nothing: no repository, no clock, no HTTP. That is what makes it the cheapest thing in the codebase to test (Volatile Dependencies).
  • The direction is inward — callers depend on the cart, the cart depends on no caller — which is what lets the representation change without a coordinated release (Dependency Direction).
Misreads
  • "Encapsulation means private fields and getters." Getters that expose every field are the public data structure with more typing, and often worse because the ceremony implies a protection that is not there (Exposing Too Much).
  • "So make everything private." The point is that *state with a rule attached* is reached through operations. A value object with no rules and no identity should be plain, readable data (Value Objects).
  • "This is the same as information hiding." It is not, and the difference is the next lesson: this hides state; information hiding hides decisions likely to change (Information Hiding).
  • "Encapsulation makes code safe." It makes one rule enforceable at one boundary in one process. Two concurrent writers, or a second service with database access, walk straight past it (State Ownership).
Smells this explains
  • primitive-obsession
  • feature-envy

Testing it, and how it ages

What to test, and at which boundary
  • Test the rule at the module boundary: construct a cart, drive it with operations, assert the limit holds. No mock is involved because there is nothing to mock (What a Unit Is).
  • Test the routes you did not think of: deserialization, cloning, merging two carts. Those are the paths where invariants leak (Enforcing Invariants).
  • One property test that any sequence of legal operations leaves a legal cart is worth a dozen examples here, because the invariant is universally quantified (Property-Based Testing).
  • Do not test that items is private. That is a test of the language, and it will break when the field is renamed for reasons nobody cares about (Testing as Design Feedback).
How this design ages
  • The first two years the operations grow: applyCoupon, mergeGuestCart, splitByWarehouse. That growth is the boundary doing its job, and each one is cheap because the rules already have an address.
  • Around the point where the cart has thirty operations, the pressure changes: it is now a module rather than a type, and the useful move is to split by reason-to-change rather than to keep adding (Divergent Change).
  • It stops being right when the cart must be edited by two processes at once, or must be persisted incrementally. At that point the in-memory invariant is not the real invariant and the boundary moves to the transaction (Consistency Boundaries).

Where this applies

This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.

  • GENERALThat a rule needs a single point every mutation passes through holds in any paradigm; what differs is the mechanism — a closure, a module, an opaque type or a class — not the requirement that only one door exists.
  • LANGUAGE-SPECIFICIn Rust ownership makes the leak structural: handing out &mut Vec<Item> is visible in the signature and the borrow checker limits the damage. In TypeScript, Python or Java, returning the live collection compiles silently and the leak is found by a bug, so those languages need a defensive copy and a test where Rust needs neither.
  • PARADIGM-SPECIFICA functional design gets the same guarantee differently: the cart is immutable and every operation returns a new one, so there is no mutation to guard, only a constructor to keep private. The rule still needs exactly one home — it just becomes a smart constructor rather than a method (Immutability).

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Domains that do not exist yet
  • Testing & Reliability Engineering — a well-encapsulated type is the cheapest thing in a system to test exhaustively, which is why testability is usually the first signal that a boundary is in the right place.