Adapter Pattern: Interface Compatibility

Every non-trivial software system eventually faces the same challenge: you need to integrate code that wasn't designed to work together. Maybe you're connecting a legacy billing system to a modern...

Key Insights

  • The Adapter pattern enables incompatible interfaces to work together by wrapping one interface to match another, making it essential for integrating legacy systems and third-party libraries without modifying existing code.
  • Object adapters using composition offer more flexibility than class adapters using inheritance—prefer composition unless you have a specific reason to inherit.
  • Keep adapters thin and focused on interface translation only; the moment you add business logic to an adapter, you’ve created a maintenance liability.

The Integration Problem

Every non-trivial software system eventually faces the same challenge: you need to integrate code that wasn’t designed to work together. Maybe you’re connecting a legacy billing system to a modern API. Perhaps you’re swapping out a third-party library without rewriting your entire application. Or you’re building an abstraction layer over multiple services that each speak their own dialect.

Think of it like international travel. Your laptop charger works perfectly at home, but plug it into a wall socket in another country and nothing happens. The electricity is there, your device is functional—they just can’t communicate. You need an adapter that translates between the two interfaces.

The Adapter pattern solves this exact problem in software. It lets you make existing classes work with others without modifying their source code. This is particularly valuable when you don’t control the code you’re adapting—third-party libraries, legacy systems, or external APIs.

Adapter Pattern Fundamentals

The Adapter pattern converts the interface of a class into another interface that clients expect. It enables classes to work together that couldn’t otherwise because of incompatible interfaces.

Four components make up this pattern:

  • Target: The interface your client code expects to work with
  • Adaptee: The existing class with an incompatible interface
  • Adapter: The class that bridges Target and Adaptee
  • Client: The code that uses the Target interface

There are two approaches to implementing adapters. Object adapters use composition—they hold a reference to the adaptee and delegate calls to it. Class adapters use inheritance—they inherit from both the target interface and the adaptee. Object adapters are more common because most languages don’t support multiple inheritance.

Let’s start with a simple example. You have a legacy printer class that your new document system needs to use:

// The Adaptee - legacy code you can't modify
public class LegacyPrinter {
    public void printText(String text, int copies) {
        for (int i = 0; i < copies; i++) {
            System.out.println("LEGACY OUTPUT: " + text);
        }
    }
}

// The Target - interface your new system expects
public interface DocumentPrinter {
    void print(Document doc);
    void printMultiple(Document doc, PrintOptions options);
}

// The Adapter - bridges the gap
public class LegacyPrinterAdapter implements DocumentPrinter {
    private final LegacyPrinter legacyPrinter;
    
    public LegacyPrinterAdapter(LegacyPrinter legacyPrinter) {
        this.legacyPrinter = legacyPrinter;
    }
    
    @Override
    public void print(Document doc) {
        legacyPrinter.printText(doc.getContent(), 1);
    }
    
    @Override
    public void printMultiple(Document doc, PrintOptions options) {
        legacyPrinter.printText(doc.getContent(), options.getCopies());
    }
}

The client code works with DocumentPrinter and never knows it’s actually using legacy infrastructure underneath.

Object Adapter Implementation

Object adapters wrap the adaptee through composition. You hold a reference to the adaptee instance and delegate calls to it, translating parameters and return values as needed.

Here’s a practical example. Your application has a PaymentProcessor interface, but you need to integrate Stripe’s API which has a completely different structure:

// Your application's target interface
public interface PaymentProcessor {
    PaymentResult charge(Money amount, PaymentMethod method);
    PaymentResult refund(String transactionId, Money amount);
    PaymentStatus checkStatus(String transactionId);
}

// Stripe's API (the adaptee) - simplified for illustration
public class StripeAPI {
    public StripeCharge createCharge(long amountInCents, String currency, 
                                      String cardToken) {
        // Stripe-specific implementation
    }
    
    public StripeRefund createRefund(String chargeId, long amountInCents) {
        // Stripe-specific implementation
    }
    
