Engineering Atlas › Low-Level Design

Adapter pattern

13 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

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

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

  1. Code you do not own will not change to fit your interfaces, so something must translate.
  2. Translating at every call site duplicates conversions, and the copies disagree on units and errors.
  3. Putting translation in one object behind a caller-owned interface makes vendor swaps additive.
  4. A wrapper is one indirect call, which is cheap compared with network or disk I/O behind most adaptees.
  5. 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

OptionStrengthsWeaknessesChoose when
Call the vendor SDK directlyNo extra code; every feature availableVendor types and units spread everywhere; migrations touch every callerPrototypes, or a vendor you will never replace
Object adapter behind a domain interfaceOne place for translation; easy fakes; swappableLowest-common-denominator interface; one extra hopMost integrations with external systems
Class adapter (inheritance)Less forwarding codeExposes adaptee's surface; fragile to adaptee changesRarely; when a framework requires subclassing
Anti-corruption layer (many adapters plus model translation)Protects a whole domain modelSignificant code and mapping to maintainIntegrating a large legacy or partner system
Optional capability interfacesUse rich features when presentCallers must handle both pathsBackends differ in what they can do

Pitfalls in production

Leadership lens

Check yourself

  1. 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.

  2. 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.

  3. 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.

  4. 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

Free resources

Open in the interactive app: practice, quiz, mock interview →