Adapter Pattern in Python: Class and Object Adapters
The adapter pattern solves a common integration problem: you have two interfaces that don't match, but you need them to work together. Rather than modifying either interface—which might be impossible...
Key Insights
- Object adapters use composition and offer greater flexibility—prefer them in most Python applications since they can adapt any instance at runtime and work well with dependency injection.
- Class adapters use multiple inheritance and provide direct access to protected members, but they couple your code tightly to specific implementations and can’t adapt subclasses.
- Python’s dynamic nature enables powerful adapter variations like
__getattr__-based transparent proxies, but keep adapters thin and resist the temptation to add business logic.
Introduction to the Adapter Pattern
The adapter pattern solves a common integration problem: you have two interfaces that don’t match, but you need them to work together. Rather than modifying either interface—which might be impossible or unwise—you create an intermediary that translates between them.
Think about power adapters. When you travel internationally, your laptop charger doesn’t change, and neither do the wall outlets. The adapter sits between them, converting one interface to another. The same principle applies to software.
You’ll reach for the adapter pattern when integrating third-party libraries with different conventions, connecting legacy systems to modern APIs, or wrapping external services to match your domain’s interface. It’s a structural pattern focused on interface compatibility, not behavior modification.
The Problem: Incompatible Interfaces
Consider a payment processing scenario. You have a legacy payment processor that’s been battle-tested in production for years:
class LegacyPaymentProcessor:
"""Legacy system with its own interface conventions."""
def __init__(self, merchant_id: str):
self.merchant_id = merchant_id
self._connected = False
def connect(self) -> None:
self._connected = True
print(f"Connected to legacy gateway for merchant {self.merchant_id}")
def execute_payment(self, amount_cents: int, card_token: str) -> dict:
if not self._connected:
raise RuntimeError("Not connected to payment gateway")
# Simulate payment processing
return {
"status": "OK",
"confirmation": f"LEG-{card_token[:8].upper()}",
"amount_processed": amount_cents
}
def disconnect(self) -> None:
self._connected = False
Your new order processing system expects a different interface entirely:
from abc import ABC, abstractmethod
from dataclasses import dataclass
from decimal import Decimal
@dataclass
class TransactionResult:
success: bool
transaction_id: str
amount: Decimal
error_message: str | None = None
class PaymentGateway(ABC):
"""Modern payment interface expected by the order system."""
@abstractmethod
def process_transaction(
self,
amount: Decimal,
payment_token: str,
idempotency_key: str
) -> TransactionResult:
pass
The legacy processor uses execute_payment() with cents; the new system expects process_transaction() with Decimal. The return types differ completely. You can’t modify the legacy processor without extensive regression testing, and you shouldn’t change your clean new interface to accommodate old conventions.
Object Adapter Implementation
The object adapter uses composition. It holds a reference to the adaptee and delegates calls while translating between interfaces:
class PaymentAdapter(PaymentGateway):
"""Object adapter: wraps legacy processor via composition."""
def __init__(self, legacy_processor: LegacyPaymentProcessor):
self._processor = legacy_processor
self._processor.connect()
def process_transaction(
self,
amount: Decimal,
payment_token: str,
idempotency_key: str
) -> TransactionResult:
# Convert Decimal dollars to integer cents
amount_cents = int(amount * 100)
try:
result = self._processor.execute_payment(amount_cents, payment_token)
return TransactionResult(
success=result["status"] == "OK",
transaction_id=f"{idempotency_key}-{result['confirmation']}",
amount=Decimal(result["amount_processed"]) / 100
)
except RuntimeError as e:
return TransactionResult(
success=False,
transaction_id="",
amount=Decimal("0"),
error_message=str(e)
)
def __del__(self):
self._processor.disconnect()
Usage is straightforward:
# Create the legacy processor
legacy = LegacyPaymentProcessor("MERCH-001")
# Wrap it with the adapter
gateway: PaymentGateway = PaymentAdapter(legacy)
# Use the modern interface
result = gateway.process_transaction(
amount=Decimal("99.99"),
payment_token="tok_visa_4242",
idempotency_key="order-12345"
)
print(f"Transaction {'succeeded' if result.success else 'failed'}: {result.transaction_id}")
The order system only sees PaymentGateway. It doesn’t know or care that a legacy processor lurks underneath.
Class Adapter Implementation
The class adapter uses multiple inheritance, extending both the target interface and the adaptee:
class PaymentClassAdapter(PaymentGateway, LegacyPaymentProcessor):
"""Class adapter: uses multiple inheritance."""
def __init__(self, merchant_id: str):
LegacyPaymentProcessor.__init__(self, merchant_id)
self.connect()
def process_transaction(
self,
amount: Decimal,
payment_token: str,
idempotency_key: str
) -> TransactionResult:
amount_cents = int(amount * 100)
try:
# Direct call to inherited method
result = self.execute_payment(amount_cents, payment_token)
return TransactionResult(
success=result["status"] == "OK",
transaction_id=f"{idempotency_key}-{result['confirmation']}",
amount=Decimal(result["amount_processed"]) / 100
)
except RuntimeError as e:
return TransactionResult(
success=False,
transaction_id="",
amount=Decimal("0"),
error_message=str(e)
)
The class adapter calls self.execute_payment() directly rather than delegating to a wrapped instance. This provides access to protected members and can be slightly more efficient.
Python’s Method Resolution Order (MRO) matters here. With PaymentClassAdapter(PaymentGateway, LegacyPaymentProcessor), Python searches for methods left-to-right, depth-first. If both parent classes defined a method with the same name, PaymentGateway’s version would take precedence. Use ClassName.__mro__ to inspect the resolution order when debugging inheritance issues.
Object vs. Class Adapters: Trade-offs
| Aspect | Object Adapter | Class Adapter |
|---|---|---|
| Mechanism | Composition | Multiple inheritance |
| Flexibility | High—can adapt any compatible instance | Low—bound to specific class |
| Coupling | Loose—depends on interface | Tight—inherits implementation |
| Subclass adaptation | Works with adaptee subclasses | Cannot adapt subclasses |
| Protected member access | No direct access | Full access |
| Runtime binding | Yes—swap adaptees dynamically | No—fixed at definition |
| Testing | Easy to mock adaptee | Harder to isolate |
Choose object adapters when:
- You need to adapt instances provided at runtime
- You’re using dependency injection
- The adaptee might be subclassed
- You want loose coupling and easy testing
Choose class adapters when:
- You need access to protected members
- You control both the adapter and adaptee
- You want to override adaptee behavior
- Performance of delegation is a genuine concern (rare)
In practice, object adapters are more Pythonic. Python’s composition-friendly nature and duck typing make them the natural choice. Reserve class adapters for specific situations where inheritance benefits outweigh the coupling costs.
Advanced Patterns and Variations
Two-Way Adapter
Sometimes you need bidirectional compatibility—legacy code calling new systems and vice versa:
class TwoWayPaymentAdapter(PaymentGateway):
"""Adapter that works in both directions."""
def __init__(self, legacy_processor: LegacyPaymentProcessor):
self._processor = legacy_processor
self._processor.connect()
def process_transaction(
self,
amount: Decimal,
payment_token: str,
idempotency_key: str
) -> TransactionResult:
amount_cents = int(amount * 100)
result = self._processor.execute_payment(amount_cents, payment_token)
return TransactionResult(
success=result["status"] == "OK",
transaction_id=f"{idempotency_key}-{result['confirmation']}",
amount=Decimal(result["amount_processed"]) / 100
)
# Expose legacy interface for backward compatibility
def execute_payment(self, amount_cents: int, card_token: str) -> dict:
return self._processor.execute_payment(amount_cents, card_token)
Dynamic Adapter with __getattr__
For transparent proxying where you want to expose the adaptee’s full interface while adding or overriding specific methods:
class TransparentPaymentAdapter(PaymentGateway):
"""Transparently delegates unknown attributes to the adaptee."""
def __init__(self, legacy_processor: LegacyPaymentProcessor):
self._processor = legacy_processor
self._processor.connect()
def process_transaction(
self,
amount: Decimal,
payment_token: str,
idempotency_key: str
) -> TransactionResult:
amount_cents = int(amount * 100)
result = self._processor.execute_payment(amount_cents, payment_token)
return TransactionResult(
success=result["status"] == "OK",
transaction_id=f"{idempotency_key}-{result['confirmation']}",
amount=Decimal(result["amount_processed"]) / 100
)
def __getattr__(self, name: str):
# Delegate any unknown attribute to the wrapped processor
return getattr(self._processor, name)
This lets callers access adapter.merchant_id or adapter.disconnect() directly. Use this pattern carefully—it can obscure what interface you’re actually providing.
Protocol-Based Adapters
Python 3.8+ Protocols enable structural subtyping, letting you define adapters without explicit inheritance:
from typing import Protocol
class SupportsPayment(Protocol):
def process_transaction(
self, amount: Decimal, payment_token: str, idempotency_key: str
) -> TransactionResult: ...
def charge_customer(gateway: SupportsPayment, amount: Decimal) -> TransactionResult:
return gateway.process_transaction(amount, "tok_test", "key-123")
Any class implementing process_transaction with the right signature works—no inheritance required.
Best Practices and Pitfalls
Keep adapters thin. An adapter translates interfaces, nothing more. If you’re adding business logic, validation beyond type conversion, or caching, you’re building something else. Extract that logic into separate components.
Document the contract. Make explicit what the adapter guarantees. Does it handle connection lifecycle? What exceptions can leak through? Write docstrings that answer these questions.
Test both sides. Write tests that verify the adapter correctly implements the target interface and correctly calls the adaptee. Mock the adaptee to test translation logic in isolation.
Don’t leak adaptee details. The whole point is abstraction. If callers need to know about the legacy processor’s quirks, your adapter isn’t doing its job. Handle edge cases internally.
Avoid over-adapting. If you’re writing adapters for adapters, step back. You might need a different pattern (like a facade) or a more fundamental refactoring.
Consider factory functions. Rather than exposing adapter constructors, provide factory functions that create properly configured adapters:
def create_payment_gateway(merchant_id: str) -> PaymentGateway:
legacy = LegacyPaymentProcessor(merchant_id)
return PaymentAdapter(legacy)
This hides the adaptation entirely from client code.
The adapter pattern is a surgical tool for integration problems. It lets incompatible interfaces collaborate without modifying either one. In Python, prefer object adapters for their flexibility and testability. Keep the translation logic minimal, and you’ll have a clean seam between old and new systems.