BoundariesGENERALSCALE-SPECIFICCONTESTED

Boundary Adapters

Translate external formats into your own model at the edge, once. The rule that keeps a vendor SDK's types from spreading through code that has nothing to do with the vendor.

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

Where should an external system's data stop being the external system's data and start being ours?

The requirement

A CRM integration was added in a hurry. Nine months later the CRM vendor announces a breaking API version, and the migration estimate is four weeks for what the vendor describes as a field rename.

The obvious build

Use the SDK's types directly. They are already defined, already documented, already correct, and writing a parallel set of our own types is duplication with extra steps.

Why it breaks

It is a completely reasonable decision on day one, and it costs nothing until the vendor changes something. The break is not that the types are wrong; it is that they are *theirs*, so every one of their decisions becomes a change to your code (Volatile Dependencies).

How it breaks as requirements change
  • It is a completely reasonable decision on day one, and it costs nothing until the vendor changes something. The break is not that the types are wrong; it is that they are *theirs*, so every one of their decisions becomes a change to your code (Volatile Dependencies).
  • The types spread by ordinary, blameless steps: the SDK object is convenient to pass along, so it is passed along, and after nine months a vendor type appears in a report generator that has no idea what a CRM is (Feature Envy).
  • Their model admits states yours does not — a contact with no email, a date as a string in a format that varies by locale, an enum with a value added last week — and every consumer either handles that or quietly does not (Primitive Obsession).
  • And the missing test is the expensive part. Nobody can say what the system requires of a contact, so the migration cannot be verified; it can only be attempted (Characterization Tests).
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 vendor's deprecation window is ninety days and is not negotiable.
  • The SDK's response type is used directly as the shape stored in the database and returned from two internal APIs, so the change has consumers outside the team.
  • There is no test that asserts what a contact looks like in our system, because the type was never ours to assert about.
Invariants
  • A contact in our system has an identity, an email that we have validated, and an owner — regardless of which CRM produced it or which version of its API (Value Objects).
  • No record can enter the system in a state our rules consider impossible, even if the external system permits it (Making Illegal States Unrepresentable).

Who owns what, and where the seams fall

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

Responsibilities
  • The adapter owns one external system and one direction of translation each way: their shape to ours on the way in, ours to theirs on the way out (Adapter).
  • The adapter owns their error vocabulary too, mapping it to the small set of failures the rest of the system knows how to act on (An Error Taxonomy That Survives Contact).
  • The internal model owns what is true for us — which fields are required, what an identity is, which states are legal — independent of what any vendor sends (Domain Modeling).
  • Nothing downstream of the adapter is responsible for knowing that a vendor exists. That is the property being bought, and it is the only way to check whether the adapter is real.
Boundaries
  • The seam is at the process edge: the first function that touches a vendor response is the last function allowed to see a vendor type. Data crosses it as our types, always (Stable Boundaries).
  • This applies to more than SDKs. A JSON webhook body, a CSV column layout, a database row from a system you do not own and an LLM's response text are all external formats with the same property — someone else decides when they change (Trust Boundaries).
  • The boundary is where validation belongs, because it is the only place that has both the raw input and the knowledge of what our model requires. Validating later means the invalid value has already been stored somewhere (Where Invariants Live).

The vendor type in the report generator

Nobody decides to spread a vendor type through a codebase. It happens because at every individual step, passing the object along is the least work — and each step is defensible on its own. Nine months later a type named after somebody else's product is a parameter in a function about invoices.

The fix is not a rule about wrapping things. It is a single boundary with a single property that can be checked: exactly one file imports the SDK. Everything downstream sees types you defined, which means everything downstream is unaffected by anything the vendor does that you chose to absorb (Information Hiding).

Where the vendor stops
Their type, everywhere
import { CrmContact } from '@vendor/crm-sdk'

// crm/client.ts
export async function fetchContact(id: string): Promise<CrmContact> {
  return sdk.contacts.get(id)   // handed straight out
}

