EncapsulationLANGUAGE-SPECIFICGENERALCONTESTED

Exposing Too Much

Three quiet ways a boundary leaks: a getter that returns mutable internals, a public field that becomes a contract, and an interface that grew to mirror its only implementation.

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

How does a module that looks properly encapsulated end up unable to change anything?

The requirement

A Schedule type holds the shifts assigned to a team. Rota planning, payroll, the mobile app and a reporting job all read it, and now overtime rules have to be enforced whenever a shift is added.

The obvious build

The type is fine — the field is private and there is a getShifts() accessor, which is what encapsulation means. Add the overtime check to addShift and the rule is enforced.

Why it breaks

getShifts() returns the live array. Payroll calls schedule.getShifts().push(correction) — reasonably, from its point of view — and the overtime rule is bypassed by a call that never touched addShift (Shared-State Coupling).

How it breaks as requirements change
  • getShifts() returns the live array. Payroll calls schedule.getShifts().push(correction) — reasonably, from its point of view — and the overtime rule is bypassed by a call that never touched addShift (Shared-State Coupling).
  • The mobile app iterates the returned array and caches it. When a shift is removed the app is holding a list that no longer exists, and the bug reproduces only on stale clients.
  • A publishedAt field was made public "temporarily" for a report. It is now read in nine places and one of them writes it, so the published-immutability invariant has an exception nobody documented (Invariant Leaks).
  • The ScheduleSource interface was extracted for testing and has one implementation. Over three years every method of that implementation was added to the interface, so the interface is now the class with an extra file — and the second implementation, when it arrives, must implement refreshMaterializedView().
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 type has existed for three years and its author has left. Every current usage is legitimate; none were reviewed against a design.
  • The mobile app is deployed separately and can be up to two versions behind, so anything it depends on is frozen for a release cycle (Backward Compatibility as a Constraint).
  • The reporting job is nobody's responsibility and breaks silently, which means changes to shared shapes are discovered by an angry finance email.
Invariants
  • No employee is scheduled for more than the legal maximum in a rolling seven-day window.
  • A schedule that has been published cannot change without producing a change record, because payroll reconciles against it.

Who owns what, and where the seams fall

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

Responsibilities
  • Schedule owns which shifts exist and every rule about legal combinations of them.
  • Readers own presentation and analysis and own no mutation, which means what they receive must be something they cannot mutate.
  • Whoever declares a member public owns the promise that it will still be there and still mean the same thing in two years.
Boundaries
  • The boundary includes the *type* of everything crossing it: a private field returned by reference is outside the boundary regardless of the keyword in front of it.
  • Read access and write access are separate decisions. Most leaks come from granting write access accidentally while intending to grant read access (State Ownership).
  • An interface is a boundary only if something on the other side of it is genuinely allowed to differ. One implementation and a mock is not two things (Interface Versus Implementation).

The getter that hands out the keys

This is the most common leak in the domain, and it survives review because it looks like the textbook. The field is private; there is an accessor; the class has methods. Nothing about the shape suggests that a caller can reach in and change the module's state.

The reason it matters is not that someone might mutate it maliciously. It is that a reasonable caller with a legitimate need will mutate it, from a file the module's owner never reads, and the invariant will fail silently in a code path nobody associates with the rule.

smellGetter returning mutable internals

looks like A private collection or mutable object handed straight back: getShifts(): Shift[] { return this.shifts }. Callers iterate it, and one of them eventually sorts, splices or pushes.

suggests The module owns a rule it cannot enforce, because there is a route into its state that does not pass through its operations. Expect the invariant to hold in tests and fail in production, at the one call site nobody thought of.

fix Return a snapshot, a readonly view, or better a projection shaped for the caller's actual need — shiftsFor(employee, week) rather than getShifts(). The projection is usually less work than the copy and tells you what the caller wanted.

when this is fine When the collection is genuinely immutable — a frozen array, a persistent data structure, a Rust &[T] — or when the type is a plain data record with no invariant to protect, in which case there is nothing for the caller to violate and copying is pure cost. A DTO crossing a boundary should hand back its contents (Three Models, Not One).
Three leaks in twelve lines
1export class Schedule {
2 private shifts: Shift[] = []
3 publishedAt: Date | null = null // leak 2: public field, now a contract
4
5 getShifts(): Shift[] { return this.shifts } // leak 1: the live array
6
7 addShift(s: Shift): void {
8 assertUnderWeeklyMax(this.shifts, s) // the rule, enforceable only here
9 this.shifts.push(s)
10 }
11}
12
13// payroll.ts — reasonable, and it bypasses the rule
14schedule.getShifts().push(correction)
15
16// report.ts — reasonable, and it breaks published-immutability
17schedule.publishedAt = null

