Skip to content

9.4.7 — Adapter

What the original Gang of Four book says: Convert the interface of a class into another interface that clients expect. Adapter lets classes work together that otherwise could not, because their interfaces do not match.

What that means when you are actually writing code: When a thing you need does the right job but speaks the wrong interface, write a thin translator, so your code speaks only its own language — and you gain a seam you can swap out and test.

Adapter is the pattern you will reach for more than any other structural pattern in a real job, because real jobs are eighty percent integrating things you did not write: SDKs, legacy modules, third-party APIs, another team's service. It is also the pattern with the highest ratio of value delivered to code written — a good adapter is often twenty lines and saves a codebase from vendor lock-in for years.

1. The story: the SDK that changed under you

You integrate Stripe for payments. The SDK is good, so you use it directly:

typescript
const charge = await stripe.charges.create({          // ← Stripe's shape, everywhere
  amount: order.totalCents,
  currency: "usd",
  source: token,
  metadata: { orderId: order.id },
});
if (charge.status === "succeeded") { /* … */ }         // ← Stripe's status vocabulary

Convenient. So it spreads: into the checkout service, the subscription renewer, the refund handler, the retry job, the reconciliation report, and forty tests. Every one of them now speaks fluent Stripe — charges.create, charge.status === "succeeded", stripe.errors.CardError.

Two years later, one of three things happens, and all three are completely normal.

First, Stripe releases version 12 of the SDK and renames charges to paymentIntents with a different result shape. You now have a breaking change scattered across forty files, and the migration is a quarter of work with a code freeze.

Second, the business signs a deal requiring a second processor — Adyen for Europe — whose SDK looks nothing like Stripe's. Your forty files cannot take a second vocabulary without forty if (region === "EU") branches.

Third, you try to write a unit test for the refund handler and discover you cannot, because the handler is Stripe — testing it means talking to Stripe's test servers over the network, which is slow, flaky, and needs secrets in CI.

The root cause is one sentence: your business logic was allowed to depend on a shape you do not own. Adapter fixes it by inserting a translator that you do own.

2. How you arrive at the pattern

Step 1 — Start naive. Call the foreign library directly wherever you need it. This is correct for a throwaway script, or for a library you are certain you will never replace and never need to mock.

Step 2 — Wait for the force. The foreign interface is not yours to control, and it collides with something you need: it changes on someone else's schedule, or a second implementation of the same capability arrives with a different interface, or you need a test double and cannot make one for code you do not own.

Step 3 — Draw the line between what varies and what stays fixed.

What variesthe foreign interface — its method names, argument shapes, return shapes, error types, and its very existence (it may be replaced)
What stays fixedthe capability your domain needs — "charge a card, get a result", stated in your vocabulary

The fixed part becomes a role you define — the target interface, sometimes called a port (9.3.9, Dependency Inversion). The varying part gets wrapped in an object that implements your role by translating to the foreign one — the adapter.

Step 4 — Decide when the choice is made. Runtime composition: the adapter holds the foreign object (the adaptee) and hands calls off to it. This is the object adapter, which is the modern default. (The Gang of Four also list a class adapter using multiple inheritance; section 7 explains why you will almost never use it.)

Step 5 — Name the pattern and say what it costs. The name is Adapter. The costs: one extra layer of indirection and one more object to maintain; the adapter can only expose capabilities the adaptee actually has (it translates, it does not invent); and a leaky adapter that lets the foreign vocabulary bleed through its interface gives you all the cost and none of the isolation (section 10). The benefit is decisive: your domain depends on a stable shape you control, and swapping or mocking the foreign thing becomes a one-file change.

3. The mental model

In one sentence: an adapter is a travel plug. The appliance and the wall socket both work perfectly; they just do not fit, so you put a small shim between them and stop caring about the mismatch.

