Adapter
Making an existing class fit the interface you need.
01The intuition
The problem it solves
A school attendance app stores records through a small interface it owns: save(record). The new biometric scanner from a vendor comes with its own library, and its method is called pushEvent, takes a JSON string, and uses a different date format.
class AttendanceStore:
def save(self, record) -> None:
self._rows.append(record)
class VendorScanner: # vendor code, cannot change
def pushEvent(self, payload: str) -> int:
print("vendor got", payload); return 200
def mark_present(student, when):
record = {"id": student.id, "at": when.isoformat()}
store.save(record)
payload = json.dumps({"sid": student.id, "ts": when.strftime("%d/%m/%Y %H:%M")})
scanner.pushEvent(payload) # vendor's shape, leaked into our code
Every place that records attendance now knows the vendor's method name, argument shape and date format. When the vendor ships version 2 with sendEvents(list), every call site changes. And the app cannot treat the scanner like its other stores, because it does not speak save.
An adapter is a thin class that wraps something with the wrong interface and presents the interface you want. The old thing is unchanged; your code sees only your own interface.
Build it one piece at a time
Step 1: write down the interface your code wants. It already exists in spirit as save(record); make it explicit.
from abc import ABC, abstractmethod
class RecordSink(ABC):
@abstractmethod
def save(self, record: dict) -> None: ...
class AttendanceStore(RecordSink):
def __init__(self): self._rows = []
def save(self, record: dict) -> None:
self._rows.append(record)
Step 2: wrap the vendor object in a class that implements your interface. All translation, names, formats, error codes, happens here and only here.
import json
from datetime import datetime
class ScannerSink(RecordSink):
def __init__(self, scanner: VendorScanner):
self._scanner = scanner
def save(self, record: dict) -> None:
when = datetime.fromisoformat(record["at"])
payload = json.dumps({"sid": record["id"], "ts": when.strftime("%d/%m/%Y %H:%M")})
status = self._scanner.pushEvent(payload)
if status != 200:
raise IOError(f"scanner rejected event: {status}")
Step 3: let the app talk to a list of sinks. The call site no longer knows the vendor exists.
class Attendance:
def __init__(self, sinks: list[RecordSink]):
self._sinks = sinks
def mark_present(self, student, when) -> None:
record = {"id": student.id, "at": when.isoformat()}
for sink in self._sinks:
sink.save(record)
attendance = Attendance([AttendanceStore(), ScannerSink(VendorScanner())])
Step 4: absorb the vendor's next version in the adapter. Version 2 wants batches; the adapter buffers and flushes, and the app is untouched.
class ScannerV2Sink(RecordSink):
def __init__(self, scanner, batch: int = 20):
self._scanner, self._batch, self._buf = scanner, batch, []
def save(self, record: dict) -> None:
self._buf.append(record)
if len(self._buf) >= self._batch:
self._scanner.sendEvents([json.dumps(r) for r in self._buf]); self._buf.clear()
| Step | Places that know the vendor's method names | Vendor v2 edits | Can tests run without the vendor library |
|---|---|---|---|
| Start | every call site | every call site | no |
| 1 and 2 | one adapter | one adapter | yes, with a fake RecordSink |
| 3 and 4 | one adapter | one new adapter class | yes |
Why it works
An adapter separates "what my program means" from "how this particular library says it". Your code depends on an interface you designed in your own vocabulary, so it stays stable while the outside world changes. The invariant is "foreign names and formats appear in adapters only". Because the adapter implements your interface, the foreign object becomes interchangeable with your own implementations, which is what lets the scanner sit in the same list as the store.
Where it breaks: an adapter that does real work. If the adapter starts validating business rules or caching, it has become a service and should be split. Keep it to translation and error mapping. Also watch the leaky adapter, one that exposes a vendor type in its own method signatures; then nothing was adapted.
The trade-off you must say out loud
An adapter is one more class per foreign thing and a small translation cost per call. It is worth it the moment the foreign interface might change, or you have two foreign things doing the same job, or you need to test without them. Say that boundary explicitly: "everything external comes in through an adapter to an interface I own". The follow-up is usually "what about data that does not map cleanly", and the answer is that the adapter is also where you decide what to do about it, drop, default or raise, in one place.
Where it shows up in the 100 Google problems
- Design a payment processor: each provider's SDK is wrapped in an adapter to one
PaymentGatewayinterface, so the processor's state machine never sees a provider's field names. - Design a logging framework: third-party outputs (a cloud log service, syslog) are adapted to the framework's
Sinkso they chain with file and console sinks. - Design a notification service: an SMS vendor and a push vendor are adapted to one
Channel.send; swapping vendors is a new adapter. - Design a streaming CSV importer with validation: a file, a network stream and a test string are adapted to one line source the importer reads from.