PatternsGENERALSCALE-SPECIFICCONTESTED

Adapter

Your interface on one side, someone else's on the other, and a translation in between. The most consistently useful pattern here, because it is an anti-corruption layer in miniature.

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

A third-party client does not match how my code thinks. Do I bend my code to it, or put something in between?

The requirement

Payments go through a provider whose SDK returns its own PaymentIntent, throws its own error hierarchy, and expresses amounts as integer minor units with a currency string. Checkout, refunds, the reconciliation job and three tests all touch it.

The obvious build

Use the SDK directly. It is well documented, its types are perfectly good, and wrapping it means writing and maintaining a parallel set of types for no functional gain.

Why it breaks

The vendor's vocabulary spreads. Six months in, PaymentIntent appears in the domain model, in a database column name and in an internal API response, and it is now part of your contract with your own clients (API Stability).

How it breaks as requirements change
  • The vendor's vocabulary spreads. Six months in, PaymentIntent appears in the domain model, in a database column name and in an internal API response, and it is now part of your contract with your own clients (API Stability).
  • The vendor's major version lands. The migration is not a dependency bump but an edit to every file that mentions their types, under a deprecation deadline you do not control (Do We Need a Package for This?).
  • Error handling is written against their exception hierarchy, so every caller must know which of their eleven error classes are retryable — knowledge that is duplicated at every call site and goes stale silently (Retries Are a Property of the Operation).
  • Tests need the SDK. The reconciliation job's logic cannot be tested without a sandbox key, so it is tested by hand, so it is not tested (Testing as Design Feedback).
  • The second provider arrives and there is no seam to put it behind. The estimate for "add a provider" is the same as for "rewrite payments".
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 SDK is not ours and changes on the vendor's schedule, including breaking changes at major versions (Transitive Dependencies).
  • A second provider is a realistic possibility within two years — the commercial team has said so, without a date (Design for the Known, Name What You Assumed).
  • The SDK cannot run in unit tests without network access or a sandbox key.
Invariants
  • No vendor type crosses into the domain. PaymentIntent, vendor error codes and raw minor-unit integers stop at one line (Trust Boundaries).
  • Every vendor outcome maps to exactly one of our outcomes — success, declined, retryable failure, permanent failure. No outcome is unmapped (An Error Taxonomy That Survives Contact).

Who owns what, and where the seams fall

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

Responsibilities
  • One module owns the translation: our vocabulary in, theirs out, and back — including errors, money, identifiers and time.
  • The adapter owns the *decision* about what their outcomes mean to us, which is the part everyone assumes is mechanical and is not.
  • The domain owns being ignorant. If domain code can name the vendor, the adapter has failed (Anti-Corruption Layer).
Boundaries
  • The boundary is one file thick and total: nothing vendor-shaped exists above it, and nothing domain-shaped exists below it (Boundary Adapters).
  • The interface is defined by the *consumer*, in the consumer's module, in the consumer's vocabulary. An interface derived from the SDK is not an adapter — it is the SDK with a new name (Interface Versus Implementation).
  • Money, ids and time are the three things that leak most often, and each deserves its own type at the boundary rather than a primitive (Units in Names and Types).

One line where their world stops

The whole pattern is the vertical line in the diagram. Above it, everything is expressed in your vocabulary and can be reasoned about, tested and changed on your schedule. Below it is somebody else's release cycle.

Note that the fake is not a testing convenience bolted on — it is the second implementation, and its existence is what makes the interface honest. An interface that only the real adapter can satisfy has been shaped by the vendor.

  • Nothing above the port may name a vendor type — that is the invariant, and it is checkable with a lint rule (What to Automate Out of Review).
  • The port is named for what the domain needs, never for the vendor (Naming).
  • The fake makes the domain testable in milliseconds, which is the benefit that arrives on day one rather than at the vendor migration.
The boundary, and what is allowed to cross it
charge(Money, method)implementsimplementsthe only import in the codebaseDomain: Money, ChargeOutcomeStripeAdapter: translateInMemoryCards: the second implChargesCards (our interface)Vendor SDK: PaymentIntent, 11 error classesVendor API
UserLLMAgentToolDataDecisionHumanGuardrail

The translation is a design decision, not a mapping

The part that looks mechanical and is not: deciding what their outcomes *mean*. Their eleven error classes collapse into four of yours, and each collapse is a judgement about what your system will do about it. Getting that wrong is how a retryable network blip becomes a permanent failure recorded against a customer.

The exhaustive switch below is deliberately boring. Boring is the correct shape here, because the alternative — inheritance-based or reflective translation — hides exactly the decisions you most need to see in review (Local Reasoning).

