EncapsulationGENERALLIFETIME-SPECIFICCONTESTED

Stable Boundaries

A boundary is worth depending on when it changes more slowly than what sits behind it. If it changes at the same rate, it is a forwarding address.

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

What makes a boundary worth depending on, rather than one more layer to update whenever anything moves?

The requirement

The company is replacing its HR provider next year. Payroll, onboarding, the org chart and access provisioning all call the current provider's SDK directly, and someone has proposed putting an interface in front of it.

The obvious build

Wrap the SDK. Create HrClient with a method for each SDK call — listEmployeesPaged, getEmploymentRecordV2, patchWorkerAttributes — so nothing imports the vendor package directly. Now there is a boundary.

Why it breaks

The wrapper is written in the vendor's vocabulary, so it changes every time the vendor does. Twice a year the SDK breaks, the wrapper changes, and because the wrapper's methods mirror the SDK, all four callers change too. The boundary added a file and prevented nothing (Leaky Abstractions).

How it breaks as requirements change
  • The wrapper is written in the vendor's vocabulary, so it changes every time the vendor does. Twice a year the SDK breaks, the wrapper changes, and because the wrapper's methods mirror the SDK, all four callers change too. The boundary added a file and prevented nothing (Leaky Abstractions).
  • When the provider is actually replaced, the wrapper is useless: getEmploymentRecordV2 has no counterpart, pagination works differently, and the new provider models contractors as a separate entity. The interface encoded the old provider's model, which is the thing that changed.
  • The wrapper's failure modes are the vendor's failure modes — its rate-limit error type, its retry semantics — so callers handle vendor-shaped failures and those change too (Error Boundaries).
  • Worse, the boundary now has a reputation. "We already have an HR abstraction" is used to argue against building the one that would have worked (What Technical Debt Actually Is).
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 replacement is real but undated — a contract renewal, not a project — so any boundary has to be worth having even if the switch is delayed two years.
  • Four consuming modules, one of which (access provisioning) is security-relevant and audited (Least Privilege as a Design Decision).
  • The current provider's SDK ships breaking changes roughly twice a year, which is the actual daily pain and is why anyone is listening.
Invariants
  • An employee who has left has no active access, regardless of which provider is authoritative at the time.
  • Whatever the boundary promises stays true across a provider change, or the boundary was decoration.

Who owns what, and where the seams fall

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

Responsibilities
  • The boundary owns the translation between our vocabulary and theirs, and owns it completely — no vendor type, error or pagination token appears outside it (Anti-Corruption Layer).
  • Our side owns the model: what an employee is *to us*, which is a much smaller and much more stable thing than what an employee is to an HR platform.
  • Callers own their own domain logic and own nothing about providers, including the knowledge that there is one.
Boundaries
  • The seam falls where the change rates differ. Our notion of "an employee has left" changes when employment law or company policy does — years. The provider's representation of it changes twice a year. Put the boundary between those two rates.
  • A boundary drawn in the fast-changing party's vocabulary inherits its change rate, which is why the wrapper failed. Vocabulary is not cosmetic here; it is the mechanism.
  • The boundary must be narrower than what it covers. If the interface has a method for every SDK call, it cannot be more stable than the SDK (Designing a Module Interface).

Change rate is the whole criterion

The useful question about a proposed boundary is not whether it is a good abstraction. It is: how often does what is behind this change, and how often does the boundary itself change? Subtract. If the answer is roughly zero, the boundary is a forwarding address and you are paying for a file.

The diagram is not a layering diagram — nothing here is about controllers and repositories. It is the same code sorted by how often each part has a reason to change, and the rule is that arrows point from faster to slower. Every painful dependency in a codebase is an arrow pointing the other way.

  • The adapter depends on our model, not the other way round. That inversion is the entire structural move (Dependency Inversion).
  • Four modules and one vendor change per release: without the boundary the vendor's twice-yearly break costs five edits, with it, one.
  • If our vocabulary changed as often as the SDK does, none of this would pay — and that comparison, not a principle, is what should decide it (The Cost of Change).
Sort the code by change rate; arrows must point downhill
implementsthe only edge that knows a vendor existsPayroll (changes: quarterly)Onboarding (changes: monthly)Access provisioning (changes: monthly)Org chart (changes: rarely)Provider adapter (changes: twice a year, plus once on replacement)Our HR vocabulary: Employee, Termination, ReportsTo (changes: yearly)Vendor SDK (changes: on their schedule, not ours)
UserLLMAgentToolDataDecisionHumanGuardrail

What is actually behind the difference

SIMULATEDThe change rates in this table are illustrative periods drawn from an Engineer Atlas model of a mid-size codebase, not measurements of any real vendor or ORM. The transferable part is the comparison — run it against your own repository history, where the numbers are real: count commits touching each side over the last two years and compare, rather than trusting the shape of this table.

Not every boundary in a codebase deserves this treatment, and the ones people build most eagerly are often the ones with the smallest change-rate gap. Score the candidates honestly by asking what has changed on each side in the last two years — that is evidence, unlike a prediction about the future.