    public StripeCharge retrieveCharge(String chargeId) {
        // Stripe-specific implementation
    }
}

// The adapter
public class StripePaymentAdapter implements PaymentProcessor {
    private final StripeAPI stripe;
    
    public StripePaymentAdapter(StripeAPI stripe) {
        this.stripe = stripe;
    }
    
    @Override
    public PaymentResult charge(Money amount, PaymentMethod method) {
        long cents = amount.toCents();
        String currency = amount.getCurrency().getCode();
        String token = extractStripeToken(method);
        
        StripeCharge charge = stripe.createCharge(cents, currency, token);
        
        return new PaymentResult(
            charge.getId(),
            mapStripeStatus(charge.getStatus()),
            Money.fromCents(charge.getAmount(), charge.getCurrency())
        );
    }
    
    @Override
    public PaymentResult refund(String transactionId, Money amount) {
        StripeRefund refund = stripe.createRefund(transactionId, amount.toCents());
        return new PaymentResult(
            refund.getId(),
            mapStripeStatus(refund.getStatus()),
            Money.fromCents(refund.getAmount(), refund.getCurrency())
        );
    }
    
    @Override
    public PaymentStatus checkStatus(String transactionId) {
        StripeCharge charge = stripe.retrieveCharge(transactionId);
        return mapStripeStatus(charge.getStatus());
    }
    
    private PaymentStatus mapStripeStatus(String stripeStatus) {
        return switch (stripeStatus) {
            case "succeeded" -> PaymentStatus.COMPLETED;
            case "pending" -> PaymentStatus.PENDING;
            case "failed" -> PaymentStatus.FAILED;
            default -> PaymentStatus.UNKNOWN;
        };
    }
    
    private String extractStripeToken(PaymentMethod method) {
        // Convert your payment method to Stripe's token format
    }
}

The key advantage of object adapters is flexibility. You can swap adaptees at runtime, adapt multiple different classes to the same interface, and the adapter only depends on the adaptee’s public interface.

Class Adapter Implementation

Class adapters use inheritance instead of composition. The adapter inherits from both the target interface and the adaptee class. This approach is less common because it requires multiple inheritance, which languages like Java and C# don’t support for classes.

Python supports multiple inheritance, making class adapters viable:

from abc import ABC, abstractmethod
from xml.etree import ElementTree

# Target interface
class DataReader(ABC):
    @abstractmethod
    def read(self, source: str) -> dict:
        pass
    
    @abstractmethod
    def read_list(self, source: str) -> list[dict]:
        pass

# Adaptee - existing XML parser
class XMLParser:
    def parse_file(self, filepath: str) -> ElementTree.Element:
        tree = ElementTree.parse(filepath)
        return tree.getroot()
    
    def parse_string(self, xml_string: str) -> ElementTree.Element:
        return ElementTree.fromstring(xml_string)

# Class adapter using multiple inheritance
class XMLDataReader(DataReader, XMLParser):
    def read(self, source: str) -> dict:
        root = self.parse_string(source)
        return self._element_to_dict(root)
    
    def read_list(self, source: str) -> list[dict]:
        root = self.parse_string(source)
        return [self._element_to_dict(child) for child in root]
    
    def _element_to_dict(self, element: ElementTree.Element) -> dict:
        result = dict(element.attrib)
        for child in element:
            result[child.tag] = child.text or self._element_to_dict(child)
        return result

Class adapters have trade-offs. They can override adaptee behavior, which is sometimes useful. However, they’re tightly coupled to one specific adaptee class—you can’t adapt multiple classes or swap implementations at runtime.

Real-World Applications

The Adapter pattern appears constantly in production systems:

Logging abstraction is a classic use case. Your application shouldn’t be coupled to a specific logging library:

// Your application's logging interface
public interface ILogger
{
    void Debug(string message);
    void Info(string message);
    void Warning(string message);
    void Error(string message, Exception? ex = null);
}