// billing/invoice.ts   <- has no business knowing a CRM exists
function billingEmailFor(c: CrmContact): string {
  return c.email_addresses?.[0]?.value ?? c.primary_email ?? ''
}

// Their v2 renames email_addresses. This function is one of
// nine sites that must be found by grep, and nothing asserts
// what billing actually requires of a contact.
Our type, from the first line
// crm/adapter.ts  — the only file importing the SDK
import { CrmContact } from '@vendor/crm-sdk'
import { Contact, Email } from '../contacts/model'

export function toContact(raw: CrmContact): Contact | Rejected {
  const email = Email.parse(pickEmail(raw))
  if (!email.ok) return rejected('contact-missing-email', raw.id)
  return new Contact(ContactId.of(raw.id), email.value, ownerOf(raw))
}

// billing/invoice.ts
function billingEmailFor(c: Contact): Email {
  return c.email      // required by construction; no fallbacks
}

The second version turns "the vendor renamed a field" from a nine-site search into a one-line edit in one function, and it turns "what does billing need from a contact" from tribal knowledge into a type that fails to construct when the requirement is not met. The three-way fallback in the first version is the real tell: it exists because nobody knew which field would be populated, and that uncertainty was distributed to every caller instead of being resolved once at the edge (Parse, Do Not Validate in Backend teaches the mechanism).

What the adapter owns, and what that says about the boundary

An adapter that only renames fields is not doing enough to be worth its existence. The useful test is to write down everything the adapter is responsible for and check that each item would otherwise have been distributed across callers.

Note the last line of the verdict below. An adapter has several reasons to change, and that is correct rather than a violation — they are all the *same* reason from the system's point of view: the vendor did something. Concentrating a set of correlated reasons to change into one unit is precisely what a boundary is for (Divergent Change).

responsibilitiescrm/adapter.ts — the only module that imports @vendor/crm-sdkThe CRM adapter, once it is real
Knows
  • The vendor's field names, including the two that mean the same thing
  • Which of their fields are actually optional in practice, as opposed to in their docs
  • Their enum values and which ones we treat as equivalent
  • Their error codes, their rate limit headers and their pagination cursor format
  • Which fields we deliberately discard
Does
  • Fetches and pushes over the SDK
  • Parses their payload into our Contact, rejecting what our rules forbid
  • Maps their errors into our small failure set
  • Stores the raw payload beside the parsed record for audit and replay
  • Applies their retry and backoff rules so callers never see a 429
Depends on
  • The vendor SDK
  • Our contacts model
  • Our clock, injected, so token expiry is testable (Time as a Dependency)
Changes when — 4 distinct reasons
  • The vendor changes a field, an enum or an error code
  • The vendor changes rate limits or pagination
  • Our model gains a field that must be sourced from them
  • We start discarding, or stop discarding, one of their fields

Four reasons to change, and unlike the usual finding this is healthy: three of the four are "the vendor did something", which is one reason wearing three hats, and concentrating it here is the entire purpose. The one to watch is the fourth — when our model changes, this file changes too, which means the adapter is coupled to both sides by construction and can never be more stable than the less stable of them. If the adapter starts changing for reasons that are neither the vendor nor the model, something unrelated has been put inside it and should be moved out (Cohesion).

How adapters fail, including the ones that look right

The failure modes here are worth memorising because they are quiet. An adapter that is not really a boundary passes review, satisfies the diagram, and provides none of the protection it was built for — and you find out during the vendor migration, which is the worst possible time.

Each row below has the same shape: something crosses the boundary that should not have, or the boundary is verified against something that is not the vendor.