The row worth arguing over is the ORM one. It is the most frequently proposed boundary in this list and the one with the weakest change-rate case, because a mature ORM changes far more slowly than the queries and schema behind it — so the boundary usually ends up mirroring the ORM and buying nothing (When the Repository Is Just Indirection).

Candidate boundaryHow fast the thing behind it changesHow fast the boundary would changeWorth depending on?
Our vocabulary over an HR vendor SDKTwice a year, plus a full model change on replacementWhen employment concepts change — roughly yearlyYes. A real gap, and the recurring vendor breakage pays for it without waiting for the migration.
A wrapper with one method per SDK callTwice a yearTwice a year — it mirrors the SDKNo. Zero gap. It is the SDK with our import path, and it will still be described as "our abstraction" (Leaky Abstractions).
An interface over the standard library or a mature date libraryYears, with deprecation cyclesWhenever we need a new operation — oftenNo, and backwards: our side is the faster-changing one, so the wrapper changes more than what it wraps.
A payment gateway boundary at a payments companyProviders, fee models and regional rules change constantlyThe concept of "take a payment" is close to fixedYes, and this is the strongest case in the list — the gap is large and the switch is near-certain (The Cost of Change).
A repository interface over an ORMSchema and queries change weekly; the ORM itself changes yearlyEvery time a caller needs a new query shape — weeklyUsually no. The boundary changes faster than the ORM behind it, which is the criterion pointing the wrong way (When the Repository Is Just Indirection).
Our own module's public operations over its internalsInternals change with every optimisation and refactorOnly when the domain gains a new operationYes, and this is the cheapest one on the list because both sides are ours (Designing a Module Interface).

The day the boundary is tested

Every argument about a boundary is settled by a change that crosses it. Two crossed this one: the vendor's routine breaking change, which arrives twice a year and is the boring case, and the provider replacement, which arrives once and is the case everybody argues about.

The honest framing is that the boring case pays for the boundary and the dramatic one is a bonus. A boundary justified only by a migration that may never happen is a bet; one justified by a recurring cost you can already measure is not (The Cost of Change).

The vendor ships a breaking SDK release
The change

The provider renames employment status values, changes pagination from offset to cursor, and moves termination dates into a nested object. Routine for them, twice a year, every year.

Wrapper with one method per SDK call, vendor vocabulary throughout
HrClientPayrollSyncOnboardingFlowAccessProvisionerOrgChartBuilder
testshr_client_testpayroll_testonboarding_testaccess_testorgchart_test
5 modules · 5 test files

Five modules, because the wrapper's signatures change with the SDK's. Access provisioning is audited, so its change needs a security review — a two-line rename now costs a review cycle, twice a year.

Our vocabulary at the boundary; one adapter owns the vendor
HrProviderAdapter
testshr_contract_testhr_adapter_test
1 module · 2 test files

One module. The contract suite is unchanged because the contract did not change — only the translation did. No security review, because nothing the auditors care about moved.

what it cost Two models of an employee now exist and someone maintains the mapping between them; when a field is wrong, the debugging path has three suspects instead of two. Callers also cannot use provider capabilities the interface does not express, so a genuinely useful vendor feature requires widening the boundary — which means the stability was bought partly with capability, and that trade is permanent rather than one-off.

How to build it

Most important first.

  • Write the interface from the four callers' needs, in our words: employee(id), terminationsSince(date), reportsOf(managerId). Three operations, not thirty (Consumer-First Design).
  • Define our own types at the boundary — our Employee, our Termination — even when they start out shaped like the vendor's. The duplication is the point: it is what lets the vendor's shape move without ours moving (Anti-Corruption Layer).
  • Define our own error vocabulary too, in terms of what a caller can do about it, not what the vendor called it (An Error Taxonomy That Survives Contact).
  • Check the stability claim before committing: ask what has changed on each side in the last two years. If both changed at the same rate, this boundary is not buying anything and a direct call is more honest (When Design Does Not Pay).
  • Version the boundary explicitly if consumers deploy separately, so its stability is a promise rather than an observation (Versioned Interfaces).
  • Keep the translation dumb. Business rules that drift into the adapter make it change for two reasons and destroy the stability you built it for (Divergent Change).

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
  • Vendor SDK breaking change: one module, one test suite, zero callers. Under the mirror wrapper it is one module plus four callers, twice a year, forever — that recurring cost is what actually pays for the boundary, not the hypothetical migration.
  • Provider replacement: one new adapter implementing the same three operations, one contract suite that both must pass, and a dual-read window. Callers do not change at all.
  • What did not get cheaper: a change to *our* model — adding contractor employment types — crosses the boundary by design, because that is our vocabulary changing. It touches the interface, the adapter and any caller that cares. A boundary is stable relative to what is behind it, never absolutely (Change Amplification).
  • And the honest downside: our three-operation interface cannot express a capability the new provider has and the old one did not, so gaining a feature also means changing the boundary. Stability and capability trade against each other.
