Adapter pattern
Open in the interactive app: practice, quiz, mock interview →In one minute
- An adapter makes an existing component usable through an interface it was not written for. It sits between code you own (the target interface) and code you cannot or should not change (the adaptee: a vendor SDK, a legacy module, a standard library type).
- The adapter's real job is translation, not forwarding: names, units, argument order, error types, null conventions and missing capabilities. A thin adapter that only renames methods is fine; one that hides a semantic mismatch is a bug waiting to happen.
- Adapters are how ports-and-adapters (hexagonal) architecture is built: the domain owns the interface, and each vendor gets one adapter. Swapping vendors becomes a new adapter plus a wiring change, not edits across the codebase.
- At runtime an adapter costs one extra indirect call per operation, which is negligible, plus whatever data conversion it does, which is not always negligible.
- Agents generate adapters quickly and often leak the adaptee through them (vendor exceptions, vendor field names, vendor units). Review the adapter as the place where the vendor's world stops.
How it works
The problem
A checkout service records metrics. It was written against a StatsD client, so 180 call sites look like statsd.timing("checkout.payment", ms, tags=["region:eu"]). The platform team is moving to Prometheus, which has different rules: metric names may not contain dots, counters must never decrease, durations are measured in seconds, and labels are key-value pairs, not "k:v" strings. Rewriting 180 call sites is risky and would have to be repeated at the next migration.
The fix is to stop letting call sites speak a vendor's language. Define the interface the service actually wants, in its own terms:
class Metrics(Protocol):
def count(self, name: str, value: int, tags: dict[str, str]) -> None: ...
def timing(self, name: str, seconds: float, tags: dict[str, str]) -> None: ...Then write one adapter per backend. The StatsD adapter turns seconds into milliseconds and tags into |#k:v suffixes. The Prometheus adapter turns checkout.payment into checkout_payment_seconds, rejects negative counts and passes labels as a map. Call sites move once, to metrics.timing(...), and never move again.
Object adapter and class adapter
The Gang of Four describe two forms. An object adapter holds a reference to the adaptee and implements the target interface by calling it. A class adapter inherits from the adaptee and implements the target interface at the same time. Prefer the object adapter almost always: it works with final classes and with interfaces, it can wrap any implementation of the adaptee (including a fake in tests), and it does not expose the adaptee's whole public surface through inheritance. Go has no inheritance, so every Go adapter is an object adapter, sometimes a struct that embeds the adaptee (which, note, re-exports all its methods).
Function adapters
Many adapters are tiny. Go's http.HandlerFunc is a function type with a ServeHTTP method that calls the function itself, which adapts any func(w, r) to the http.Handler interface. Python's io.TextIOWrapper adapts a binary stream to the text-stream interface by decoding bytes. strings.NewReader adapts a string to io.Reader. These are adapters too, and they show the ideal: small, obvious, and translating exactly one mismatch.
What an adapter must translate
- Names and shapes.
send_email(template_id, dynamic_template_data)becomesnotify(user, message). - Units and encodings. Milliseconds versus seconds, cents versus decimal amounts, UTF-8 versus UTF-16, epoch seconds versus
datetime. - Errors. A vendor's
ThrottlingExceptionbecomes the domain'sRateLimited; a 404 on delete becomes "already gone" if the target contract says delete is idempotent. - Missing capabilities. If the target promises
list(prefix)and the adaptee cannot filter, the adapter filters client side, or the interface admits the capability is optional. Never pretend.
Where adapters live
In ports-and-adapters architecture, the domain package defines the interfaces (ports) and imports nothing from vendors; an adapters package imports both. That direction is the dependency inversion described in the DIP lesson, and the contract test suite from the LSP lesson is how you prove every adapter honours the port. At a larger scale, Domain-Driven Design calls a whole layer of adapters around a legacy or third-party system an anti-corruption layer: a boundary where the other system's model is translated so it does not leak into yours.
Adapter, decorator, facade
The three are all wrappers and differ by intent. An adapter changes the interface and keeps the behaviour. A decorator keeps the interface and adds behaviour. A facade offers a new, simpler interface over several components. If a wrapper changes both interface and behaviour, it is probably doing two jobs; split it.
Reviewing agent-written code
Asked to "add a Prometheus backend", an agent will often write PrometheusMetrics whose methods mirror the vendor client, then change call sites to match. Or it will write an adapter that forwards timing(ms) straight into a histogram measured in seconds, which silently multiplies every latency by 1000. Check three things in review: the interface is named in domain terms and owned by the caller; units and errors are converted inside the adapter and asserted in its tests; no vendor type appears in a signature outside the adapter package.
The same shape elsewhere
Adapters are trust but verify at a type boundary: the adaptee's guarantees differ from the target's, and the adapter is where you check and translate. The same shape appears in network protocol gateways (gRPC-JSON transcoding translates HTTP/JSON requests into gRPC calls), in device drivers (a kernel's block-device layer presents one interface over NVMe, SATA and virtio disks) and in payment orchestration (one charge interface over processors with different decline codes).
Down the stack
Layer 1The contract
An adapter promises that it satisfies the target interface's full contract, not just its method signatures: the same units, error types, idempotency, ordering and thread-safety guarantees that any other implementation gives. It also promises that adaptee details do not escape: callers never see vendor exceptions, vendor types or vendor units. When the adaptee cannot meet part of the contract, the adapter must fail loudly with a domain error rather than approximate silently. Lossy translations (rounding milliseconds, truncating labels) must be documented and tested.
Layer 2The mechanism
In Go, type HandlerFunc func(ResponseWriter, *Request) plus func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) { f(w, r) } is the whole adapter; the conversion http.HandlerFunc(fn) costs nothing at runtime. database/sql adapts many drivers to one API and handles optional capabilities by type assertion: if a connection implements driver.QueryerContext it calls it directly, and if it does not, the package falls back to preparing a statement, executing it and closing it. slog.NewLogLogger goes the other way, wrapping a structured slog.Handler so old code that wants a *log.Logger can still log. Python's io.TextIOWrapper holds a buffered binary stream and an incremental decoder from codecs, so read() pulls bytes, decodes them and keeps partial multi-byte sequences between calls.
Layer 3The machine
A Go interface value is two words: a pointer to an itab (the type descriptor plus a table of method pointers) and a pointer to the data. The runtime builds an itab the first time a concrete type is converted to an interface and caches it in a global hash table, so later conversions are a lookup. Calling through an adapter therefore means one load of the method pointer and one indirect call, about 1 to 2 ns when the branch predictor has seen it, and the call cannot be inlined unless profile-guided optimisation devirtualises it. Storing a non-pointer value larger than a word in an interface allocates on the heap; a pointer receiver adapter does not.
In CPython each layer is an attribute lookup plus a call, roughly 30 to 60 ns, so a pure forwarding adapter costs about as much as a dictionary update. In V8, adapter objects built by the same constructor share a hidden class and keep the call site monomorphic; a call site that sees many different adapter shapes becomes megamorphic and falls back to slower generic lookups.
The real costs are in translation. Converting a Go []byte to string copies and allocates. Building a tag string for every StatsD packet allocates per metric call; at 50,000 calls a second that is real garbage collection work, which is why production clients cache formatted tag sets. For concurrency, a stateless adapter is exactly as thread-safe as its adaptee. If the adaptee is not safe for concurrent use (many vendor clients document this), the adapter must serialise access with a mutex or keep one adaptee per goroutine, because the target interface usually promises more.
Why it's built this way
- Code you do not own will not change to fit your interfaces, so something must translate.
- Translating at every call site duplicates conversions, and the copies disagree on units and errors.
- Putting translation in one object behind a caller-owned interface makes vendor swaps additive.
- A wrapper is one indirect call, which is cheap compared with network or disk I/O behind most adaptees.
- Conversions of data can be expensive, so the adapter is also the place to cache and to measure them.
Diagram
flowchart LR C["checkout code"] --> I["Metrics interface"] I --> SA["StatsdAdapter"] I --> PA["PromAdapter"] SA -- "seconds to ms, tags to |#k:v" --> SC["StatsD client: send(line)"] PA -- "dots to _, add _total / _seconds" --> PR["Prometheus registry: add(name, labels, n)"] PA -- "negative count" --> E["domain error"]
Worked example
A company with 40 services emits about 600 distinct metrics, 3 million metric calls per second at peak across the fleet. Each service called a StatsD client directly. The migration to Prometheus started as 40 pull requests that changed call sites, and the first two produced dashboards off by a factor of 1000 because some calls passed seconds and some milliseconds.
The team stopped and introduced a shared Metrics interface with two adapters, StatsD and Prometheus, plus a DualWrite implementation that called both during the cutover. Each service changed once, mechanically, to call the interface; a lint rule then banned importing the vendor clients outside the adapters package. The adapter tests assert units explicitly: timing("x", 0.25) must produce x:250|ms and x_seconds_sum += 0.25.
The first load test showed the Prometheus adapter adding 900 ns per call, much more than the expected few nanoseconds. The cause was sanitising the metric name and rebuilding the label map on every call. Caching sanitised names in a map (600 entries) brought it to 120 ns. With the cutover done, removing StatsD was deleting one adapter and one line of wiring.
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Call the vendor SDK directly | No extra code; every feature available | Vendor types and units spread everywhere; migrations touch every caller | Prototypes, or a vendor you will never replace |
| Object adapter behind a domain interface | One place for translation; easy fakes; swappable | Lowest-common-denominator interface; one extra hop | Most integrations with external systems |
| Class adapter (inheritance) | Less forwarding code | Exposes adaptee's surface; fragile to adaptee changes | Rarely; when a framework requires subclassing |
| Anti-corruption layer (many adapters plus model translation) | Protects a whole domain model | Significant code and mapping to maintain | Integrating a large legacy or partner system |
| Optional capability interfaces | Use rich features when present | Callers must handle both paths | Backends differ in what they can do |
Pitfalls in production
- Unit mismatches crossing the adapter untested, such as milliseconds into a seconds histogram. Fix: assert units in adapter tests with exact values.
- Vendor exceptions leaking, so callers catch
botocore.ClientErrorand match strings. Fix: map every adaptee error to a domain error inside the adapter. - Silent degradation: the adaptee lacks a feature and the adapter quietly ignores it (drops a label, skips a filter). Fix: fail with an explicit error or make the capability optional in the interface.
- Interfaces shaped like the first vendor, so the second adapter has to fake concepts that do not exist. Fix: design the port from the caller's needs, then write two adapters before declaring it stable.
- Per-call conversion cost on hot paths. Fix: cache derived names and formatted strings, and benchmark the adapter alone.
- Assuming the adaptee is thread-safe because the interface is. Fix: read the vendor's concurrency docs and serialise inside the adapter if needed.
Leadership lens
- Insist that external dependencies enter the codebase through caller-owned interfaces. It is cheap at the start and very expensive to retrofit across 200 call sites.
- Make "no vendor imports outside adapters" a lint rule, not a convention. Agents and new engineers follow rules that fail the build.
- Budget for a shared contract test suite per port; it is what makes a vendor migration a project measured in weeks, not quarters.
- In design review, ask "what does this adapter lose or approximate?" The answer belongs in the adapter's documentation and in its tests.
Check yourself
What is the difference between an adapter and a decorator?
Show answer
An adapter changes the interface a component is used through and keeps its behaviour; a decorator keeps the interface and adds behaviour such as caching or retries.
Why is the object adapter usually preferred over the class adapter?
Show answer
It can wrap any implementation of the adaptee, including fakes, works with final classes and interfaces, and does not expose the adaptee's whole public surface through inheritance.
The adaptee cannot do something the target interface promises. What should the adapter do?
Show answer
Implement it on top of what the adaptee offers if that is correct, or fail with a clear domain error, or change the interface to make the capability optional. It must not approximate silently.
Where should the interface an adapter implements be defined, and why?
Show answer
In the caller's package, in the caller's terms. Then the domain imports nothing from vendors and each vendor is an additive adapter.
Watch
- Adapter Pattern (Christopher Okhravi): the Gang of Four structure, object versus class adapter, drawn step by step.
- 10 Design Patterns Explained in 10 Minutes (Fireship): a fast tour that places adapter next to the other structural patterns.
Free resources
- Hexagonal Architecture (Alistair Cockburn): the original description of ports and adapters.
- Anti-corruption Layer pattern (Azure Architecture Center): adapters at system scale, between a new service and a legacy one.
- net/http HandlerFunc (Go docs): the smallest useful adapter in the Go standard library.
- database/sql/driver (Go docs): the driver interfaces, including the optional ones the
sqlpackage adapts around. - Go interfaces (Russ Cox): how interface values and itabs are laid out and dispatched.
- Metric and label naming (Prometheus docs): the naming and unit rules a Prometheus adapter must translate into.