Their vocabulary in, ours out
1// our interface, in our module, in our words
2export interface ChargesCards {
3 charge(amount: Money, method: PaymentMethod): Promise<ChargeOutcome>
4}
5
6export class StripeAdapter implements ChargesCards {
7 async charge(amount: Money, method: PaymentMethod): Promise<ChargeOutcome> {
8 try {
9 const pi = await this.sdk.paymentIntents.create({
10 amount: amount.minorUnits, currency: amount.currency.toLowerCase(),
11 payment_method: method.token, confirm: true,
12 })
13 return pi.status === 'succeeded'
14 ? { kind: 'captured', id: ChargeId.of(pi.id) }
15 : { kind: 'declined', reason: mapDecline(pi.last_payment_error) }
16 } catch (e) {
17 return { kind: mapError(e), vendorCode: codeOf(e) } // keep their code
18 }
19 }
20}
21
22// mapError is a switch over their codes with no default:
23// an unmapped code must fail the build, not the payment.

vendorCode is deliberate: the translation must not destroy the evidence, or every production incident starts with "what did they actually say". Keeping the original code is not a leak — nothing branches on it (Debuggability by Design).

What the boundary is worth when the vendor moves

This is the change everyone builds an adapter for, and it is worth pricing honestly: the adapter makes the *code* change small and does nothing about the operational half. That distinction is the difference between a realistic estimate and the one that gets the project approved.

Vendor ships a breaking major version
The change

The provider releases v5: PaymentIntent is restructured, the error hierarchy is replaced with error codes, and minor-unit handling changes for zero-decimal currencies. Old version is supported for six months.

SDK called directly from wherever payments happen
checkout/chargerefunds/servicejobs/reconcileapi/payment-statusadmin/payment-viewdomain/Order (holds a PaymentIntent)
testsevery test that constructs a payment — 40+, all needing sandbox or heavy mocks
6 modules · 1 test file

The domain model itself holds a vendor type, so the migration reaches the database column and the internal API response. The deadline belongs to the vendor.

One adapter behind a domain-owned interface
payments/StripeAdapterpayments/mapError
testsadapter_contract_testerror_mapping_table_test
2 modules · 2 test files

The domain does not compile differently. The contract test suite is the proof of equivalence, and it already exists because the fake needed it.

what it cost The adapter is a parallel vocabulary that had to be written, maintained and kept honest for however long it took the vendor to break something — potentially years of maintenance for a change that might never come. It also loses capability: anything the vendor offers that the interface does not expose is unreachable without widening the port, and teams do widen it, which is how the boundary erodes. And none of this touches the operational half: reconciling two providers, migrating stored payment methods and retraining support are unchanged (Designing the Migration).

How to build it

Most important first.

  • Write the interface you would want if the vendor did not exist: charge(amount: Money, method: PaymentMethod): Promise<ChargeOutcome>. Then make the adapter satisfy it.
  • Map errors exhaustively and explicitly. A switch over the vendor's error codes with a default that maps to "permanent failure and alert" is better than any clever inheritance-based translation (Error Modeling).
  • Convert primitives at the boundary: their minor-unit integer plus currency string becomes our Money; their id string becomes our ChargeId (Primitive Obsession).
  • Keep the adapter dumb. It translates; it does not retry, does not log business events, and does not decide policy — those are the caller's and they need to be testable without the vendor (Separation of Concerns).
  • Write one contract test suite against the interface and run it against both the adapter (in sandbox, slowly, in CI nightly) and an in-memory fake (fast, everywhere else) (Contract Tests).
  • Accept that the adapter is where their model and yours genuinely disagree, and that resolving the disagreement is design work — an outcome they call "requires_action" has to become something in your model, and choosing what is not mechanical.

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
  • Named change — "the vendor releases v5 with a renamed error hierarchy": with an adapter, one file and its tests; without, every file that mentions their types, found by compiler errors if you are lucky and by production if you are not.
  • Named change — "add a second provider for one region": with an adapter, a second implementation of an interface that already exists and is already contract-tested; without, a rewrite. This is the change the pattern is really for, and it is the one people build it for and then never need.
  • Named change — "run the reconciliation job's logic in a unit test": with an adapter, pass a fake, and the job becomes testable in milliseconds. This benefit arrives immediately and is the one that pays for the adapter even if the other two never happen.
  • What the adapter does *not* make cheaper: a change to the vendor's semantics rather than their types. If they start settling asynchronously, the adapter cannot hide that — the domain has to learn about pending settlement, and no amount of translation prevents it (Leaky Abstractions).
