Scaled up to whole-system boundaries, the Adapter pattern reappears as hexagonal architecture's "ports and adapters" and DDD's anti-corruption layer. What changes at that scale, and how do you decide how many adapters and how thick to make them?
answer
- Port owned by domain; adapter depends inward
- ACL translates models, not methods
- Contract tests bind fake + real adapters
- N×M point-to-point → N+M canonical
- Generic port drifts to lowest common denominator
basics
~20 sAt system scale the target interface becomes a "port" your domain owns, and each external system gets an adapter that translates its model into yours. This keeps vendor concepts out of the core, at the cost of extra layers and mapping code.
solid answer
~60 sThree shifts. **Ownership inverts by design**: the port is defined by the domain's needs and lives in the domain module; the adapter module depends on the domain, never the reverse (dependency inversion enforced by the build graph, not conventions). **Translation moves from signatures to models**: a DDD anti-corruption layer converts the external system's *concepts* — its ids, statuses, lifecycles, consistency and error semantics — into yours, so a partner's model can't colonize your ubiquitous language. **Verification becomes contract-based**: one abstract suite per port, run against every adapter plus an in-memory fake, sometimes paired with consumer-driven contract tests against the real provider. Sizing decisions: adapt only where change or blast radius justifies it (one vendor you'll never swap may not need a port); prefer a canonical port with N adapters over N×M point-to-point mappings; keep adapters thin and put resilience, caching and metrics in decorators over the port; and accept that a port designed for many providers drifts to a lowest common denominator — sometimes two specific ports beat one generic one.
code
pseudocode · 17 lines// domain module — owns the port, names concepts in ITS language, no vendor imports
interface PaymentAuthorizer {
authorize(amount: Money, method: PaymentMethod, key: IdempotencyKey): AuthResult
}
// adapter module — depends on domain; domain must NOT depend on this
class StripeAuthorizer(private sdk: StripeSdk) implements PaymentAuthorizer {
authorize(amount, method, key) =
try { toAuthResult(sdk.charges.create(toStripeRequest(amount, method), key.value)) }
catch (e: StripeException) { throw classify(e) } // never escapes as StripeException
}
// policy lives OUTSIDE the adapter, reusable over the port
val authorizer = Timed(CircuitBroken(Retrying(StripeAuthorizer(sdk), onlyIdempotent = true)))
// one abstract contract suite, run against StripeAuthorizer, AdyenAuthorizer, and InMemoryAuthorizer
abstract class PaymentAuthorizerContract { /* ordering, error classes, idempotency, absence */ }go deeper
Say the same idea scales up: your app defines the interface it needs, and each external system gets a wrapper that speaks your language, so vendor details stay at the edge.
Add who owns the port (the domain), that adapters depend inward, and that a fake implementation of the port makes core logic testable without the vendor.
Discuss model translation vs. signature mapping, error/timeout/idempotency handling, keeping adapters thin with policy in decorators, and contract tests across adapters and fakes.
Judge where to abstract at all by blast radius and change likelihood; reason about canonical vs. point-to-point (N+M vs N×M), lowest-common-denominator ports, port versioning as a published contract, observability across boundaries, and the ACL's role in decoupling teams.
## From class-level pattern to architectural style The GoF Adapter reconciles two *classes*. Hexagonal architecture (Alistair Cockburn's *Ports and Adapters*) applies the same idea to a *system boundary*: the application core defines **ports** — interfaces expressing what it needs or offers — and **adapters** connect those ports to concrete technology (HTTP controllers, message consumers, SQL, vendor SDKs, file systems, clocks). *Driving* (primary/inbound) adapters call into the core through inbound ports; *driven* (secondary/outbound) adapters are called by the core through outbound ports. Onion/Clean architecture say the same thing with different vocabulary. Eric Evans' **anti-corruption layer (ACL)** is the DDD framing: when integrating with a system whose model differs from yours (a legacy monolith, a partner API, another team's bounded context), insert a translation layer so *their* model does not leak into *your* ubiquitous language. An ACL is an adapter whose unit of translation is the *domain model*, not the method signature. ## What genuinely changes at scale ### 1. Ownership and direction of dependency At class scale, the target may be someone else's interface. At system scale, the port **must** be defined by the consumer — that is the whole mechanism of dependency inversion. Enforce it structurally: separate modules/packages, with a build-level or ArchUnit-style rule that `domain` may not reference `adapter-*` or any vendor package. If your "port" is just the SDK's interface copied, you have added a file and inverted nothing. ### 2. Translation of models, not calls The hard work is conceptual: their `customer_id` vs your `CustomerId`; their 14 order statuses vs your 4; their eventual consistency vs your read-your-writes expectation; their partial-failure/batch semantics; their pagination and rate limits; their notion of "deleted". The adapter owns all of it, including the judgement calls, and those judgements deserve tests and documentation. ### 3. Failure and time become first-class Every outbound adapter is a network boundary: timeouts, retries (only for idempotent operations), circuit breaking, bulkheads (a bounded pool per dependency so one slow vendor can't consume all threads), and clock/idempotency-key handling. Decide deliberately whether these live *in* the adapter or in a decorator over the port. Decorator placement is usually better: policy is reusable across adapters and testable without any vendor. ### 4. Verification - **Port contract tests**: one abstract suite encoding the port's promises (ordering, absence semantics, error classification, idempotency), executed against every adapter *and* the in-memory fake used by domain tests. This is what stops the fake and the real adapter from drifting — the single most common integration bug. - **Consumer-driven contract tests / recorded interactions** for the wire format against the actual provider. - **Fault-injection tests** for timeout/5xx/partial-response paths, which are otherwise never exercised. ### 5. Observability Span/metric per adapter with the dependency named, error-classification counters (including an "unmapped provider error" counter that must stay at zero), and payload-shape logging that respects the secrets rules. Without this, boundary translation defects are invisible until they're incidents. ## Sizing decisions **How many ports?** Not one per external system reflexively. Group by *capability the domain needs* (`PaymentAuthorizer`, `AddressVerifier`), not by vendor. If two capabilities have different consumers and change rates, two ports. **Canonical model vs point-to-point.** With N sources and M consumers, direct mappings trend toward N×M translators; a canonical internal model makes it N+M. The catch is that a canonical model is a design commitment: it must be owned, versioned, and rich enough not to lose information, or it becomes a lossy lowest common denominator that all adapters fight. Rule of thumb: adopt a canonical port when N and M are both >2 *and* the concepts genuinely overlap. **One generic port or several specific ones?** A port designed for "any payment provider" degrades to the intersection of every provider's features, and you lose the one differentiating capability you actually bought. Sometimes the honest design is a narrow shared port for the common path plus provider-specific capabilities exposed explicitly (optional sub-interfaces), rather than pretending uniformity. **Thick or thin?** Thin adapters (mapping + error translation) are easiest to test and swap. Thickness creeps in via caching, batching, retry, rate limiting and stitching multiple provider calls into one port call. Stitching is often legitimate (the port speaks your language, the provider needs three calls); cross-cutting policy usually is not. **When *not* to abstract.** A single vendor with no realistic replacement, deeply embedded (an ORM, a cloud primitive) may not deserve a port — the abstraction cost is paid forever, the swap never happens, and the abstraction leaks anyway. Judge by blast radius: how many places would a change touch, and how likely is it? Ports also pay for themselves in *testability* alone, which is often the stronger argument than swappability. **Versioning.** Adding a method to a port breaks every adapter; deleting one breaks clients. Treat ports as published interfaces: additive change via new optional interfaces, deprecation windows, and a fake maintained in lockstep. ## Organizational angle Adapters are where team boundaries meet. An ACL is often the artifact that lets a team stop being blocked by another team's model or release cadence — a Conway's-law countermeasure. It also localizes the cost of a partner's breaking change to one module owned by one team, which is a scheduling and risk argument as much as a design one.
- How do you prevent the in-memory fake used in domain tests from drifting away from the real adapter?Make the fake a first-class implementation of the port and subject it to the same abstract contract-test suite as every real adapter, including error classification and idempotency cases. If the contract can't express a guarantee, that guarantee isn't real — either add it to the contract or stop relying on it in domain code.
- When is adding a port and adapter over an external dependency not worth it?When there is exactly one realistic provider, the dependency is pervasive and conceptually inseparable (ORM, cloud primitive, language runtime), and the abstraction would leak anyway. The counter-argument that still often wins is testability and blast radius: if a provider change would touch dozens of files, or you cannot test the core without the vendor, the port pays for itself even with no swap in sight.
- You must integrate five partner APIs that all mean roughly 'shipment status'. Canonical port or five specific ones?Canonical, if the concepts genuinely overlap and there are multiple consumers — it turns N×M mappings into N+M and keeps partner vocabulary out of the domain. But budget for the lossy cases: model the union of statuses your domain actually acts on, expose partner-specific extras through explicit optional capabilities rather than a leaky generic 'extras' map, and version the canonical model deliberately.
- Where should retries and circuit breaking live — inside the adapter or outside it?Outside, as decorators over the port, so the policy is uniform across adapters, testable with a fake, and configurable per environment. Keep inside the adapter only what requires provider knowledge: which error codes are retryable, how idempotency keys are passed, and provider-specific rate-limit headers.
An embassy. Your country (the domain) sets the rules for how it will be addressed; the embassy staff (adapters) translate every foreign government's paperwork, customs and etiquette into that format. Foreign procedures never propagate inside the country, and when a foreign government reorganizes, only the embassy re-trains — not every citizen.
saying these in an interview costs you the question
- Defining the 'port' by copying the vendor SDK's interface — no inversion has occurred.
- Letting the domain module import vendor types or exceptions and calling it hexagonal.
- Assuming a single generic port fits all providers, then discovering it's the intersection of their weakest features.
- Building an anti-corruption layer for every integration regardless of blast radius or change likelihood — indirection has a permanent carrying cost.
- Testing only the real adapter and never the in-memory fake against the same contract, so domain tests pass while production diverges.
- Putting retry logic in an adapter over a non-idempotent operation.
- Treating the ACL as pure DTO mapping instead of a translation of concepts, lifecycles and consistency semantics.