What the recommended approach costs
  • Two models of the same thing must be kept in sync by hand, and the translation is real code with real bugs. That cost is paid every day; the benefit is paid on vendor-change days.
  • The boundary limits you to the intersection of what providers offer, which means deliberately not using the best feature of the one you have (What a Framework Charges).
  • Debugging crosses one more layer. When a field is wrong, "is it them, the mapping, or us" is a three-way question rather than a two-way one (Debuggability by Design).

What can go wrong

Failure modes
  • The adapter is built in our vocabulary and one caller imports a vendor type "just for a field". The concentration of volatility is now a leak, and the migration will find it late (Exposing Too Much).
  • Our model is defined as a copy of the vendor's, so it changes whenever they do despite the different names. Renaming is not translating.
  • The boundary is stable and wrong: it promises reportsOf(managerId) returns direct reports, the new provider models dotted-line management, and the promise cannot be kept. A stable interface over a changed reality is worse than an unstable one, because callers trust it.
  • The mitigation fails on its own: the adapter accumulates fallbacks, caches and reconciliation for provider quirks until it is the most complex module in the system and every provider change is a week inside it. Concentrating volatility does not remove it (The Complexity Budget).
Dependencies, and their direction
  • Four modules depend on our HR vocabulary; the adapter depends on the vendor. The direction is what matters: nothing stable depends on anything volatile (Dependency Direction).
  • The adapter is the only module with a reason to change on the vendor's schedule, which is exactly the concentration of volatility we are paying for (Volatile Dependencies).
  • Our types depend on nothing. That is what makes them stable — a type with no dependencies has no imported reasons to change (Stable Dependencies).
Misreads
  • "Wrap every external dependency." A stable, ubiquitous library with a good interface — the standard library, a mature date library — changes more slowly than your code does, so a wrapper around it puts the boundary on the wrong side of the change-rate difference and adds cost with no return.
  • "The boundary makes us provider-independent." It makes the *switch* cheaper on the caller side. Data migration, historical records, contractual lock-in and behavioural differences are untouched by it, and those are usually the larger half (Designing the Migration).
  • "Stability means it never changes." It means it changes more slowly than what it hides. A boundary that never changes at all across five years of requirements is more likely to be unused than well designed.
  • "So make the interface as general as possible." Generality and stability are different. An interface generalized over providers nobody has evaluated is speculative, and the generalization will be wrong in the specific way that matters (Speculative Generality).
Smells this explains
  • leaky-abstractions
  • shotgun-surgery

Testing it, and how it ages

What to test, and at which boundary
  • One contract suite the boundary must satisfy, run against every implementation including the fake used by callers' tests. That suite is the boundary's actual definition (Contract Tests).
  • Test callers against the fake, not against the provider. If a caller's test needs the real SDK, the boundary is leaking (Test Doubles, Precisely).
  • Keep a small number of tests against the live provider, run on a schedule rather than in the commit pipeline, to catch the day their behaviour drifts from the contract (Where a Test Must Be Real).
  • Test the failure translation specifically: a vendor rate-limit must arrive at the caller as our transient error, or the caller ends up handling vendor semantics after all.
How this design ages
  • The first year the boundary looks like pure overhead: an extra type, an extra mapping, an extra file. The first vendor breaking change absorbed without touching a caller is when it starts paying, and that usually arrives within months.
  • Over time the adapter grows and the interface should not. Interface growth is the signal that the boundary is being pulled toward the provider's model (API Stability).
  • It stops being right if the provider becomes a genuine differentiator — if we start using capabilities specific to them deliberately. At that point the honest move is to admit the coupling rather than route it through an interface that pretends otherwise (Build, Library, SaaS or Managed Service).

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.

  • GENERALThe criterion — depend on what changes more slowly than you do — is about change rates rather than technology, so it applies equally to a module boundary, a service contract and a database schema; only the cost of being wrong scales up.
  • LIFETIME-SPECIFICStability is measured in changes, not months. For a codebase with a two-year life and one vendor, the wrapper may never see a provider change and is straightforwardly wasted; the same design in a system expected to outlive two vendor contracts pays for itself several times.
  • CONTESTEDThe strongest opposing view is that vendor-abstraction layers are among the most commonly built and least commonly repaid structures in the industry: the promised switch usually never happens, the abstraction converges on the incumbent's model anyway, and it costs the intersection of features plus a permanent translation tax. Engineers who have lived through a provider migration report that the abstraction rarely survived contact with the second provider's different model. The counter is narrower than the usual claim: the boundary earns its keep on the *recurring* cost of the vendor's own breaking changes, which is observable today, and the migration is a secondary benefit that may never arrive.

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 contract suite that every implementation of a boundary must pass is the mechanism that turns a stability claim into something a build can check.
  • System Design — the same change-rate reasoning decides which seams in a system are worth turning into network boundaries, where getting it wrong costs latency and partial failure rather than a file.