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.

pythondesign-patternsoop

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.