The analogy that makes it stick — the interpreter in a meeting. Your team speaks English; a supplier speaks Japanese. You do not make everyone learn Japanese (which is rewriting all your code to Stripe's shape), and you do not force the supplier to speak English (you cannot change Stripe). You hire one interpreter who sits between them. Your team says what it means in English, the interpreter translates, and the answer comes back in English. If you switch suppliers, you swap the interpreter, and your team never notices.

When to reach for it. The signals are: "their SDK or API doesn't match what our code expects" · "we need to support a second vendor for the same thing" · "I can't mock this because it's a third-party library" · "we're migrating from X to Y and don't want to touch every call site" · "the old module returns callbacks and our code is all promises." And when you scale it up: "we're calling another team's service and don't want their model leaking into ours" — that is the same pattern with a name of its own, the anti-corruption layer.

The one distinction people blur — Adapter versus Facade. Both wrap something. Adapter's job is to change an interface to match one you already require (you have a target shape in mind). Facade's job is to simplify a complex subsystem behind an easier interface you invent for convenience (there was no pre-existing shape to match). Adapter says "make this fit that"; Facade says "make this simpler." 9.4.9 develops the contrast fully.

4. Structure

your domaincheckout · refund① target interface (port)PaymentGatewaycharge() · refund()YOUR vocabulary② the adapterStripeAdapterimplements the port,translates callsadapteestripe SDKusesimplementswrapsswap the adapter → swap the vendorAdyenAdapter, FakeGateway (tests) — domain code unchangedThe port is the contract; adapters are interchangeable translations behind it.
Figure 7 — The translator seam. Your domain (blue) depends only on the target interface (grey) written in your vocabulary. Each adapter (green) implements that interface by handing calls off to a foreign adaptee (amber). Because the domain never names the adaptee, swapping vendors — or dropping in a fake for tests — is a change confined to one green box.

The participants: the Target (the interface your client requires — the port), the Client (your domain, which depends only on the Target), the Adaptee (the foreign class with the useful-but-wrong interface), and the Adapter (which implements the Target by delegating to the Adaptee and translating in both directions).

5. The implementation, line by line

typescript
// ── The target interface (port): YOUR vocabulary, YOUR types — the fixed part
export interface PaymentGateway {                        // (1)
  charge(amount: Money, token: PaymentToken): Promise<ChargeResult>;
  refund(chargeId: ChargeId, amount: Money): Promise<RefundResult>;
}
export type ChargeResult =                                // (2) your result shape, not Stripe's
  | { ok: true; id: ChargeId }
  | { ok: false; reason: "card_declined" | "insufficient_funds" | "network"; retryable: boolean };

// ── The adapter: implements the port by translating to the Stripe SDK
export class StripeGateway implements PaymentGateway {
  constructor(private readonly sdk: Stripe) {}           // (3) holds the adaptee (composition)

  async charge(amount: Money, token: PaymentToken): Promise<ChargeResult> {
    try {
      const intent = await this.sdk.paymentIntents.create({   // (4) translate the REQUEST
        amount: amount.cents,                            //     Money → Stripe's integer cents
        currency: amount.currency.toLowerCase(),         //     Currency → Stripe's lowercase code
        payment_method: token.value,
        confirm: true,
      });
      return { ok: true, id: intent.id as ChargeId };    // (5) translate the RESPONSE
    } catch (e) {
      return this.#mapError(e);                          // (6) translate the ERRORS — the part people forget
    }
  }

  async refund(chargeId: ChargeId, amount: Money): Promise<RefundResult> { /* … */ }

  #mapError(e: unknown): ChargeResult {                  // (7) foreign errors → your vocabulary
    if (e instanceof Stripe.errors.StripeCardError) {
      const retryable = e.code === "processing_error";
      const reason = e.code === "insufficient_funds" ? "insufficient_funds" : "card_declined";
      return { ok: false, reason, retryable };
    }
    if (e instanceof Stripe.errors.StripeConnectionError) {
      return { ok: false, reason: "network", retryable: true };
    }
    throw new UnexpectedGatewayError(e);                 // (8) truly unknown → don't swallow
  }
}

