Adapter Pattern — Deep Dive
Adapter with Python protocols
Python’s Protocol class (from typing) is ideal for defining the target interface. Unlike abstract base classes, protocols use structural subtyping — any class with matching methods satisfies the protocol without explicit inheritance.
from typing import Protocol, Any
from dataclasses import dataclass
class NotificationSender(Protocol):
def send(self, recipient: str, subject: str, body: str) -> bool: ...
@dataclass
class EmailResult:
message_id: str
status: str
class SendGridClient:
"""Third-party email client with its own interface."""
def deliver(self, to_addr: str, title: str, html_content: str) -> EmailResult:
# actual SendGrid API call
return EmailResult(message_id="sg-123", status="sent")
class SendGridAdapter:
"""Adapts SendGridClient to our NotificationSender protocol."""
def __init__(self, client: SendGridClient):
self._client = client
def send(self, recipient: str, subject: str, body: str) -> bool:
result = self._client.deliver(
to_addr=recipient,
title=subject,
html_content=f"<p>{body}</p>",
)
return result.status == "sent"
def notify_user(sender: NotificationSender, user_email: str) -> None:
sender.send(user_email, "Welcome!", "Thanks for signing up.")
# Usage
client = SendGridClient()
adapter = SendGridAdapter(client)
notify_user(adapter, "user@example.com") # type-checks correctly
The notify_user function accepts any NotificationSender. When you switch from SendGrid to Mailgun, you write a new adapter — notify_user doesn’t change.
Function-level adapters
Not every adapter needs a class. For simple interface mismatches, a function works fine:
from typing import Callable
# Legacy function with positional args
def legacy_log(level: int, timestamp: str, message: str) -> None:
print(f"[{level}] {timestamp}: {message}")
# Your app expects (message, severity) keyword interface
def adapt_logger(
legacy_fn: Callable,
) -> Callable[[str, str], None]:
from datetime import datetime
def adapted(message: str, severity: str = "INFO") -> None:
level_map = {"DEBUG": 0, "INFO": 1, "WARNING": 2, "ERROR": 3}
legacy_fn(
level_map.get(severity, 1),
datetime.now().isoformat(),
message,
)
return adapted
log = adapt_logger(legacy_log)
log("Server started", severity="INFO")
This is common when integrating callback-based APIs where the signature doesn’t match what your framework expects.
Two-way adapter
Sometimes you need translation in both directions — your code calls the external service, and the external service calls back into your code:
class InternalEvent:
def __init__(self, event_type: str, payload: dict):
self.event_type = event_type
self.payload = payload
class ExternalWebhook:
"""Third-party webhook with different structure."""
def __init__(self, action: str, data: str, timestamp: float):
self.action = action
self.data = data
self.timestamp = timestamp
class WebhookAdapter:
"""Translates between internal events and external webhooks."""
@staticmethod
def to_internal(webhook: ExternalWebhook) -> InternalEvent:
import json
return InternalEvent(
event_type=webhook.action,
payload=json.loads(webhook.data),
)
@staticmethod
def to_external(event: InternalEvent) -> ExternalWebhook:
import json
import time
return ExternalWebhook(
action=event.event_type,
data=json.dumps(event.payload),
timestamp=time.time(),
)
Two-way adapters appear frequently in webhook integrations, message queue consumers, and API gateways.
Adapter registry pattern
When you have multiple adapters for the same interface (e.g., different payment providers), combine with a registry:
from typing import Protocol
class PaymentGateway(Protocol):
def charge(self, amount_cents: int, token: str) -> str: ...
_adapters: dict[str, type[PaymentGateway]] = {}
def register_gateway(name: str):
def decorator(cls):
_adapters[name] = cls
return cls
return decorator
def get_gateway(name: str) -> PaymentGateway:
return _adapters[name]()
@register_gateway("stripe")
class StripeAdapter:
def charge(self, amount_cents: int, token: str) -> str:
# call Stripe API
return f"stripe-charge-{amount_cents}"
@register_gateway("paypal")
class PayPalAdapter:
def charge(self, amount_cents: int, token: str) -> str:
# call PayPal API
return f"paypal-charge-{amount_cents}"
gateway = get_gateway("stripe")
result = gateway.charge(5000, "tok_visa")
This makes provider switching a configuration change rather than a code change.
Testing with adapters
Adapters naturally support testability. Define a test adapter that conforms to the same protocol:
class FakeNotificationSender:
def __init__(self):
self.sent: list[tuple[str, str, str]] = []
def send(self, recipient: str, subject: str, body: str) -> bool:
self.sent.append((recipient, subject, body))
return True
def test_notify_user():
fake = FakeNotificationSender()
notify_user(fake, "test@example.com")
assert len(fake.sent) == 1
assert fake.sent[0][1] == "Welcome!"
No mocking library needed. The protocol ensures the fake has the right shape, and mypy verifies it statically.
Performance considerations
Adapters add one layer of indirection — typically one function call. In most applications, this overhead is negligible compared to the actual work (network calls, database queries, serialization). However, in hot loops processing millions of items:
- Prefer function adapters over class adapters (less attribute lookup overhead)
- Consider inlining the adaptation if profiling shows the adapter layer is significant
- Use
__slots__on adapter classes to reduce memory per instance
Anti-patterns to avoid
Fat adapter
An adapter should only translate interfaces. If your adapter contains business logic, validation, retries, or caching, it’s doing too much. Split those concerns into separate layers.
Adapter that adapts an adapter
If you find yourself wrapping adapters in other adapters, the underlying abstraction is wrong. Step back and redesign the target interface.
Premature adaptation
Don’t write adapters for libraries you control. If you own both sides, just fix the interface mismatch directly.
Leaky adapter
If the adapter lets implementation-specific types, exceptions, or concepts leak into the caller, it’s not doing its job. The caller should only see your domain types. Wrap foreign exceptions, convert foreign data types, and translate foreign error codes into your application’s vocabulary.
The one thing to remember: A clean adapter is a thin, testable translation layer — it converts one interface to another without adding logic, keeping your code decoupled from the specific libraries and services it depends on.
See Also
- Python Bridge Pattern Why separating what something does from how it does it keeps your Python code from becoming a tangled mess.
- Python Builder Pattern Why building complex Python objects step by step beats cramming everything into one giant constructor.
- Python Composite Pattern How the Composite Pattern lets you treat a group of things the same way you'd treat a single thing in Python.
- Python Facade Pattern How the Facade Pattern gives you one simple button instead of a confusing control panel in Python.
- Python Flyweight Pattern How the Flyweight Pattern saves memory by sharing common data instead of copying it thousands of times.