EvolvabilityGENERALLANGUAGE-SPECIFICSCALE-SPECIFIC

Encapsulation Radius

Pick an implementation decision and ask who has to be told when it changes. The set of modules that notice is the radius, and a smaller one is stronger hiding.

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

If I change how this module works internally — not what it promises — how far does the shockwave travel?

The requirement

The search feature has to move from a database LIKE query to a real search index. The team says this is "internal to search". A week in, six unrelated modules have failed to compile and the mobile client has a bug.

The obvious build

Search is already its own module with its own folder and its own class. Changing what happens inside it is by definition internal, so the blast radius is one module.

Why it breaks

A folder is not a boundary. The radius is determined by what callers *know*, and here they know the row shape, the fact that ordering came out of SQL, and that a missing field means null rather than absent.

How it breaks as requirements change
  • A folder is not a boundary. The radius is determined by what callers *know*, and here they know the row shape, the fact that ordering came out of SQL, and that a missing field means null rather than absent.
  • The old query returned an ORM row object, which every caller destructures. Swapping the engine changes that type, so the change is not internal — the type was part of the interface all along and nobody wrote that down (Not Leaking Your Internals).
  • Some callers relied on properties nobody promised: that results were stable across identical calls, that the count was exact, that an empty query returned everything. Those are now part of the contract by usage (Invariant Leaks).
  • The radius was invisible until the change was attempted, which is the general case. You do not discover an encapsulation boundary is fictional until you try to use it.
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
  • Search results are consumed by the web app, the mobile API and a nightly export, and all three were written against whatever the old query returned.
  • The mobile client cannot be updated in lockstep — old app versions stay in the wild for months (Backward Compatibility as a Constraint).
  • The rollout has to be reversible, because search quality regressions are only visible in production traffic.
Invariants
  • A result set means the same thing before and after: same ordering guarantees, same visibility rules, same tenancy filter.
  • No caller ever sees a row shape that belongs to the storage engine, because that is exactly the decision being changed.

Who owns what, and where the seams fall

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

Responsibilities
  • The search module owns the query engine, the ranking, the index shape and the retry policy — every decision likely to change.
  • It owns publishing a result type that belongs to the domain, not to the storage layer, so that the storage layer can move.
  • Callers own what they do with results and own nothing about how the results were produced.
Boundaries
  • The boundary is the set of types and guarantees that leave the module. Everything reachable through those types is inside the radius, however deep it is (Designing a Module Interface).
  • A returned type from a third-party library places that library inside your public interface, whatever your folder structure claims (Leaky Abstractions).
  • Undocumented properties that callers depend on are on the boundary too — the contract is what callers rely on, not what you wrote in the docstring (Stable Boundaries).

The rings around a decision

Take one concrete decision — "search runs as a SQL LIKE query" — and draw the set of things that would have to be told if it changed. It is almost never the folder you expected, because it includes anything that saw a type, a shape, or a behaviour that the decision produced.

The useful discipline is to do this before making the change rather than during it. Ten minutes tracing what leaves the module is cheaper than a week discovering it one compile error at a time.

  • The radius here is four modules plus every deployed mobile version — not one module, which is what the folder tree suggested.
  • The dashed part is the expensive part: the export depends on a property nobody promised, so no compiler will report it and no reviewer will see it.
  • Shrinking this radius is one change — publish your own result type — and it is worth doing before the engine swap, not during it.
Who has to be told when the search engine changes?
inside — free to changeleaks the row shapeand old app versionsexport pages by offsetDecision: LIKE query over the orders tableUnwritten assumption: results are stable and counts exactSearch module internalsReturned ORM row typeWeb appMobile APINightly export
UserLLMAgentToolDataDecisionHumanGuardrail

The four lines that shrink it

Almost all of the radius in real systems comes from one habit: returning somebody else's type. An ORM entity, an HTTP client response, a driver row. Each of those places the library inside your public interface, and the library's next major version becomes your migration.

The fix is unglamorous and it is mapping code. That is a genuine cost — a mapper is a place bugs live, and it is one more file for a reader to pass through — and it is usually much smaller than the coupling it removes.

What leaves the search module
The storage engine is in the signature
// search/index.ts
import { OrderRow } from '../db/entities'