Now the numbered lines.

(1) The port is defined by your needs, not the vendor's surface. It has exactly two methods, because your domain does exactly two things. It does not expose paymentIntents, customers, or the other 200 Stripe endpoints — this is 9.3.8's Interface Segregation: a client-specific interface, not the adaptee's full API.

(2) The result type is yours. ChargeResult uses your reasons (card_declined, insufficient_funds) as a discriminated union your business logic can switch on exhaustively (3.7.3). Stripe's status strings never escape the adapter.

(3) Composition, not inheritance. The adapter has-a SDK. This is the object adapter, and the reason it beats the class adapter is that you can inject a mock SDK, wrap several adaptees, and avoid coupling to the adaptee's class hierarchy (section 7).

(4) Translate the request. Money — your value object with cents and a currency — becomes Stripe's integer-cents-plus-lowercase-currency convention. This is the direction people remember.

(5) Translate the response. Stripe's PaymentIntent becomes your ChargeResult. The as ChargeId brands the id (3.7.7) so that a raw string cannot be passed where a charge id is required.

(6) Translate the errors — this is the part everything rests on, and the most-skipped one. An adapter that translates requests and responses but lets Stripe.errors.CardError propagate has failed at its job: your domain now catches Stripe's error types, so the coupling you removed from the happy path walks right back in through the catch block. A complete adapter maps foreign errors to your vocabulary too.

(7) Error mapping is a small state machine. A foreign error type plus its code map to your reason plus a retryability flag. Getting retryable right here is what lets a generic retry layer (9.4.8) work without knowing anything about Stripe.

(8) Do not swallow the unknown. An error you did not anticipate must not become a silent { ok: false } — it should throw loudly, so it is noticed and mapped later. Adapters translate the known; the unknown is a bug to surface.

What this does when you run it: new StripeGateway(stripe).charge(money(2000, "USD"), token) returns { ok: true, id } or a typed failure — and nothing about Stripe is observable in the return value or the thrown error. Swap in new AdyenGateway(adyen) and the domain code does not change a character.

5.1 The two-way and structural cases

Adapters often translate in both directions — not just request and response, but also pushed data. A webhook is the classic case: Stripe posts its event shape to you, and an adapter translates inbound:

typescript
class StripeWebhookAdapter {
  toDomainEvent(raw: Buffer, sig: string): PaymentEvent {   // foreign push → your event
    const ev = this.sdk.webhooks.constructEvent(raw, sig, this.secret);  // verify + parse
    switch (ev.type) {
      case "payment_intent.succeeded": return { kind: "charge_settled", id: ev.data.object.id };
      case "charge.refunded":          return { kind: "refund_settled", id: ev.data.object.id };
      default:                         return { kind: "ignored", raw: ev.type };
    }
  }
}

And adapters can bridge paradigms, not just names — the callback-to-promise adapter you write constantly:

typescript
import { promisify } from "node:util";
const readFileAsync = promisify(fs.readFile);   // adapts (err, data) callback → Promise<data>

promisify is a generic adapter factory: it takes any Node-style callback function and returns a promise-returning one. Recognising it as Adapter is the point — the pattern is not always a class.

5.2 Python

python
from typing import Protocol

class PaymentGateway(Protocol):                    # the port — structural, no inheritance needed
    def charge(self, amount: Money, token: str) -> ChargeResult: ...

class StripeGateway:                               # satisfies the Protocol by shape alone
    def __init__(self, sdk): self._sdk = sdk       # holds the adaptee
    def charge(self, amount, token):
        try:
            pi = self._sdk.PaymentIntent.create(amount=amount.cents, currency=amount.ccy.lower(),
                                                payment_method=token, confirm=True)
            return ChargeResult(ok=True, id=pi.id)
        except stripe.error.CardError as e:        # translate the error
            return ChargeResult(ok=False, reason="card_declined", retryable=False)

Python's Protocol means the adapter does not even need to declare that it implements the port — structural typing checks it by shape (3.3), which makes adapters especially light.