// Adapter for Serilog
public class SerilogAdapter : ILogger
{
    private readonly Serilog.ILogger _logger;
    
    public SerilogAdapter(Serilog.ILogger logger)
    {
        _logger = logger;
    }
    
    public void Debug(string message) => _logger.Debug(message);
    public void Info(string message) => _logger.Information(message);
    public void Warning(string message) => _logger.Warning(message);
    public void Error(string message, Exception? ex = null)
    {
        if (ex != null)
            _logger.Error(ex, message);
        else
            _logger.Error(message);
    }
}

// Adapter for NLog
public class NLogAdapter : ILogger
{
    private readonly NLog.Logger _logger;
    
    public NLogAdapter(NLog.Logger logger)
    {
        _logger = logger;
    }
    
    public void Debug(string message) => _logger.Debug(message);
    public void Info(string message) => _logger.Info(message);
    public void Warning(string message) => _logger.Warn(message);
    public void Error(string message, Exception? ex = null)
    {
        if (ex != null)
            _logger.Error(ex, message);
        else
            _logger.Error(message);
    }
}

Other common applications include database driver abstraction (your repository works with an interface while adapters handle MySQL, PostgreSQL, or MongoDB specifics), cloud provider abstraction (same interface for AWS S3 and Azure Blob Storage), and UI component libraries (adapting different charting libraries to a common interface).

The Adapter pattern is often confused with similar structural patterns:

Facade simplifies a complex subsystem by providing a unified interface. Adapter translates between interfaces. A facade might hide ten classes behind one simple API; an adapter makes one interface look like another.

Bridge separates abstraction from implementation, but it’s designed that way from the start. Adapter is a retrofit—you use it when you already have incompatible interfaces that need to work together.

Decorator wraps an object to add behavior while keeping the same interface. Adapter wraps an object to change its interface. A decorator adds logging to a service; an adapter makes a service look like a different type entirely.

Best Practices and Pitfalls

Keep adapters thin. An adapter should only translate between interfaces. The moment you add business logic, validation beyond type conversion, or complex transformations, you’ve created something that’s hard to test and maintain.

Test adapters in isolation. Mock the adaptee and verify the adapter correctly translates calls:

public class StripePaymentAdapterTest {
    private StripeAPI mockStripe;
    private StripePaymentAdapter adapter;
    
    @BeforeEach
    void setUp() {
        mockStripe = mock(StripeAPI.class);
        adapter = new StripePaymentAdapter(mockStripe);
    }
    
    @Test
    void charge_convertsMoneyToCents() {
        when(mockStripe.createCharge(anyLong(), anyString(), anyString()))
            .thenReturn(new StripeCharge("ch_123", "succeeded", 1000, "usd"));
        
        adapter.charge(Money.of(10, Currency.USD), testPaymentMethod());
        
        verify(mockStripe).createCharge(eq(1000L), eq("USD"), anyString());
    }
    
    @Test
    void charge_mapsStripeStatusToPaymentStatus() {
        when(mockStripe.createCharge(anyLong(), anyString(), anyString()))
            .thenReturn(new StripeCharge("ch_123", "succeeded", 1000, "usd"));
        
        PaymentResult result = adapter.charge(
            Money.of(10, Currency.USD), 
            testPaymentMethod()
        );
        
        assertEquals(PaymentStatus.COMPLETED, result.getStatus());
    }
}

Don’t over-engineer. If you’re adapting one class that will never change, a simple wrapper method might suffice. The full Adapter pattern shines when you need to swap implementations, test in isolation, or maintain a clean architecture boundary.

Avoid adapter chains. If you find yourself adapting an adapter, step back and reconsider your design. Deep adapter chains indicate a fundamental interface mismatch that should be addressed at the architecture level.

The Adapter pattern is a pragmatic solution for interface incompatibility. Use it when you need to integrate code you can’t modify, but don’t reach for it when a simpler solution—or a redesign—would serve you better.

Liked this? There's more.

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