Both call sites are the kind of code that passes review: they are short, they read clearly, and neither author knew a rule existed. That is the mechanism — leaks are not exploited, they are used innocently by people who could not have known.

A public field is a contract you did not negotiate

The second leak is quieter than the first because it usually starts as a read. Someone needs publishedAt for a report, the field is made public, and for a year nothing bad happens. The cost arrives when the representation has to change — when publishedAt becomes a publication *event* with an actor and a reason, and nine call sites expect a nullable date.

The third leak, the mirror interface, is different in kind: nothing is exposed that was not already public, but the abstraction is now shaped by exactly one implementation, so it provides no freedom while charging full price in indirection.

Three leaks, how each is found, and what it costs
TriggerSymptomCauseResponse
A caller mutates the array returned by a getterThe invariant fails in production on a path with no test, usually a batch job or an admin toolRead access was granted and write access came with it, because the collection was handed over by referenceReturn a snapshot or a projection; add a test that mutating the returned value does not change the module (Immutability)
A public field is read by nine modules and written by oneA representation change — nullable date becomes an event record — is estimated at a week and touches four teamsA field exposed for one read became a shape everyone depends on, with no record that it was ever meant to be temporaryAdd an accessor with the meaning the callers actually need, migrate them one at a time, then make the field private (Expand and Contract)
An interface has one implementation and a mockA genuine second implementation must implement methods that only make sense for the first — refreshMaterializedView() on an in-memory storeThe interface was extracted from a class rather than designed from what consumers need, and then grew with it method by methodShrink the interface to what consumers call, per consumer if they differ; delete it entirely if the second implementation never arrives (Interface Segregation, Critically)
A leak is closed and a hot path is exempted for latencyThe invariant holds everywhere except the highest-traffic code pathDefensive copying was the only fix considered, so the performance objection had no answer other than an exceptionReplace the copy with a projection or an index the caller actually needs, so the fast path is also the closed one (Cost-Aware Interfaces)

Deciding what to expose, on the day someone asks

Every leak in this lesson began as a small, reasonable request. The decision below is the one worth having explicitly, because the default — say yes, it is one line — is what produces all three.

The asymmetry is the argument. Exposing something is one keyword and takes a minute; retracting it is a per-caller migration across teams and deploy cadences, and in a codebase with an external consumer it may be impossible without a version bump (Semantic Versioning).

Someone needs something the module does not expose

What is the smallest thing that satisfies the real need without promising the representation?

They need a value derived from internal state

when Payroll wants total hours for an employee this week.

cost Add the derived operation. Costs one method and one test; buys the freedom to change the representation forever, because nothing about it escaped.

They need to iterate the collection

when The mobile app renders a list of shifts.

cost Return a snapshot or a readonly projection type of your own. Costs an allocation per call and a mapping function; the projection type is what stops the internal shape becoming a wire contract (Three Models, Not One).

They need to change internal state

when Payroll wants to record a correction.

cost That is an operation, not access — recordCorrection(shift, reason). Costs a design conversation, and it is where the rule and the audit record get attached, which is the whole reason not to hand over the array.

They need it for a test double

when "Extract an interface so we can mock the schedule."

cost Usually the wrong reason to add an abstraction. A real Schedule with no dependencies is a better double than a mock of it; extract an interface when a second real implementation exists (Mocking).

They need something genuinely unanticipated, today, urgently

when An incident, a compliance request, a one-off export.

cost Expose it explicitly as internal, with the caller named and a deletion trigger written down. Costs the discipline to actually delete it — and the honest expectation is that roughly half of these become permanent (Deliberate Debt).

How to build it

Most important first.

  • Return a snapshot, a readonly view, or a projection shaped for the caller — never the collection the module mutates (Immutability).
  • Model the reader's need rather than handing over the data: shiftsFor(employeeId, week) gives payroll what it wanted and gives it nothing else.
  • Treat any request for a public field as a request for an operation, and find out which one (Designing a Module Interface).
  • Let an interface be defined by its consumers, not by its implementation. If a method exists because the implementation has it, delete it from the interface (Interface Segregation, Critically).
  • Where the language allows it, make the leak impossible rather than discouraged: readonly arrays, frozen objects, #private fields, sealed types (Making Illegal States Unrepresentable).
  • When you must expose something temporarily, expose it with a deletion date and a test that fails after it (Deprecation).

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
  • With the leaks: changing the internal representation from an array to an interval tree touches four consumers, one of which is a mobile client on a two-version delay — so the change spans two release cycles and cannot be reverted quickly.
  • Without them: the same change touches one module, and the mobile app never notices because it only ever received projections.
  • Adding the overtime rule is the sharper version: with a leaked mutable array the rule cannot be enforced at all without auditing every caller, so the cost is not an edit — it is an investigation with no defined end.
  • What does not get cheaper: the mirror interface must still be shrunk method by method, and each removal is a small negotiation with whoever calls it. Leaks are cheap to create and are removed one caller at a time (Incremental Migration).