6. Five domains, the same shape

(a) The anti-corruption layer — Adapter between services. Your Orders service has to call a legacy Billing service whose model is a mess — fields like cust_typ_cd, dates as YYYYMMDD strings, amounts in a made-up minor unit. Do not let that model into your domain. Write an adapter that speaks Billing's dialect on one side and your clean Invoice and Money types on the other. This is Eric Evans's anti-corruption layer, and it is Adapter scaled up to a network boundary — the single most valuable place to apply the pattern, because a bad external model that leaks into your core is a decade-long tax (10.8.3).

(b) The mock seam — Adapter for testing. Because your domain depends on PaymentGateway and not on Stripe, tests inject a FakeGateway that returns whatever the test needs. No network, no secrets, no flake. This is often the primary reason to write an adapter even when you will never change vendors — the test double is a second implementation, and it is a legitimate stakeholder (9.3.9, Dependency Inversion).

(c) Multiple vendors behind one port. Region-specific payment processors, several cloud storage providers, multiple SMS gateways — each gets an adapter, and a 9.4.2 factory picks one at runtime. Adapter (make each fit the port) and Factory (choose which) are constant companions.

(d) Version migration without a big-bang. Migrating from library-v1 to library-v2: write an adapter that implements your existing port with v2 underneath, deploy it behind the same interface, and no call site changes. The migration becomes one file plus a flag, and rollback is flipping the flag.

(e) Legacy-to-modern shape. An old module returns XML strings and Node-style callbacks; your app is JSON and promises. An adapter parses, converts, and promisifies — so exactly one file knows the old module is ugly, and the day it is deleted, one file changes.

7. Variants

VariantShapeNotes
Object adapterthe adapter holds the adaptee and delegatesthe default; injectable, composable
Class adapterthe adapter inherits both target and adapteeneeds multiple inheritance; brittle; avoid in TS/JS
Two-way adaptertranslates both directions (request and push)webhooks, event bridges
Function adaptera function wrapping a functionpromisify, pify, argument reordering
Anti-corruption layeran adapter at a service boundarythe pattern's highest-value form
Default / null adapteran adapter returning safe no-opsa fake or a disabled feature

Why the class adapter is only a footnote: the Gang of Four's class adapter uses multiple inheritance so that the adapter is both the target and the adaptee. JavaScript and TypeScript have no multiple class inheritance, and even in languages that do, it couples you to the adaptee's class hierarchy and prevents wrapping more than one adaptee. The object adapter (composition) is strictly more flexible — another instance of 9.2.4's composition-over-inheritance rule.

8. Where you already use it

What you have usedWhat it converts
util.promisify(fn)a callback-style function into one that returns a promise
Your own Logger interface over any logging libraryeach library's own method names
A PaymentGateway interface over a card provider's SDKthe provider's request and error shapes
Code that maps a database row into a domain objectcolumn names into your own field names
A wrapper written during an SDK upgradethe old shape, so callers stay unchanged

The first row is worth pausing on, because it is Adapter in five lines and it ships inside Node. Older Node functions take a callback: fs.readFile(path, (err, data) => …). Your code speaks promises, because it uses await everywhere. util.promisify(fs.readFile) hands you back a function with the same job and a different shape — call it, get a promise. Nothing about fs.readFile changed. A translator was placed in front of it.

