Skip to content

Adapter Pattern

GoF Patterns Series — Structural · See also: Decorator · Facade

Introduction

Third-party libraries, legacy systems, and external APIs rarely speak the same language as your domain code. You cannot modify them — they are compiled, versioned, or owned by another team — yet your application must consume them. Wrapping that incompatible interface in a thin translation layer is exactly what the Adapter pattern is designed to do. It lets collaborating classes work together without altering either side of the boundary.

Intent

Convert the interface of a class into another interface that clients expect, enabling classes with incompatible interfaces to collaborate.

Structure

The pattern has two variants. The Object Adapter uses composition: the adapter holds a reference to the adaptee and delegates calls. The Class Adapter uses multiple inheritance (or, in Java, a combination of a class and interface) to extend the adaptee directly. Prefer the Object Adapter; it is more flexible and does not require access to the adaptee's source.

Interaction Flow

Participants

RoleResponsibility
Target (PaymentGateway)The interface the client knows and depends on.
Adaptee (LegacyPaymentGateway)The existing class with an incompatible interface that must be used as-is.
Adapter (LegacyPaymentGatewayAdapter)Implements the Target interface and translates calls to the Adaptee.
Client (OrderService)Operates against the Target interface; has no knowledge of the Adaptee or Adapter.

Anti-Pattern — Direct Adaptee Usage

Without the pattern, domain code reaches directly into the third-party API, scattering translation logic everywhere and making swapping impossible.

java
// ANTI-PATTERN: OrderService directly coupled to LegacyPaymentGateway
public class OrderService {

    // Concrete third-party class hard-wired into domain logic
    private final LegacyPaymentGateway legacyGateway = new LegacyPaymentGateway();

    public Order placeOrder(Order order) {
        // Translation logic duplicated every time the gateway is called:
        // convert cents → dollars, map customer IDs, parse the string return value
        double dollars = order.getTotalCents() / 100.0;
        String txRef = legacyGateway.initiatePayment(
                order.getCustomerId(), dollars, "USD");

        if (txRef == null || txRef.isEmpty()) {
            throw new PaymentException("Legacy gateway returned no transaction reference");
        }

        order.setPaymentRef(txRef);
        order.setStatus(OrderStatus.PAID);
        return orderRepository.save(order);
    }

    public void refundOrder(Order order) {
        // Same translation logic duplicated again
        double dollars = order.getTotalCents() / 100.0;
        boolean ok = legacyGateway.reverseTransaction(order.getPaymentRef(), dollars);
        if (!ok) {
            throw new RefundException("Legacy refund failed for " + order.getPaymentRef());
        }
    }
}

Problems with this design:

  • OrderService is coupled to LegacyPaymentGateway's exact method names and parameter types. Migrating to Stripe requires editing every call site in domain code.
  • The cents-to-dollars conversion and response parsing are scattered across multiple methods; a new payment path anywhere in the codebase repeats the same translation logic.
  • Unit testing OrderService requires instantiating or mocking the third-party class rather than a simple domain interface.
  • The LegacyPaymentGateway class cannot be replaced by a test double at compile time — there is no interface to substitute.

Correct Implementation — Object Adapter

Step 1 — Define the Target interface in the domain layer.

java
// Target interface — owned by the domain, never by the third party
public interface PaymentGateway {

    ChargeResult charge(String customerId, long amountCents);

    RefundResult refund(String chargeId, long amountCents);
}
java
public record ChargeResult(String chargeId, boolean success, String errorMessage) {
    public static ChargeResult ok(String chargeId) {
        return new ChargeResult(chargeId, true, null);
    }
    public static ChargeResult failed(String reason) {
        return new ChargeResult(null, false, reason);
    }
}

public record RefundResult(boolean success, String errorMessage) {}

Step 2 — Write the Adapter. It lives in the infrastructure layer, not the domain.

java
// Object Adapter — composes the LegacyPaymentGateway, never extends it
public class LegacyPaymentGatewayAdapter implements PaymentGateway {