Adapter failure modes
TriggerSymptomCauseResponse
The adapter method returns the SDK typeA vendor type appears in autocomplete in modules that have nothing to do with the vendorThe adapter was written as a convenience wrapper rather than as a translation, usually because the initial mapping felt like duplicationChange the return type to your own and let the compiler enumerate the call sites. This is the cheapest day it will ever be (Extract Module).
Vendor adds an enum valueRecords silently take the default branch and end up in a state nobody designedThe mapping used a non-exhaustive switch or a lookup with a fallback, so unknown input became a legal valueMake the mapping exhaustive and reject the unknown loudly, then decide what it means. Unknown must be a distinct outcome, not a default (Optional Values and Absence).
Vendor changes a field they never documented as ours to useA downstream feature quietly degrades — a report shows blanks — with no error anywhereA field was mapped optimistically with a fallback to empty, so absence and emptiness became indistinguishableSeparate "absent", "empty" and "invalid" in the internal model and force the caller to handle them (Making Illegal States Unrepresentable).
Fixtures were recorded once, eighteen months agoAdapter tests pass continuously while production integration failsThe tests verify your recorded belief about the vendor, not the vendorAdd a scheduled check against the sandbox or the published schema, and treat a fixture as documentation of a past response rather than a contract (Contract Tests).
The internal model was derived field-for-field from theirsEvery vendor schema change still requires an internal change, despite the adapterThe model was written by looking at their payload rather than at your requirementsRe-derive the model from what your rules need, then map. Fields you never use should not exist internally at all (Exposing Too Much).
Retry and rate-limit handling live in the callersFour different backoff implementations, one of which hammers the vendor during an incidentThe adapter was scoped to data translation only, leaving operational behaviour to whoever called itPull the operational contract inside the adapter so callers see a plain call that either succeeds or returns a known failure (Designing for Failure).

How to build it

Most important first.

  • Define your own type for the concept first, from your requirements, before looking at their schema. Deriving your model from theirs guarantees it inherits their decisions (Choosing the Model).
  • Parse at the edge into that type, rejecting or defaulting explicitly. A parse that returns your type is a proof that the value is usable; a cast is a promise nobody checked (Enforcing Invariants).
  • Map their errors and their enums into yours exhaustively, so a new value they add is a compile error or a logged rejection rather than a silent fall-through (An Error Taxonomy That Survives Contact).
  • Keep the raw payload if you may need it — for debugging, replay or audit — but store it as an opaque blob beside your model, never as the model (Logging at Boundaries).
  • Put one adapter per external system, and make it the only file that imports the SDK. That single-import rule is enforceable with a lint check and is what stops the slow spread (Do We Need a Package for This?).

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 bumps a field name: one edit in the mapping function, one fixture updated, one test run. Bounded and roughly constant regardless of how many consumers there are.
  • Vendor adds a status value: an exhaustive match makes it a compile error at exactly one site, so the cost is the decision about what it means rather than the search for where to make it.
  • Adding a second CRM: one more adapter against the same internal model, and no consumer changes at all. This is where the boundary pays back the most and it is why the same lesson underlies hexagonal seams (Hexagonal Architecture (Ports and Adapters)).
  • What did not get cheaper: a change to the internal model still touches every consumer, exactly as before. The adapter isolates you from *their* churn, not from your own.
What the recommended approach costs
  • You maintain a parallel type and a mapping function forever, including for fields that are identical on both sides and probably always will be.
  • Debugging gains a hop: the value in the log is yours and the value the vendor sent is not, so keeping the raw payload becomes necessary and costs storage.
  • The internal model is a design decision made early, when you know least about the domain, and a bad one is more expensive to change than a vendor type would have been (Premature Abstraction).

What can go wrong

Failure modes
  • The adapter exists but returns the vendor type, so it is a function call with no boundary. This is the most common outcome and it looks correct in a diagram (Leaky Abstractions).
  • Translation is lossy in a way nobody noticed, and a field that mattered to one downstream consumer is silently dropped (Invariant Leaks).
  • The internal model is a rename of the vendor model, field for field, so a vendor schema change still forces an internal change — the ceremony was paid and the decoupling was not bought (Speculative Generality).
  • The mitigation fails on its own: adapter tests use recorded fixtures captured once, so they keep passing after the vendor changes and the first signal is a production failure (Contract Tests).