9. Ways to get it wrong

  1. The leaky adapter. The port exposes stripeCustomerId, or returns Stripe's own types. Now the coupling is back and you paid for a useless layer.

    The fix: the port speaks only your vocabulary; if a vendor concept must cross, model it generically (externalRef).

  2. Forgetting to translate errors. Requests and responses are translated, but exceptions are not — so catch (e) { if (e instanceof Stripe.errors...) } appears in your domain.

    The fix: map the errors inside the adapter (section 5 note 6).

  3. The fat port. The interface mirrors the adaptee's entire API "to be safe". Now it is not an adapter, it is a pass-through, and a second vendor cannot satisfy it.

    The fix: the port has exactly the methods your client calls.

  4. The adapter doing business logic. Retry, caching, and validation creeping into the translator.

    The fix: those are Decorator (9.4.8) and validation concerns; the adapter only translates.

  5. Adapting something you own. If you control both interfaces, do not write an adapter — change one of them. Adapter's tension requires that the adaptee be out of your control.

  6. One mega-adapter for many adaptees. Squeezing three vendors into one class with if (vendor === …).

    The fix: one adapter per adaptee; a factory selects.

  7. Skipping the contract test. The fake and the real adapter diverge silently.

    The fix: section 9's shared suite.

10. Adapter compared with its neighbours

Compared withThe differenceChoose Adapter when
FacadeAdapter matches a required interface; Facade invents a simpler oneyou have a target shape the adaptee must fit
DecoratorDecorator keeps the same interface and adds behaviour; Adapter changes the interfacethe goal is compatibility, not enhancement
ProxyProxy keeps the same interface and controls access; Adapter changes itthe interface itself is the problem
StrategyStrategy swaps an algorithm you own; Adapter fits a thing you do notthe variation is a foreign interface
Bridge (9.4.1 section 2)planned up front so two things pair freelyyou are fixing a mismatch after the fact

The cleanest one-line separator of the wrapping trio: Adapter changes the interface, Decorator adds to it, Proxy guards it — all three keep the wrapped object at arm's length. Memorise that; it resolves ninety percent of "is this an Adapter or a…?" questions.

11. Interview calibration

The 45-second answer, in the order you would say it:

Adapter wraps something that has a useful capability but the wrong interface, and translates it to an interface I define. The key is that the adaptee is code I don't control — a vendor SDK, a legacy module, another team's service — so I define a port in my own vocabulary, and the adapter implements that port by delegating and translating requests, responses, and crucially the errors.

That gives me three things: my domain depends on a stable shape I own, I can swap the vendor by writing a second adapter, and I can test by injecting a fake. Scaled up to a service boundary it's the anti-corruption layer. The classic mistake is a leaky adapter that lets the vendor's types or errors bleed through — then you've paid for a layer that isolates nothing.

Follow-up questions, with the seed of each answer:

  • "Object adapter or class adapter?" — Object (composition). The class adapter needs multiple inheritance, couples to the adaptee's hierarchy, and can't wrap multiple adaptees.
  • "Adapter versus Facade?" — Adapter fits a required interface; Facade invents a simpler one. Match versus simplify.
  • "Where's the highest value?" — The anti-corruption layer: keeping a bad external model out of your core.
  • "Do you test adapters?" — Yes — the translation logic is real code with silent failure modes; plus a contract suite proving the fake and the real one behave identically.
  • "When is it not worth it?" — When you own both interfaces (just change one), or for a genuine throwaway with no test or swap need.