    private static final String CURRENCY = "USD";

    private final LegacyPaymentGateway legacy;

    public LegacyPaymentGatewayAdapter(LegacyPaymentGateway legacy) {
        this.legacy = legacy;
    }

    @Override
    public ChargeResult charge(String customerId, long amountCents) {
        double dollars = amountCents / 100.0;
        try {
            String txRef = legacy.initiatePayment(customerId, dollars, CURRENCY);
            if (txRef == null || txRef.isBlank()) {
                return ChargeResult.failed("Gateway returned no transaction reference");
            }
            return ChargeResult.ok(txRef);
        } catch (LegacyGatewayException e) {
            return ChargeResult.failed(e.getMessage());
        }
    }

    @Override
    public RefundResult refund(String chargeId, long amountCents) {
        double dollars = amountCents / 100.0;
        try {
            boolean ok = legacy.reverseTransaction(chargeId, dollars);
            return ok ? new RefundResult(true, null)
                      : new RefundResult(false, "Gateway declined refund");
        } catch (LegacyGatewayException e) {
            return new RefundResult(false, e.getMessage());
        }
    }
}

Step 3 — Domain code depends only on the interface.

java
// Clean domain service — no translation logic, no third-party imports
@Service
public class OrderService {

    private final PaymentGateway gateway;
    private final OrderRepository orderRepository;

    public OrderService(PaymentGateway gateway, OrderRepository orderRepository) {
        this.gateway = gateway;
        this.orderRepository = orderRepository;
    }

    public Order placeOrder(Order order) {
        ChargeResult result = gateway.charge(order.getCustomerId(), order.getTotalCents());
        if (!result.success()) {
            throw new PaymentException("Charge failed: " + result.errorMessage());
        }
        order.setPaymentRef(result.chargeId());
        order.setStatus(OrderStatus.PAID);
        return orderRepository.save(order);
    }

    public void refundOrder(Order order) {
        RefundResult result = gateway.refund(order.getPaymentRef(), order.getTotalCents());
        if (!result.success()) {
            throw new RefundException("Refund failed: " + result.errorMessage());
        }
        order.setStatus(OrderStatus.REFUNDED);
        orderRepository.save(order);
    }
}

Step 4 — Wire the adapter as a Spring bean.

java
@Configuration
public class PaymentConfig {

    @Bean
    public PaymentGateway paymentGateway() {
        // Swap this single line to migrate to a different provider
        return new LegacyPaymentGatewayAdapter(new LegacyPaymentGateway());
    }
}

To migrate to Stripe, write a StripeGatewayAdapter implements PaymentGateway and change the @Bean method. OrderService is untouched.

Class Adapter Variant (for reference)

In languages with multiple inheritance the Class Adapter extends the adaptee directly. In Java this is rarely useful because you can only extend one class, but the pattern does appear when the adaptee is abstract:

java
// Class Adapter — extend the adaptee and implement the target interface
// Use only when you need to override adaptee methods; otherwise prefer composition
public class LegacyGatewayClassAdapter extends LegacyPaymentGateway
        implements PaymentGateway {

    @Override
    public ChargeResult charge(String customerId, long amountCents) {
        // Can call or override inherited LegacyPaymentGateway methods directly
        String txRef = initiatePayment(customerId, amountCents / 100.0, "USD");
        return txRef != null ? ChargeResult.ok(txRef) : ChargeResult.failed("No ref");
    }

    @Override
    public RefundResult refund(String chargeId, long amountCents) {
        boolean ok = reverseTransaction(chargeId, amountCents / 100.0);
        return new RefundResult(ok, ok ? null : "Declined");
    }
}

Prefer the Object Adapter. The Class Adapter locks you to a single adaptee class and exposes all of its public methods to callers.

Adapting AWS SDK v1 Calls to v2 Shapes

AWS SDK v2 introduced incompatible model classes (software.amazon.awssdk.*) versus v1 (com.amazonaws.*). If a library still emits SDK v1 S3Object records and your application is on SDK v2, an Adapter isolates the conversion:

java
public interface ObjectStorageClient {
    List<StoredObject> listObjects(String bucket, String prefix);
    void putObject(String bucket, String key, byte[] data);
}

// Adapter wrapping the AWS SDK v1 AmazonS3 client
public class AwsSdkV1StorageAdapter implements ObjectStorageClient {

    private final com.amazonaws.services.s3.AmazonS3 v1Client;

    public AwsSdkV1StorageAdapter(com.amazonaws.services.s3.AmazonS3 v1Client) {
        this.v1Client = v1Client;
    }

    @Override
    public List<StoredObject> listObjects(String bucket, String prefix) {
        com.amazonaws.services.s3.model.ObjectListing listing =
                v1Client.listObjects(bucket, prefix);

        return listing.getObjectSummaries().stream()
                .map(s -> new StoredObject(s.getKey(), s.getSize(), s.getLastModified().toInstant()))
                .toList();
    }

    @Override
    public void putObject(String bucket, String key, byte[] data) {
        v1Client.putObject(bucket, key, new java.io.ByteArrayInputStream(data),
                new com.amazonaws.services.s3.model.ObjectMetadata());
    }
}

When the migration to SDK v2 completes, only AwsSdkV1StorageAdapter is replaced — none of the services that call ObjectStorageClient need updating.

Real-World Examples

ExampleAdapteeTarget
Arrays.asList(T[])T[] (array)List<T>
InputStreamReaderInputStream (bytes)Reader (chars)
InputStreamReader(stream, charset)byte stream with encodingcharacter stream
Spring HandlerAdapterany controller type (annotated, HttpRequestHandler, etc.)Spring MVC DispatcherServlet handler protocol
AWS SDK v1 → v2 migrationv1 model objectsv2 domain types

InputStreamReader is a textbook Object Adapter: it holds an InputStream reference and implements Reader, translating byte reads to character reads while handling charset decoding in the adapter layer.

When to Use

  • You need to use a class whose interface does not match what the rest of your code expects, and you cannot modify that class (third-party library, legacy code, generated stubs).
  • You want to create a reusable class that cooperates with classes whose interfaces are not known in advance — the adapter absorbs the translation.
  • You are wrapping several existing classes behind a unified interface to enable polymorphic substitution.
  • You are migrating from one external dependency to another (SDK v1 → v2, Stripe → Adyen) and need domain code to remain unchanged.

Consequences

Benefits

  • Isolation of translation logic. All impedance-mismatch code lives in one class. The domain stays clean.
  • Swappability. Any adapter that implements the Target interface can replace any other at the injection site — the client is unaffected.
  • Testability. The domain service can be unit-tested with a simple mock of the Target interface, with no third-party class on the test classpath.
  • OCP compliance. Adding a new provider means writing a new adapter, never modifying existing domain classes. See Open/Closed Principle.

Trade-offs

  • Indirection cost. Every call passes through an extra layer. In practice the overhead is negligible, but it is worth noting.
  • Interface design burden. The Target interface must be designed carefully to be general enough to be implemented by multiple adapters without leaking adaptee-specific assumptions.
  • Multiple adapters can drift. If the Target interface evolves, every adapter must be updated. Keep the interface narrow and stable.
  • Facade — also wraps existing code, but the goal is simplification (one cohesive interface over many classes), not translation between incompatible interfaces.
  • Decorator — wraps an object that already implements the same interface; adds behavior rather than translating between interfaces.
  • Hexagonal Architecture — the Adapter pattern is the mechanism by which Ports and Adapters are realised: the port is the Target interface, and the adapter is the concrete class that bridges it to external infrastructure.
  • Dependency Inversion Principle — the Target interface is the abstraction that both the client and the adapter depend on; the concrete adaptee is never visible to the domain.
  • Open/Closed Principle — the adapter lets you extend the system with new providers without modifying existing classes.