export async function search(q: string): Promise<OrderRow[]> {
  return db.orders.raw(
    'select * from orders where title like ?', [`%${q}%`],
  )
}

// caller
const rows = await search(q)
rows.forEach(r => render(r.title, r.customer_id, r.raw_json))
The module publishes its own type
// search/index.ts
export type SearchHit = {
  orderId: OrderId
  title: string
  /** relevance order; NOT stable between identical calls */
  rank: number
}

export async function search(q: string): Promise<SearchHit[]>

// caller
const hits = await search(q)
hits.forEach(h => render(h.title, h.orderId))

In the first version the ORM entity, the column names and the fact that a relational table is involved are all reachable by every caller, so replacing the engine changes their code — the radius is the whole codebase. In the second, callers know four fields and one written-down non-guarantee, so the inside can be replaced entirely with a compile-time promise that nothing else moves. It costs a mapping function and a type definition, which is the price of the boundary being real rather than declared.

Reading the radius off a real unit

CONTESTEDThe opposing case, argued seriously by people who have maintained large systems: mapping layers between owned and library types are a substantial tax paid on every field of every model, forever, and the great majority of the dependencies they protect against are never actually swapped — so the expected value is negative and returning the library type is the correct default. That is right when the library is genuinely stable and the system is small; it fails when the "never swapped" dependency turns out to be the one under an acquisition, a licence change, or a major version with no upgrade path.

The radius and the "reasons to change" list are two views of the same thing. A unit with one reason to change and one published type has a small radius almost automatically; a unit with six reasons has a large one, because six different kinds of change each reach different callers.

The exercise below is worth running on the module you are about to modify. If the changesWhen list is long and the published surface is wide, the estimate you are about to give is wrong.

responsibilitiesSearchService, as foundThe search module, before the boundary is fixed
Knows
  • The orders table schema
  • That relevance is a SQL expression
  • Which tenant column filters visibility
  • The pagination scheme used by the export
  • The ORM entity type for orders
Does
  • Builds a query
  • Executes it
  • Returns ORM rows unchanged
  • Applies the tenancy filter
  • Caps the result set at 500
Depends on
  • ORM
  • Database
  • Tenant context
  • Nothing else — which is the misleading part
Changes when — 6 distinct reasons
  • The search engine changes
  • Ranking rules change
  • The orders schema changes
  • The tenancy model changes
  • Pagination semantics change
  • The result cap changes

Six reasons to change, and every one of them is visible to callers because the ORM row leaves the module. The finding is not "split this into six classes" — it is that exactly one of those six reasons, the engine, is the volatile one, and the boundary should be drawn to hide that one first. Publishing SearchHit puts the engine, ranking and schema changes entirely inside the module and leaves tenancy and pagination on the interface, where they belong, because callers genuinely need to know about both.

How to build it

Most important first.

  • Define the result type yourself. A SearchResult with the fields the domain needs is the single highest-leverage move here, and it is usually four lines (Designing a Module Interface).
  • State the guarantees explicitly, including the negative ones: ordering is by relevance and not stable, counts are approximate above a threshold, an empty query returns nothing. Written-down non-guarantees shrink the radius more than written-down guarantees do.
  • Keep the engine's types out of every signature — argument types as well as returns, since an accepted type is just as much a commitment.
  • Verify the radius by attempting the change behind a flag on a branch before committing to it, so you discover the real callers early rather than at week two (Feature Flags and What They Cost).
  • Where a caller genuinely needs something the boundary hides, add it to the interface deliberately rather than letting them reach through. A widened interface you chose is cheaper than a narrow one everyone bypasses.

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
  • Before: swapping the engine costs six caller modules, their tests, a mobile release cycle, and a discovery phase whose length is unknown until it ends.
  • After: swapping the engine costs the search module and its tests. The next ranking change, index change or engine change costs the same one module — the radius is what makes the *repeat* cheap, not the first attempt.
  • The cost of adding a field to results stays the same as before: it touches the type and every caller that wants it. Hiding does not make additive change cheap, and claiming it does is the usual oversell.
  • A wrong boundary is expensive to move later precisely because it succeeded: the more callers rely on a published type, the more it costs to change it, so a small radius converts internal freedom into external rigidity (API Stability).