What the recommended approach costs
  • You write and maintain a parallel vocabulary — types, mappings, tests — that adds no user-visible behaviour.
  • Debugging crosses a translation layer, so a vendor error in a log has been renamed by the time you see it. Keeping the original code in the mapped error is worth the field (Debuggability by Design).
  • For a vendor you will genuinely never replace and whose types are genuinely good, the adapter is cost with only the testability benefit — which is often still enough, but the honest case is thinner than the usual pitch.

What can go wrong

Failure modes
  • A leaky adapter: the interface returns the vendor's error type, or exposes a rawResponse field that three callers now read. The boundary exists and does not hold (Leaky Abstractions).
  • The interface is traced from the SDK — same method names, same shapes — so a second provider cannot implement it and the adapter delivered none of what it promised (Premature Abstraction).
  • Translation loses information the domain later needs: the vendor's decline reason is collapsed to "declined", and then support asks why the customer was declined.
  • The mitigation fails too: mapping every vendor field into our types produces a parallel model that must be maintained field-for-field, and a vendor change now costs *more* than it would have without the adapter (What an Abstraction Costs).
Dependencies, and their direction
  • The domain depends on the interface. The adapter depends on the interface and on the SDK. The SDK depends on nothing of ours. That is the only arrangement in which the vendor can change without the domain changing (Dependency Direction).
  • The adapter concentrates the vendor dependency at exactly one point, which makes the dependency surface countable — one import, one place to audit (Do We Need a Package for This?).
  • Everything else in the system depends on your vocabulary, which you control and can version (Versioned Interfaces).
Misreads
  • "Wrap every library." No. Wrap the ones whose types would spread into your domain, that you might replace, or that block testing. A JSON parser or a date library that you use locally needs no adapter (YAGNI, With Its Bill Attached).
  • "The adapter makes the vendor swappable." It makes the *code change* smaller. Data migration, reconciliation across two providers and operational differences are unaffected, and they are usually the larger half (Designing the Migration).
  • "An interface with one implementation is premature." This is the case where it is not, because the second implementation is the fake you use in tests — that is a real second implementation with a real different behaviour (Interface Versus Implementation).
  • "Adapter equals anti-corruption layer." An ACL is the same idea at a larger grain, with a whole model translated rather than one interface. Same principle, different scale of investment (Anti-Corruption Layer).
Smells this explains
  • primitive-obsession
  • feature-envy

Testing it, and how it ages

What to test, and at which boundary
  • Contract tests against the interface, run against both the real adapter and the fake, so the fake cannot drift from reality unnoticed (Contract Tests).
  • Table-driven mapping tests: every vendor error code, every vendor status, and an assertion that no input reaches the default branch unmapped.
  • Domain tests use the fake and never the SDK. If a domain test imports the vendor package, the boundary has already been crossed (What a Unit Is).
  • One nightly test against the vendor sandbox to catch the change they made without telling you (API Migration: Running the Change End to End).
How this design ages
  • Adapters age well because their surface is fixed by your needs, not the vendor's. The vendor's surface can double and the adapter grows only where you actually consume it.
  • They age badly when the vendor's *model* diverges from yours: async settlement, partial captures, multi-currency accounts. That is when the adapter stops being translation and starts being a small system of its own (Module Granularity).
  • The second implementation is where the interface finally gets validated. Expect it to be slightly wrong and expect the fix to be cheap, because only the adapters change (The Rule of Three).

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.

  • GENERALTranslating at a boundary you do not control is right in every language and paradigm, because the reason is ownership rather than mechanism: their vocabulary changes on their schedule and yours changes on yours.
  • SCALE-SPECIFICFor a script or a prototype the adapter is overhead and calling the SDK directly is correct; the trigger is not codebase size but *number of call sites and expected lifetime* — three call sites that will live three years is already past the line, and one call site in a tool being deleted in a month is not (When Design Does Not Pay).
  • CONTESTEDThe strongest opposing case: adapters around large SDKs frequently reimplement a worse version of the vendor's model, lose capability, and go stale — and teams that call the SDK directly and confine it to one module by convention get most of the containment for none of the maintenance. That is a real pattern in practice; the difference is whether "by convention" survives four years and six engineers, which it usually does not.

Where the depth lives

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

Domains that do not exist yet
  • System Design — the same boundary at service grain, where the adapter becomes a whole translating service and the argument shifts from types to schemas and deployment coupling.
  • Testing & Reliability Engineering — the fake behind the port is what makes fast domain tests possible, and contract tests are how the fake is kept from quietly diverging from the real thing.