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 sayscharge(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):
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):
# 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:
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.
Moneyin,PaymentResultout, onePaymentDeclinedError. 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 exckeeps the vendor exception chained for the logs while the client catches one type. PayPal never raises; the adapter inspectsstateand 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:
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:
# 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.TextIOWrapperadapts a binary stream (BufferedReader,BytesIO, a socket file) to the text interface:read()returnsstr, encodings and newlines are translated.socket.makefile()andos.fdopen()do the same for sockets and raw descriptors.functools.cmp_to_keyadapts an old-style comparison function to thekey=interface thatsorted,minandheapqexpect.contextlib.closingadapts anything with aclose()method to the context-manager protocol;asyncio.to_threadadapts a blocking callable to an awaitable.sqlite3.register_adapteradapts 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.LoggerAdaptercarries 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:
requestsmounts transport adapters (HTTPAdapter) per URL prefix; SQLAlchemy dialects and Django database backends adapt engines to one query API.
Related patterns and confusions¶
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¶
- Design a payment gateway and digital wallet —
PaymentProcessorover several providers; the adapters absorb provider-specific ids, units and failure codes. - Design a notification service (LLD) — the
ChannelSenderProtocol is exactly the seam an adapter goes in: the package ships stubs behind it, and a real SMS or push vendor whose SDK disagrees about addresses and receipts is wrapped there without the dispatcher noticing. - Design a food delivery system (Swiggy, Zomato, DoorDash) — distance and ETA behind one interface, so a straight-line estimate and a maps provider are interchangeable; payments behind the same processor interface as above.
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.
Related¶
- Facade — a simpler interface over many objects rather than a matching one over one
- Bridge — the same shape designed in advance so both hierarchies vary
- Decorator — same interface, extra behaviour, stackable around an adapter
- Proxy — same interface, controlled access
- Design a payment gateway and digital wallet — adapters over payment providers in a full problem
- Design a notification service (LLD) — adapters over messaging vendors
- Gamma, Helm, Johnson and Vlissides, Design Patterns (1994), Adapter
- PEP 544 — Protocols: Structural subtyping
- Python documentation:
io— Core tools for working with streams