Dependencies, and their direction
  • The adapter depends on the vendor SDK and on your internal model. Nothing depends on the adapter except the wiring and the code that needs the external system (Wiring and the Composition Root).
  • Your model depends on nothing external, which is the whole point and is checkable by grep.
  • A subtle one: the adapter often ends up depending on the vendor's *pagination and rate limits*, which are operational facts rather than data shapes. Those belong in the adapter too, and forgetting them pushes retry logic into callers (Retries in Backend covers the mechanism).
Misreads
  • "So wrap every library." No. The rule is about systems whose format someone else controls and changes on their schedule. A JSON parser, a date library or a hashing function is not that, and wrapping it buys nothing (Dependency Inversion, Critically).
  • "The adapter is just a mapping function, so it is trivial." It is where validation, error translation, defaulting, enum handling and lossy-field decisions live. That is most of the integration's real complexity, and concentrating it is the point (Essential and Accidental Complexity).
  • "Our internal model should mirror theirs so mapping is easy." Then their model is your model with extra steps, and their next change is still your next change (Naming and Domain Language).
  • "We use their types because they are already validated." Their validation encodes their invariants, not yours. A contact valid to a CRM can be unusable to your billing rules (Invariants).
Smells this explains
  • primitive-obsession
  • shotgun-surgery
  • feature-envy

Testing it, and how it ages

What to test, and at which boundary
  • Test the mapping function directly with recorded real payloads, including the malformed and partial ones, because those are the cases the caller must never see (What a Unit Is).
  • Test the rejection path as carefully as the success path: a payload with a missing required field must fail at the boundary with a useful message, not thirty frames later (Error Boundaries).
  • Run a scheduled contract check against the vendor's live schema or sandbox, so a vendor change is detected by a build rather than by a customer (Contract Tests).
  • Add one test that no module outside the adapter imports the SDK. It is a one-line assertion and it is what keeps the boundary from eroding.
How this design ages
  • Adapters accumulate vendor quirks — a date that is sometimes null, an id that is numeric in one endpoint and a string in another — and that accumulation is success. Each quirk absorbed at the edge is a quirk that never reached the rules.
  • When a second external system arrives, the internal model is stress-tested for the first time, and it usually needs one round of adjustment. That round is much cheaper than the alternative, because it happens in one place (The Rule of Three).
  • The adapter stops being enough when the external system's *concepts* differ from yours, not just its formats. At that point field mapping is not translation and you need an anti-corruption layer (Anti-Corruption Layer).

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 owner of a format decides when it changes is true of every external system regardless of language, paradigm or scale, so the argument for translating at the edge does not depend on the stack. Where it varies is only how much the language helps: exhaustive matching and parse-into-type make the boundary nearly free to enforce, and their absence makes it a discipline rather than a check.
  • SCALE-SPECIFICFor a script that reads one endpoint and exits, using the vendor type directly is correct and an internal model is pure overhead — the vendor cannot break code that has already finished running. The argument strengthens sharply with the number of call sites and the expected lifetime, and flips somewhere around "more than one consumer" or "outlives the current API version".
  • CONTESTEDA serious counter-position: the internal model is invented before you understand the domain, so it is usually a worse model than the vendor's battle-tested one, and teams pay the mapping cost forever to protect against a breaking change that may never come. That is right for stable, standardised formats with a single consumer — and it is exactly wrong for a vendor SDK type that has already reached nine call sites, because the cost of extraction rises with spread while the cost of the boundary does not.

Where the depth lives

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

Distributed Systemslocal-vs-remote-call
Domains that do not exist yet
  • Testing & Reliability Engineering — the recorded-fixture trap is the reliability question underneath this lesson: a test that cannot fail when reality changes is not providing the confidence it appears to.
  • Programming Languages & Runtime Internals — how cheaply this boundary can be enforced depends on the language's parsing and exhaustiveness facilities; sum types turn "handle the unknown case" from a review comment into a compile error.