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.
How does a module that looks properly encapsulated end up unable to change anything?
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 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.
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).
getShifts()returns the live array. Payroll callsschedule.getShifts().push(correction)— reasonably, from its point of view — and the overtime rule is bypassed by a call that never touchedaddShift(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
publishedAtfield 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
ScheduleSourceinterface 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 implementrefreshMaterializedView().
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.
- 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.
- 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.
Scheduleowns 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.
- 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.
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.
&[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).1export class Schedule {2 private shifts: Shift[] = []3 publishedAt: Date | null = null // leak 2: public field, now a contract4 5 getShifts(): Shift[] { return this.shifts } // leak 1: the live array6 7 addShift(s: Shift): void {8 assertUnderWeeklyMax(this.shifts, s) // the rule, enforceable only here9 this.shifts.push(s)10 }11}12 13// payroll.ts — reasonable, and it bypasses the rule14schedule.getShifts().push(correction)15 16// report.ts — reasonable, and it breaks published-immutability17schedule.publishedAt = nullBoth 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.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| A caller mutates the array returned by a getter | The invariant fails in production on a path with no test, usually a batch job or an admin tool | Read access was granted and write access came with it, because the collection was handed over by reference | Return 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 one | A representation change — nullable date becomes an event record — is estimated at a week and touches four teams | A field exposed for one read became a shape everyone depends on, with no record that it was ever meant to be temporary | Add 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 mock | A genuine second implementation must implement methods that only make sense for the first — refreshMaterializedView() on an in-memory store | The interface was extracted from a class rather than designed from what consumers need, and then grew with it method by method | Shrink 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 latency | The invariant holds everywhere except the highest-traffic code path | Defensive copying was the only fix considered, so the performance objection had no answer other than an exception | Replace 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).
What is the smallest thing that satisfies the real need without promising the representation?
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.
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).
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.
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).
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:
readonlyarrays, frozen objects,#privatefields, 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.
- 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).
- 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
- 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).
- 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).
- "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).
- feature-envy
- primitive-obsession
- god-object
Testing it, and how it ages
- 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).
- 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.unmodifiableListor an immutable collection type; TypeScript'sreadonlyis 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.
- — 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.