Skip to content

Adapter

Intent

Convert the interface of a class you do not control into the interface your code expects, so the two can work together without either one changing. The client keeps talking to one stable Target written in its own vocabulary; the adapter does the translation of names, units, result shapes and errors, and adds nothing else.

When to use and when not to

Use it when

  • A vendor SDK, legacy module or third-party library has the behaviour you need but the wrong shape: create_charge(amount_cents, currency, source) where your domain says charge(Money, token).
  • You expect a second implementation (a second payment provider, a second SMS carrier) and want the switch to be a constructor argument, not a rewrite.
  • You want the vendor's types, exceptions and quirks confined to one file that the rest of the codebase never imports.
  • Tests need a fake: once the client depends on your Target, a fake is a ten-line class, not a mocked SDK.

Leave it out when

  • The object already has the right shape. Python checks structure, not ancestry, so an object with the right method names and signatures is a PaymentProcessor; wrapping it adds a layer that does nothing.
  • You are about to add retries, logging or caching "while you are in there". That is a Decorator with the same interface; an adapter that also changes behaviour is two patterns in one class and cannot be tested as either.
  • You control both sides. Change the interface instead of bridging it.

Structure

Four roles: the Target interface the client owns, the Adaptees you cannot change, and one Adapter per adaptee that implements the Target by delegating.

classDiagram
    class PaymentProcessor {
        <<interface>>
        +charge(amount: Money, card_token: str) PaymentResult
        +refund(payment_id: str) PaymentResult
    }
    class Checkout {
        -_processor: PaymentProcessor
        +pay(amount: Money, card_token: str) PaymentResult
        +refund(payment_id: str) PaymentResult
    }
    class StripeAdapter {
        -_client: StripeClient
        +charge(amount: Money, card_token: str) PaymentResult
        +refund(payment_id: str) PaymentResult
    }
    class PayPalAdapter {
        -_gateway: PayPalGateway
        +charge(amount: Money, card_token: str) PaymentResult
        +refund(payment_id: str) PaymentResult
    }
    class StripeClient {
        +create_charge(amount: int, currency: str, source: str) dict
        +create_refund(charge: str) dict
    }
    class PayPalGateway {
        +execute_payment(total: str, currency_code: str, payer_id: str) PayPalPayment
        +refund_sale(payment_id: str) PayPalPayment
    }
    Checkout o-- PaymentProcessor : calls
    PaymentProcessor <|.. StripeAdapter
    PaymentProcessor <|.. PayPalAdapter
    StripeAdapter *-- StripeClient : wraps
    PayPalAdapter *-- PayPalGateway : wraps

The arrows tell the story: Checkout knows only PaymentProcessor; each adapter realises the Target and owns one adaptee. Nothing points from the vendor side back into the domain. This is the object adapter (composition); the class adapter (inheriting from the adaptee) is discussed below.

Canonical example in Python

The Target comes first, and it is written in the domain's vocabulary before any vendor is looked at (code/patterns/adapter.py, tested by code/patterns/tests/test_adapter.py):

code/patterns/adapter.py — the Target, owned by the client
class PaymentStatus(StrEnum):
    CAPTURED = "captured"
    REFUNDED = "refunded"


@dataclass(frozen=True, slots=True)
class PaymentResult:
    """What the client needs to know, in the client's vocabulary."""

    payment_id: str
    amount: Money
    status: PaymentStatus
    provider: str


class PaymentDeclinedError(HandbookError):
    """The one failure the client handles, whatever the vendor raised or returned."""


@runtime_checkable
class PaymentProcessor(Protocol):
    """The Target: the interface the client is written against.

    It belongs to the domain, not to any vendor: amounts are ``Money``, failures
    are ``PaymentDeclinedError`` and results are ``PaymentResult``.
    """

    def charge(self, amount: Money, card_token: str) -> PaymentResult: ...

    def refund(self, payment_id: str) -> PaymentResult: ...

The two adaptees stand in for SDKs you cannot edit. Read them for the four ways they disagree: method names, amount encoding (integer cents versus a decimal string), result shape (dict versus object) and failure reporting (an exception versus a status field):