Recall

  • Adapter = translate a foreign interface into one you define. The tension requires that the adaptee be out of your control (a vendor SDK, a legacy module, another team's service). Define a port in your vocabulary; the adapter implements it by delegating and translating.
  • Translate all three: the request (your types → theirs), the response (theirs → yours), and — the most-skipped — the errors (their exception types → your reasons plus retryability). An adapter that lets foreign error types escape has failed; the coupling walks back in through catch.
  • The object adapter (composition) is the default — injectable, composable, mockable. The class adapter (inheritance) needs multiple inheritance and couples to the adaptee's hierarchy; avoid it. Adapter is not always a class: promisify is a function adapter.
  • The highest-value form is the anti-corruption layer — Adapter at a service boundary, keeping a bad external model out of your core. The second-highest is the test seam — the fake is a legitimate second implementation, often reason enough to write the port even with one vendor.
  • The wrapping-trio separator: Adapter changes the interface, Decorator adds to it, Proxy guards it, Facade simplifies a subsystem. Misuse: a leaky port (vendor types escape), a fat pass-through port, business logic in the translator, adapting something you own.

Self-test: What must be true about the adaptee for Adapter to apply? Name the three things an adapter translates and which one people forget. Why the object adapter over the class adapter? What is an anti-corruption layer? Give the one-line separator for Adapter, Decorator, Proxy, and Facade.

Quiz Bank

FoundationalDerive Adapter from direct SDK usage and name what breaks without it.

Naive: call the vendor SDK directly wherever payments happen — correct for a script or a library you will never replace or mock.

The force: the SDK is not yours to control, and it collides with a need — it changes on the vendor's schedule, a second processor arrives with a different interface, or you need a test double you cannot build for foreign code.

What breaks: the vendor's vocabulary (charges.create, status === "succeeded", CardError) spreads to every call site, so an SDK breaking change is scattered surgery across dozens of files; a second vendor cannot be accommodated without branching everywhere; and unit tests are impossible because the domain is the SDK, forcing network calls in CI.

The varies/fixed line: what varies is the foreign interface (names, shapes, errors, its very existence); what is fixed is the capability your domain needs, stated in your vocabulary.

The pattern: define a port (PaymentGateway) in your types, and an adapter that implements it by delegating to the SDK and translating requests, responses, and errors.

The cost: one indirection layer and one object to maintain; the adapter can only expose capabilities the adaptee has; and a leaky adapter gives cost without isolation.

The payoff: the domain depends on a stable shape you own; swapping or mocking the vendor is a one-file change.

FoundationalWhy is error translation the essential part of an adapter, and what happens if you skip it?

Because errors are an interface too, and they are the one people forget is an interface. If an adapter translates request shapes and response shapes but lets the adaptee's exception types propagate, then your domain's catch blocks have to reference Stripe.errors.StripeCardError, Stripe.errors.StripeConnectionError, and so on — which means the vendor coupling you carefully removed from the happy path reappears in the failure path, scattered across every call site that handles errors.

Skipping it defeats the pattern in the exact situations that matter most: a vendor swap now requires rewriting every error handler, and a fake cannot faithfully reproduce the vendor's error types. A complete adapter maps foreign errors to your vocabulary — a discriminated union of reasons your business logic can switch on (card_declined, insufficient_funds, network) plus a retryable flag — so that a generic retry layer (9.4.8) can function without knowing the vendor exists.

Two disciplines make this robust: map the known errors explicitly, and rethrow genuinely unexpected ones as a loud UnexpectedGatewayError rather than swallowing them into a generic failure, because an unmapped error is a bug to surface, not a state to hide. The test in section 9 asserting that a CardError becomes { ok: false, reason: "card_declined", retryable: false } is the regression guard for exactly this.

AppliedDistinguish Adapter, Decorator, Proxy and Facade — all of which wrap an object — with a concrete example of each.

All four hold a reference to another object and expose an interface, and they differ by intent.

Adapter changes the interface. StripeGateway implements PaymentGateway — the wrapped object speaks Stripe, the exposed interface speaks your domain; the purpose is compatibility.

Decorator keeps the interface and adds behaviour. new RetryingGateway(new StripeGateway(sdk)) — both implement PaymentGateway, and the wrapper adds retries transparently; the purpose is enhancement, and decorators stack.

Proxy keeps the interface and controls access. new CachingUserRepo(realRepo), or a lazy-loading proxy — same interface, but the wrapper decides whether and when to call through (caching, lazy init, access checks, rate limiting); the purpose is control.

Facade invents a simpler interface over a whole subsystem. class MediaConverter hiding ffmpeg, S3, and a thumbnail library behind one convert() — there was no pre-existing interface to match; you created a convenient one; the purpose is simplification.

The one-line separator: Adapter changes it, Decorator adds to it, Proxy guards it, Facade simplifies a group of things behind it. The tell in code is subtle but reliable — Adapter's exposed interface differs from the wrapped object's, while Decorator's and Proxy's are identical to it (they are substitutable for what they wrap), and Facade's is new and covers several objects rather than one.

InterviewWhat is an anti-corruption layer and why is it considered the highest-value application of Adapter?

An anti-corruption layer (ACL, Evans's term from Domain-Driven Design) is an Adapter applied at the boundary between your system and an external one — another team's service, a legacy system, a third-party API — whose data model is different from, and usually worse than, yours. It speaks the foreign dialect on the outside and your clean domain types on the inside, translating in both directions, so that no concept from the foreign model ever enters your core.

It is the highest-value form of Adapter for a structural reason: an external model that leaks into your domain is not a localised cost but a pervasive one. Its awkward field names, its wrong units, its missing invariants, and its future changes all propagate into every layer that touches it, and once your entities are shaped by someone else's model you can never refactor without coordinating with them. The ACL confines all of that to one translation layer you own.

Concretely: a legacy billing service returns cust_typ_cd, YYYYMMDD date strings, and amounts in an idiosyncratic minor unit; the ACL turns those into your CustomerType enum, your LocalDate, and your Money value object, and it rejects or flags anything that violates your invariants at the boundary.

The scaling insight worth stating in an interview: Adapter at class scale and the anti-corruption layer at service scale are the same pattern, which is why an API gateway that normalizes a dozen backend shapes, a message adapter that maps another system's events to yours (10.8.3), and a StripeGateway are all recognisably the same move. The cost is real — a translation layer to build and maintain, and the discipline to keep it from becoming a leaky pass-through — but it is dramatically cheaper than letting a foreign model spread through your core.

StaffYour company acquired a competitor. Both have a Customer concept but the models are incompatible (different ids, different address shapes, different lifecycle states), and you must integrate the acquired system's data and events into your platform within a quarter without destabilizing your core domain. Design the integration around Adapter and say what you would refuse to do.

The refusal comes first, because it is the whole strategy: do not merge the two models into one super-model, and do not let the acquired model into your core. Merging produces a model that serves neither domain well, carries both systems' historical compromises forever, and forces a big-bang migration that will slip the quarter. Instead, treat the acquired system as an external system behind an anti-corruption layer, exactly as if it were a third-party vendor you happen to own.

The design. First, define the boundary as a port in your vocabulary — your existing Customer domain interface is the target, and the acquired system is one more adaptee behind it. Second, build an ACL adapter that translates acquired-side reads (their AcctID, address struct, and lifecycle codes into your CustomerId, Address value object, and CustomerStatus enum) and, critically, translates events — their change feed becomes your domain events via a two-way adapter, so the acquired system's activity flows into your platform without its shapes doing so (10.8.3). Third, make identity explicit — maintain an id-mapping table (theirId ↔ ourId) inside the ACL rather than overloading either id space; identity reconciliation is the hardest part of any acquisition integration and it belongs in exactly one place.

Fourth, decide the direction of truth per field — some data the acquired system remains authoritative for during the transition, some your platform takes over; the ACL is where that policy lives and where conflicts are resolved, with a documented rule per field rather than ad hoc merges.

Sequence to de-risk. First, read-only integration (their customers appear in your platform through the ACL, no writes) to prove translation fidelity against real data. Then event integration. Then, only if the business requires it, a deliberate data migration into your system with the ACL as the migration's translator — and even then the ACL stays until the acquired system is decommissioned, so rollback is always available.

What this buys against the constraint: your core domain never destabilises, because it never sees the acquired model — it sees only your types, translated; the quarter is achievable because the deliverable is one translation layer plus a mapping table, not a rewrite; and the integration is reversible at every stage because the ACL is additive.

The sentence for the steering committee: we are integrating the acquisition the way we integrate any external system — behind a translation layer we own — because the alternative, merging two customer models into one, trades a one-quarter integration for a multi-year entanglement that makes both products worse.

Flashcards

FlashAdapter in one line

Translate a foreign interface (vendor SDK, legacy module, other team's service — code you don't control) into a port you define. Delegate and translate.

FlashTranslate all three

Request (your types → theirs), response (theirs → yours), and errors (their exceptions → your reasons plus retryable). Errors are the part people forget.

FlashObject vs class adapter

Object (composition, holds the adaptee) — injectable, composable, mockable. Class (inheritance) needs multiple inheritance and couples to the hierarchy. Use object.

FlashAnti-corruption layer

Adapter at a service boundary, keeping a bad external model out of your core. The highest-value form; the same pattern at class scale and service scale.

FlashWrapping-trio separator

Adapter changes the interface · Decorator adds to it · Proxy guards it · Facade simplifies a subsystem. All keep the target at arm's length.

FlashAdapter misuse

Leaky port (vendor types or errors escape) · fat pass-through port · business logic in the translator · adapting something you own (just change it).

Scenario Drill

DrillYour team maintains a notification system that currently sends only email via SendGrid, called directly in 30 places. Product now wants SMS (Twilio), push (Firebase), and Slack, with per-user channel preferences and the ability to add channels later without touching business logic. Design the adapter layer, decide the port's shape, and handle the fact that these channels are genuinely different (SMS has length limits, push needs device tokens, Slack uses blocks). Say where Adapter ends and other patterns begin.

The core move is one port, many adapters — but the interesting design work is cutting a port that fits four genuinely different channels without lying about their differences.

The port, cut carefully. The temptation is either a fat port exposing every channel's quirks (which no channel fully satisfies) or a lowest-common-denominator port (send(userId, text)) that throws away SMS length handling, push device tokens, and Slack blocks. The honest cut is a port over the capability — "deliver a notification to a user through this channel" — with a channel-neutral message that each adapter renders to its medium: interface NotificationChannel { readonly kind: ChannelKind; supports(msg: Notification): boolean; deliver(to: Recipient, msg: Notification): Promise<DeliveryResult>; }. The Notification carries semantic content (title, body, action, severity, data), not pre-formatted text; each adapter renders it — the SMS adapter truncates and segments to 160-character parts and drops rich content, the push adapter maps it to a Firebase payload with the device token from Recipient, the Slack adapter builds Block Kit JSON, the email adapter builds MJML. This keeps the differences inside the adapters where they belong, rather than in the port or in the business logic.

Handling real divergence: supports() lets a channel decline a message it cannot represent (an SMS channel returns false for a message that requires interactive buttons), and the dispatcher uses it rather than each adapter failing silently — this is the honest alternative to pretending all channels are equivalent. DeliveryResult is a translated, uniform result (delivered / soft-failed-retryable / hard-failed) so that retry and fallback logic is channel-agnostic, exactly the error-translation discipline of section 5.

Where Adapter ends and other patterns begin — the part that shows seniority. Adapter is each TwilioSms, FirebasePush, SlackChannel, and SendGridEmail wrapping its SDK and translating. Factory selects channels from the user's preferences (Record<ChannelKind, () => NotificationChannel>). Strategy is the per-user channel-preference and fallback policy ("try push, fall back to SMS, then email") — that is a business decision that varies, not a translation, so it does not belong in any adapter. Decorator adds retry, rate limiting, and delivery logging uniformly across all channels by wrapping each NotificationChannel — so those cross-cutting concerns are written once, not four times. The dispatcher that fans a notification out to a user's chosen channels is orchestration, not adaptation.

Migration from the 30 direct SendGrid calls: introduce the port and a SendGridEmail adapter first, replace the 30 call sites with notifier.send(userId, notification) (behaviour identical — a pure refactor, one PR per cluster of call sites), then add the other adapters as pure additions behind the now-stable interface. Adding Slack later is one new adapter class plus one factory line — the requirement "add channels without touching business logic" is satisfied structurally, not by discipline.

The design sentence: the four channels differ in how they render and deliver, not in what the business wants from them — so the differences live in four adapters behind one capability port, and everything cross-cutting (retry, preference policy, logging) wraps that port instead of being duplicated per channel.