What the recommended approach costs
  • Owning your own result type means mapping code, which is real work and adds a place for bugs that a passthrough does not have.
  • A narrow interface will sometimes deny a caller something it legitimately needs, and the honest cost is either a widened interface or a slower caller.
  • Hiding makes debugging harder: the thing you want to inspect is behind a boundary, and the module now owes explicit observability that a transparent one got for free (Debuggability by Design).

What can go wrong

Failure modes
  • A convenience method leaks the engine type "just for the admin tool", and the radius silently reverts to what it was.
  • The result type is defined but is a structural copy of the engine row, field for field, so the type is nominally owned and the knowledge is not (Leaky Abstractions).
  • The team narrows the radius by making the interface too thin, so callers assemble what they need from three calls — and the *sequence* of those calls becomes an undocumented part of the contract (Temporal Coupling).
  • The mitigation fails specifically: an anti-corruption boundary is added, the old callers are never migrated onto it, and now two shapes are live simultaneously with no plan to converge (Anti-Corruption Layer).
Dependencies, and their direction
  • Callers depend on the published result type and on nothing else the module contains; the direction is one-way by construction (Dependency Direction).
  • The module depends on the search engine client, which is volatile — it is the thing being swapped — so it must be reachable only from inside (Volatile Dependencies).
  • The mobile API adds a version dependency: the radius includes clients you cannot deploy, which is a different and harder category (Versioned Interfaces).
Misreads
  • "Private fields give me a small radius." Access modifiers control who can name a thing, not who depends on it. A public method returning a library type has a radius the size of your codebase regardless of what is marked private (Information Hiding).
  • "Small radius means small interface." They are different. A wide interface can have a small radius if everything it exposes is yours and stable; a one-method interface returning a database row has a large one.
  • "So wrap every dependency." Wrapping a dependency you will never replace buys nothing and costs a mapping layer forever. The radius matters for decisions that will actually change (Premature Abstraction).
  • "The radius is the import graph." Imports are a lower bound. Callers that rely on undocumented behaviour are inside the radius and appear nowhere in the graph, which is why the failure is usually a surprise.
Smells this explains
  • feature-envy
  • shotgun-surgery

Testing it, and how it ages

What to test, and at which boundary
  • Test the module through its published interface only. A test that reaches inside is a caller inside the radius, and it will block the exact change the boundary exists to allow (What a Unit Is).
  • Write down the guarantees as tests — ordering, tenancy filtering, approximate counts — so the replacement engine has something to satisfy (Contract Tests).
  • Run the old and new engines side by side on real queries and diff the results, because search relevance is not something a unit test can assert (Characterization Tests).
How this design ages
  • A narrow radius lets the inside be rewritten repeatedly. That is the payoff, and it accrues on the second and third replacement rather than the first.
  • Interfaces widen under pressure: every "can you also expose" is a request to grow the radius, and granting them one at a time is how a boundary dissolves without any single decision to dissolve it.
  • The boundary needs revisiting when most changes to the module also change its interface — at that point it is not hiding anything, and the interface is just ceremony (Exposing Too Much).

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 the cost of an internal change is set by how many modules relied on what you changed holds everywhere. What differs is how much help you get detecting it: a compiler catches a changed type, and nothing catches a changed assumption.
  • LANGUAGE-SPECIFICIn a language with sealed module systems and explicit exports — OCaml signatures, Rust visibility, Java modules — the radius is largely checkable at build time, and the boundary is real by default. In Python or JavaScript, where every attribute is reachable and duck typing means any object with the right shape passes, the radius is a convention plus a linter, so the same design needs discipline that the compiler was providing elsewhere.
  • SCALE-SPECIFICWith one team and five callers, an over-wide boundary is fixed in an afternoon by editing all five. Once callers belong to other teams, or to deployed mobile clients, the same widening is permanent — so the effort worth spending on the radius scales with how hard your callers are to change, not with how large the module is.

Where the depth lives

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

Domains that do not exist yet
  • API Design — an external API is the extreme case, where the radius includes people you cannot contact and cannot deploy, which is why every internal boundary you promote to an API becomes permanent.