code/patterns/adapter.py — two vendor SDKs you do not own
# Stand-ins for two vendor SDKs. You do not own this code and cannot rename its methods.
class StripeError(Exception):
    """The vendor's exception type. It must never leak out of the adapter."""


class StripeClient:
    """Integer minor units, lower-case currency, plain dicts back, failures raised."""

    def __init__(self) -> None:
        self._ids = itertools.count(1)
        self._charges: dict[str, dict[str, object]] = {}

    def create_charge(self, amount: int, currency: str, source: str) -> dict[str, object]:
        if source == "tok_declined":
            raise StripeError("card_declined")
        charge: dict[str, object] = {
            "id": f"ch_{next(self._ids)}",
            "amount": amount,
            "currency": currency,
            "status": "succeeded",
        }
        self._charges[str(charge["id"])] = charge
        return charge

    def create_refund(self, charge: str) -> dict[str, object]:
        original = self._charges[charge]
        return {
            "id": f"re_{next(self._ids)}",
            "charge": charge,
            "amount": original["amount"],
            "currency": original["currency"],
            "status": "succeeded",
        }


@dataclass(frozen=True, slots=True)
class PayPalPayment:
    payment_id: str
    state: str
    total: str
    currency_code: str


class PayPalGateway:
    """Decimal strings, upper-case currency, objects back, failures reported in a field."""

    def __init__(self) -> None:
        self._ids = itertools.count(1)
        self._payments: dict[str, PayPalPayment] = {}

    def execute_payment(self, total: str, currency_code: str, payer_id: str) -> PayPalPayment:
        state = "denied" if payer_id.startswith("denied") else "approved"
        payment = PayPalPayment(f"PAY-{next(self._ids)}", state, total, currency_code)
        self._payments[payment.payment_id] = payment
        return payment

    def refund_sale(self, payment_id: str) -> PayPalPayment:
        original = self._payments[payment_id]
        return PayPalPayment(payment_id, "refunded", original.total, original.currency_code)

Each adapter translates in both directions and nothing more:

code/patterns/adapter.py — one adapter per vendor
class StripeAdapter:
    """An object adapter: holds the vendor client and translates every call both ways.

    Arguments: ``Money`` becomes integer cents plus a lower-case currency.
    Results: the vendor dict becomes a ``PaymentResult``.
    Errors: ``StripeError`` becomes ``PaymentDeclinedError`` (chained, for the logs).
    """

    provider = "stripe"

    def __init__(self, client: StripeClient) -> None:
        self._client = client

    def charge(self, amount: Money, card_token: str) -> PaymentResult:
        try:
            raw = self._client.create_charge(amount.cents, amount.currency.lower(), card_token)
        except StripeError as exc:
            raise PaymentDeclinedError(f"stripe: {exc}") from exc
        return PaymentResult(str(raw["id"]), amount, PaymentStatus.CAPTURED, self.provider)

    def refund(self, payment_id: str) -> PaymentResult:
        raw = self._client.create_refund(payment_id)
        amount = Money(int(str(raw["amount"])), str(raw["currency"]).upper())
        return PaymentResult(payment_id, amount, PaymentStatus.REFUNDED, self.provider)


class PayPalAdapter:
    """Same Target, different translation: decimal strings and a status field to inspect."""

    provider = "paypal"

    def __init__(self, gateway: PayPalGateway) -> None:
        self._gateway = gateway

    def charge(self, amount: Money, card_token: str) -> PaymentResult:
        payment = self._gateway.execute_payment(_decimal_string(amount), amount.currency, card_token)
        if payment.state != "approved":
            raise PaymentDeclinedError(f"paypal: payment {payment.state}")
        return PaymentResult(payment.payment_id, amount, PaymentStatus.CAPTURED, self.provider)

    def refund(self, payment_id: str) -> PaymentResult:
        refund = self._gateway.refund_sale(payment_id)
        amount = Money.of(refund.total, refund.currency_code)
        return PaymentResult(payment_id, amount, PaymentStatus.REFUNDED, self.provider)


