skip to content

How do you decide which parts of a component belong in its stable public interface and which must stay volatile implementation details? What signals tell you a detail has leaked?

level: seniorimportance: should knowfreq 52%

answer

  1. Publish slow vocabulary, hide fast mechanism
  2. Foreign types in signatures = leak
  3. Train-wreck chains = structural coupling
  4. Deep module, not thin wrapper
  5. Hyrum: observable becomes contractual

basics

~20 s

Publish what callers need to express their intent and what you can promise to keep working; hide anything you expect to change — data layout, algorithms, libraries, protocols. A detail has leaked when a change to it forces callers to change too.

solid answer

~50 s

The dividing line is *rate of change weighted by cost of change*. The public interface should be the slow-moving vocabulary of the component's responsibility; everything volatile — storage schema, serialization format, third-party types, threading and caching strategy, error representations from lower layers — stays behind it. Concrete leak signals: third-party or framework types appearing in signatures; exceptions from a lower layer propagating with their original type; return types that are your internal collection/entity classes; parameters that only make sense if the caller knows your algorithm (`useIndexScan`, `batchSize`); callers chaining through returned objects (`a.getB().getC().doX()`, the Law of Demeter violation); documentation that has to explain your internals for the API to be usable; and change-coupling in version control — your module and its callers repeatedly changing in the same commit. Counter-forces matter too: an interface that hides too much becomes a pass-through with dozens of narrow methods. Aim for deep modules — small interface, substantial functionality — and accept that some performance-critical contracts must expose more.

go deeper

for a junior

Say the interface is what callers need and are promised; internals like data format and libraries stay hidden. Give one leak example, such as returning a database row type.

for a middle

Add concrete leak signals — foreign types in signatures, leaked exceptions, flag parameters exposing internals — and the fix of translating at the boundary.

for a senior

Reason about rate-of-change × blast radius, add Law of Demeter and temporal coupling as detectors, use change-coupling evidence, and name the over-hiding failure mode (shallow pass-through modules).

for a principal

Cover Hyrum's Law and observable-vs-declared surface, deprecation/versioning strategy for out-of-process consumers, and mechanical enforcement (module systems, dependency-rule tests, API diff in CI) plus how the boundary maps to team ownership.

## The question restated Every component draws a line: **contract** (promised, changed only with ceremony) vs **implementation** (free to change any time). Getting the line wrong in one direction makes evolution expensive; in the other, it makes the component useless or bloated. There is no rule that removes the judgment, but there are reliable heuristics and reliable leak detectors. ## Heuristics for what goes on each side **Belongs in the interface** - The vocabulary of the *problem*, not the solution: `reserveSeat`, `settleInvoice`, `nextDueDate`. - Types you own and control: your own value objects, your own enums, your own error taxonomy. - Guarantees callers must be able to rely on: ordering, idempotency, visibility/consistency, failure modes, thread-safety, and complexity when it is contractually relevant. **Belongs behind it** - **Data representation**: schema, field layout, on-disk/on-wire format, collection types. - **Algorithms and policy**: sort strategy, retry policy, caching, batching, concurrency model. - **Dependencies**: which HTTP client, ORM, queue, or vendor SDK — their types must not surface. - **Identity of collaborators**: which other modules you call; a caller should not learn your call graph. A useful sort: for each candidate element, ask (a) how likely is this to change in the next year, (b) if it changes, how many call sites break, (c) do callers need it to do their job? Publish only where (c) is yes and (a)×(b) is low. Where (c) is yes and (a)×(b) is high, invent an intermediate abstraction you own. ## Leak detectors — how to *notice* without waiting for pain 1. **Foreign types in signatures.** `ResultSet`, `HttpResponse`, `JsonNode`, `EntityManager`, a vendor DTO. Upgrading or replacing that library is now a breaking change for callers. 2. **Exception/error passthrough.** If `SQLException` or a vendor error code reaches callers, your persistence choice is contractual. Translate at the boundary into your own error taxonomy. 3. **Implementation-flavored parameters.** `fetchAll(useCursor: Boolean)`, `save(flush: Boolean)`, `search(shards: Int)`. Callers must understand your internals to call you correctly. 4. **Train-wreck chains at call sites.** `order.getCustomer().getAddress().getCountry().getCode()` — the caller is navigating *your* object graph. The Law of Demeter reframed: each hop is a structural assumption you can no longer change. 5. **Temporal coupling.** Callers must call `init()` then `configure()` then `run()`. The required sequence is an internal state machine promoted to public knowledge. 6. **Documentation smell.** If the doc comment must describe how the method works for the reader to use it correctly, the abstraction is not carrying its weight — Ousterhout's test for a shallow module. 7. **Change coupling (empirical).** Mine the VCS: if your module and three consumers change in the same commits repeatedly, the boundary is not where the change is. 8. **Test smell.** If consumer tests must stub your internal collaborators or set up your database schema, your internals are part of your contract. ## The counter-forces (don't over-hide) - **Shallow pass-throughs.** A module whose every method forwards one call to one dependency adds indirection without hiding a decision. Ousterhout's "deep module" heuristic: maximize functionality behind a minimal interface; a thin wrapper is the opposite. - **Interface bloat by parameter.** Hiding a decision by adding a flag for each variation just re-publishes the decision in argument form. - **Performance contracts.** Some callers legitimately need to know cost characteristics (streaming vs materializing, batch size limits). Publish the *guarantee* ("streams; constant memory") rather than the *mechanism*. - **Cost of the abstraction.** Every hidden decision costs a translation layer to write, test, and read. Hiding a decision that never changes is pure overhead. Prefer cheap seams first; deepen when the change actually arrives. ## What makes a published interface actually stable - **Additive evolution**: add operations, never repurpose old ones; new optional parameters with defaults; new fields tolerated by consumers (tolerant reader). - **Deprecation with a window**, not silent removal; version the contract if the consumers are outside your deploy unit. - **Hyrum's Law**: with enough consumers, every observable behavior — ordering you never promised, timing, error message text — becomes someone's dependency. So minimize *observable* surface, not just declared surface: don't leak stack traces, don't expose incidental ordering, randomize where you can. - **Enforce it mechanically**: language visibility, module systems, package/export boundaries, dependency-rule tests (ArchUnit-style), API-diff/lint tooling in CI. Convention alone erodes.

  • Is a repository interface that returns your ORM entity classes leaking?
    Usually yes. The entity carries mapping annotations, lazy-loading proxies, and a lifecycle tied to a session — callers become coupled to the ORM and to the schema shape, and detaching or replacing the ORM breaks them. Return your own domain objects or read models.
  • How can you keep an interface stable when you genuinely need to change it?
    Evolve additively: add the new operation alongside the old, migrate consumers, then deprecate with a stated window. For out-of-process consumers, version the contract explicitly and support both for an overlap period. Consumer-driven contract tests tell you when it is safe to remove the old one.
  • Doesn't hiding everything just create anemic wrapper layers?
    It can. The check is whether the layer hides a decision that changes; a class whose every method forwards one call unchanged hides nothing and costs indirection. Prefer fewer, deeper modules over many shallow ones.

A power socket is a stable interface: voltage, frequency, plug shape. Whether the electricity comes from coal, nuclear, or solar is volatile detail, and no appliance's design mentions it. If someone sold a lamp that only worked with hydroelectric power, the generation method has leaked into the contract.

context