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.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.