def _decimal_string(amount: Money) -> str:
    """``Money(1234)`` -> ``"12.34"`` without going through a float."""
    units, cents = divmod(amount.cents, 100)
    return f"{units}.{cents:02d}"

Three decisions to say out loud:

  • The Target is yours, not the first vendor's. Money in, PaymentResult out, one PaymentDeclinedError. If you had mirrored Stripe's API and called it the interface, PayPal would not have fitted and the "adapter" would leak cents and lower-case currency codes everywhere.
  • Errors are translated too. raise PaymentDeclinedError(...) from exc keeps the vendor exception chained for the logs while the client catches one type. PayPal never raises; the adapter inspects state and raises on the client's behalf. Translation of failure modes is the part candidates forget.
  • No behaviour. No retries, no logging, no caching. Each adapter is a pure function of its input plus one vendor call, which is what makes it trivial to test against a recording stub.

The client is deliberately unaware:

code/patterns/adapter.py — the client
class Checkout:
    """The client: written against the Target only. It never imports a vendor type.

    Domain rules live here (positive amounts, refund only what you charged);
    vendor translation lives in the adapters. Neither knows the other's job.
    """

    def __init__(self, processor: PaymentProcessor) -> None:
        self._processor = processor
        self._payments: dict[str, PaymentResult] = {}

    def pay(self, amount: Money, card_token: str) -> PaymentResult:
        if amount.cents <= 0:
            raise ValidationError("amount must be positive")
        result = self._processor.charge(amount, card_token)
        self._payments[result.payment_id] = result
        return result

    def refund(self, payment_id: str) -> PaymentResult:
        if payment_id not in self._payments:
            raise NotFoundError(f"unknown payment {payment_id}")
        result = self._processor.refund(payment_id)
        self._payments[payment_id] = result
        return result

It validates domain rules (positive amounts, refund only what it charged) and delegates. Swap the processor and nothing in Checkout or its tests changes. Running python -m patterns.adapter prints:

--- one Checkout, two vendors, one vocabulary ---
stripe: charged 12.34 USD -> ch_1 (captured)
paypal: charged 12.34 USD -> PAY-1 (captured)
--- refunds travel through the same adapter ---
stripe: refunded 12.34 USD for ch_1 (refunded)
paypal: refunded 12.34 USD for PAY-1 (refunded)
--- every vendor failure arrives as one domain error ---
declined: stripe: card_declined
declined: paypal: payment denied
--- callable targets: a closure adapter and a bound method, side by side ---
terminal: 5.00 USD -> AUTH1000
  stripe: 5.00 USD -> ch_1
rejected before any vendor call: amount must be positive

Pythonic variant

Python removes two reasons the pattern exists in Java. There is no implements to satisfy, so an object with the right shape needs no wrapper, and a single-method Target is just a Callable, so the adapter can be a closure:

code/patterns/adapter.py — a closure adapts a one-call vendor
# When the client needs one call, the Target is a Callable and the adapter is a closure.
type ChargeFn = Callable[[Money, str], PaymentResult]


class LegacyTerminal:
    """A third adaptee that can only charge: integer cents in, an authorisation code out."""

    def __init__(self) -> None:
        self._codes = itertools.count(1000)

    def swipe(self, cents: int, card: str) -> str:
        if card == "tok_declined":
            return "DECLINED"
        return f"AUTH{next(self._codes)}"


def terminal_charge(terminal: LegacyTerminal) -> ChargeFn:
    """Adapt to the smallest interface the caller needs; a terminal cannot refund, so it is not forced to pretend."""

    def charge(amount: Money, card_token: str) -> PaymentResult:
        code = terminal.swipe(amount.cents, card_token)
        if code == "DECLINED":
            raise PaymentDeclinedError("terminal: declined")
        return PaymentResult(code, amount, PaymentStatus.CAPTURED, "terminal")

    return charge