What the recommended approach costs
  • Copying costs allocation, and for large collections read frequently it is a genuine performance decision rather than a purity question.
  • Projections mean more methods and more types. A team that finds the module tedious to use will find a way around it, and that way will be worse than the getter you refused (When Design Does Not Pay).
  • Narrowing an existing surface is a migration with no user-visible benefit, which makes it perpetually the second priority. Most codebases never finish one.

What can go wrong

Failure modes
  • The copy is added and one hot path is exempted for performance, so the leak survives in the one place with the most traffic and the least review.
  • A readonly type is used in TypeScript and the caller casts it away, which compiles. Structural typing is a lint, not a lock (Enforcing Invariants).
  • Everything is locked down and callers respond by serializing to JSON and back to get a mutable copy, which is slower, uglier and completely legal.
  • The mitigation fails on its own terms: defensive copying on a large collection in a hot loop turns an encapsulation fix into a latency regression, and the fix for that is a projection, not a retreat (Allocation and Copies).
Dependencies, and their direction
  • Every exposed member is a dependency edge from someone else's code into your representation, and you will not know it exists until you try to change it.
  • A leaked mutable collection inverts the direction: the module now depends on its callers behaving, which is a dependency you cannot see, test or type (Dependency Direction).
  • The mirror interface creates a dependency from the abstraction onto the concrete class it was extracted from, which is the inversion pointing backwards (Dependency Inversion, Critically).
Misreads
  • "Private fields plus getters is encapsulation." A getter returning the live collection grants exactly the access a public field would, with more ceremony and a stronger false impression of safety.
  • "Make it immutable and the problem goes away." Immutability closes the write leak and leaves the read leak: callers still depend on the shape, so representation changes still break them (Immutability).
  • "An interface for every class gives flexibility." An interface extracted from one class and shaped by it is that class with indirection; the flexibility is imaginary until a second implementation exists that did not have to contort to fit (How SOLID Gets Misused).
  • "We can tighten it later." Tightening is per-caller negotiation across teams and release cycles, while loosening is one keyword. Defaults should follow that asymmetry (Reversible and Irreversible Decisions).
Smells this explains
  • feature-envy
  • primitive-obsession
  • god-object

Testing it, and how it ages

What to test, and at which boundary
  • Assert the leak is closed: mutate what a getter returned and assert the module is unaffected. That is a one-line test that will outlive several representations (What a Unit Is).
  • Test the invariant through the leaked path specifically — if payroll can add a shift, the overtime test must run against payroll's route too.
  • Use an architecture or lint rule for public members on domain types, so the next leak is caught at review time rather than in three years (What to Automate Out of Review).
  • Do not write a test that asserts an interface has exactly N methods. It will be gamed, and it measures the wrong thing (Testing as Design Feedback).
How this design ages
  • Leaks accumulate monotonically. Nobody ever removes a public member on a quiet afternoon, so surface area only grows unless something forces a review (API Stability).
  • The moment the cost becomes visible is a representation change — usually driven by performance — and that is typically two to four years after the leak was created.
  • A module that survives long enough eventually needs a published-versus-internal split, at which point every leak has to be classified. Doing that classification early is much cheaper (Public vs Internal APIs).

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.

  • LANGUAGE-SPECIFICThe three leaks have very different weights by language. Rust makes the mutable-alias leak structurally impossible; Java and C# need Collections.unmodifiableList or an immutable collection type; TypeScript's readonly is erased at runtime and castable, so it documents intent rather than enforcing it; Python has no privacy at all and relies entirely on convention plus review.
  • GENERALThat an exposed member becomes something you cannot change follows from having callers you do not control, and holds in every language — only the enforcement mechanism and the cost of retraction differ.
  • CONTESTEDThe strongest opposing view is that defensive copying and projection ceremony impose a permanent, measurable cost to prevent a hypothetical misuse, and that in a codebase with good review and a small team, a plain readable structure plus the discipline not to mutate it is both faster and clearer. That argument is strong when everyone who touches the type is in the room; it weakens sharply the moment a consumer is on a different deploy cadence or a different team, because then discipline is not observable.

Where the depth lives

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

Domains that do not exist yet
  • Programming Languages & Runtime Internals — whether a leak is preventable or merely discouraged is a property of the language's module and ownership model, and it is the single biggest reason the same advice has different weight in Rust, Java, TypeScript and Python.