terminal_charge also shows interface segregation in action: the terminal cannot refund, so it is adapted to ChargeFn rather than forced to fake the full Protocol. A bound method such as StripeAdapter(client).charge satisfies the same ChargeFn, so the two forms mix freely.

The class adapter uses inheritance instead of composition. It works in Python, and it is almost always the wrong choice:

class StripeClassAdapter(StripeClient):
    """Inherits the vendor's whole API and constructor; clients can now call create_charge directly."""

    def charge(self, amount: Money, card_token: str) -> PaymentResult:
        raw = self.create_charge(amount.cents, amount.currency.lower(), card_token)
        return PaymentResult(str(raw["id"]), amount, PaymentStatus.CAPTURED, "stripe")

Every vendor method is now part of your public surface, and you cannot adapt an instance the SDK hands you. Prefer composition. Resist __getattr__ pass-through for the same reason: it turns a narrow adapter into a leaky one.

Reach for When
Nothing The object already has the Target's method names and signatures
A closure or functools.partial The Target is one callable and the translation has no state
A class with the Target's methods Several methods, shared translation state, or you want a name in logs and diagrams
A Protocol declared by the client Always: it is the contract the adapters and the test fakes are checked against

One gotcha worth stating: isinstance(x, PaymentProcessor) on a @runtime_checkable Protocol checks that the method names exist, not their signatures. A type checker catches the rest; the runtime check does not.

Real-world usage

  • io.TextIOWrapper adapts a binary stream (BufferedReader, BytesIO, a socket file) to the text interface: read() returns str, encodings and newlines are translated. socket.makefile() and os.fdopen() do the same for sockets and raw descriptors.
  • functools.cmp_to_key adapts an old-style comparison function to the key= interface that sorted, min and heapq expect.
  • contextlib.closing adapts anything with a close() method to the context-manager protocol; asyncio.to_thread adapts a blocking callable to an awaitable.
  • sqlite3.register_adapter adapts your Python types to SQLite's storage classes, and DB-API 2.0 drivers are adapters from each database's wire protocol to one cursor interface.
  • logging.LoggerAdapter carries the name but behaves like a Decorator: it presents the same logging API and injects extra context. Names lie; ask whether the interface changes.
  • Frameworks: requests mounts transport adapters (HTTPAdapter) per URL prefix; SQLAlchemy dialects and Django database backends adapt engines to one query API.

The structural patterns all "wrap something", so classify by two questions: does the interface change, and what is the wrapper for?

Looks like Adapter How to tell them apart
Decorator Same interface in and out, adds behaviour, stacks. Adapter changes the interface and adds no behaviour. RetryingProcessor(StripeAdapter(client)) is the right layering: adapt first, then decorate.
Proxy Same interface, controls access to one object (lazy load, cache, permissions, remote call). If the wrapper decides whether the real call happens, it is a Proxy.
Facade A new, simpler interface over a subsystem of several objects. An adapter wraps one object one-to-one; a facade is not interchangeable with what it hides.
Bridge The same picture of an abstraction holding an implementor, designed up front so both sides vary. Adapter is retrofitted after the fact to make unrelated things fit.
Strategy Interchangeable algorithms behind one interface. The adapters here are interchangeable, but the intent differs: you wrote the strategies; you adapted the vendors.
Anti-corruption layer The DDD name for a boundary made of adapters plus translation of the whole model, not one interface.

Where it appears in LLD problems

Interview tips

Interview tip

Write the Target first, in your vocabulary, and say why: "the interface is charge(Money, token) -> PaymentResult; Stripe and PayPal each get an adapter that translates names, units, results and errors." Then name the test: a recording stub asserting the adapter sent 1234, "usd", and a ten-line fake processor for every Checkout test. Finish with the Python note that a closure or duck typing would do for a one-method Target.

Common mistake

Letting the vendor leak. Returning the SDK's dict, re-raising its exception, or shaping the Target around the first vendor's API all mean the second vendor will not fit and every caller has to know which one is live. Runner-up: adding retries or logging inside the adapter. Keep it a pure translation and put behaviour in a Decorator around it.