03-structural-patterns

Adapter Pattern: Making Incompatible Interfaces Work Together

How to make two incompatible interfaces work together without touching either one, using object adapters, class adapters, and two-way adapters.

August 11, 2026
lldstructuraladapterinterfacewrapper

Adapter Pattern

Intent: convert the interface of a class into another interface clients expect. Adapter lets classes work together that couldn't otherwise, because of incompatible interfaces — without modifying either the client or the thing being adapted.

This is the single most common structural pattern in real backend code because it shows up every time you integrate with something you don't control: a third-party SDK, a legacy module, a vendor's payment gateway, an old internal service with a different method naming convention. You can't change their interface. You can wrap it.

💡

Think of Adapter as a translator, not a redesign. A translator at a UN meeting doesn't change what the speaker means — they just convert the words into a language the listener understands. Adapter does the same thing at the API level: same underlying capability, different vocabulary.


1. The problem: two APIs that should talk, but can't

Say you're building a PaymentProcessor that your checkout flow depends on. Internally, your codebase has standardized on one interface:

java
interface PaymentProcessor {
    PaymentResult charge(String customerId, long amountInCents, String currency);
}

Now the business signs a deal with a new payment vendor, StripeLegacyClient, whose SDK you cannot modify — it's a third-party JAR:

java
// Third-party SDK — cannot be changed, ships as a compiled dependency
class StripeLegacyClient {
    StripeChargeResponse createCharge(StripeChargeRequest request) {
        // makes an HTTP call to Stripe's legacy API, returns their DTO
        return new StripeChargeResponse();
    }
}
 
class StripeChargeRequest {
    String stripeCustomerToken;
    int amountCents;
    String currencyCode;
    // constructor, getters...
}
 
class StripeChargeResponse {
    String chargeId;
    String status; // "succeeded", "failed", "pending"
}

VIOLATION: bending your domain to fit the vendor

The naive move is to let StripeLegacyClient leak into your checkout code directly, or worse, change your PaymentProcessor interface to match Stripe's shape:

java
// VIOLATION: checkout logic is now coupled to a specific vendor's SDK types
class CheckoutService {
    private final StripeLegacyClient stripeClient = new StripeLegacyClient();
 
    void checkout(String customerId, long amountInCents) {
        StripeChargeRequest request = new StripeChargeRequest();
        request.stripeCustomerToken = customerId; // wrong semantics, forced to fit
        request.amountCents = (int) amountInCents; // silent narrowing, long -> int
        request.currencyCode = "USD";
 
        StripeChargeResponse response = stripeClient.createCharge(request);
        if (!"succeeded".equals(response.status)) {
            throw new RuntimeException("payment failed");
        }
        // Every call site that wants to charge a card now has to know Stripe's DTOs.
    }
}

Six months later, when the business adds a second vendor (Razorpay, for the India market), you either duplicate this logic with a different SDK's types scattered through CheckoutService, or you rewrite PaymentProcessor again. Every vendor swap becomes a CheckoutService change — a clear Open/Closed Principle violation: adding a payment provider means editing existing, tested code instead of adding new code.


2. Object Adapter (preferred)

The fix: write a small class that implements your interface and internally holds (composes) an instance of the incompatible class, translating calls between the two.

java
// FIXED: adapter translates between PaymentProcessor and StripeLegacyClient.
// CheckoutService never sees a Stripe type.
class StripePaymentAdapter implements PaymentProcessor {
    private final StripeLegacyClient stripeClient; // composition, not inheritance
 
    StripePaymentAdapter(StripeLegacyClient stripeClient) {
        this.stripeClient = stripeClient;
    }
 
    @Override
    public PaymentResult charge(String customerId, long amountInCents, String currency) {
        StripeChargeRequest request = new StripeChargeRequest();
        request.stripeCustomerToken = customerId;
        request.amountCents = Math.toIntExact(amountInCents); // fail loudly instead of silently narrowing
        request.currencyCode = currency;
 
        StripeChargeResponse response = stripeClient.createCharge(request);
 
        return switch (response.status) {
            case "succeeded" -> PaymentResult.success(response.chargeId);
            case "pending" -> PaymentResult.pending(response.chargeId);
            default -> PaymentResult.failure("stripe charge failed: " + response.status);
        };
    }
}
 
// CheckoutService depends only on the abstraction — completely vendor-agnostic
class CheckoutService {
    private final PaymentProcessor paymentProcessor; // injected
 
    CheckoutService(PaymentProcessor paymentProcessor) {
        this.paymentProcessor = paymentProcessor;
    }
 
    void checkout(String customerId, long amountInCents) {
        PaymentResult result = paymentProcessor.charge(customerId, amountInCents, "USD");
        if (!result.isSuccessful()) {
            throw new PaymentFailedException(result.getMessage());
        }
    }
}

Adding Razorpay now means adding one new class, RazorpayAdapter implements PaymentProcessorCheckoutService doesn't change at all.

This is why Adapter and Dependency Inversion travel together: the adapter is the concrete implementation of the abstraction that DIP asks your high-level module to depend on. CheckoutService depends on PaymentProcessor; StripePaymentAdapter is just one interchangeable detail behind it.


3. Class Adapter (via inheritance) — and why it's usually worse

Java also lets you build an adapter through extending the adaptee class directly and implementing the target interface (this is more natural in languages with multiple class inheritance like C++; in Java it only works when the adaptee is a class you can extend, not final, and you don't already need to extend something else):

java
// Class adapter: extends the adaptee, implements the target interface
class StripeClassAdapter extends StripeLegacyClient implements PaymentProcessor {
    @Override
    public PaymentResult charge(String customerId, long amountInCents, String currency) {
        StripeChargeRequest request = new StripeChargeRequest();
        request.stripeCustomerToken = customerId;
        request.amountCents = Math.toIntExact(amountInCents);
        request.currencyCode = currency;
 
        StripeChargeResponse response = createCharge(request); // inherited method, called directly
        return "succeeded".equals(response.status)
            ? PaymentResult.success(response.chargeId)
            : PaymentResult.failure(response.status);
    }
}
Object Adapter (composition)Class Adapter (inheritance)
CouplingLoose — holds a reference to any subtype of the adapteeTight — bound to one specific adaptee class at compile time
FlexibilityCan adapt the adaptee and its subclasses through one fieldLocked to the exact class extended
Multiple adapteesCan hold and translate between several adaptees in one adapterCan only extend one class (Java has no multiple class inheritance)
Overriding adaptee behaviorNot possible without the adaptee exposing hooksPossible — you can override the adaptee's own methods
Works with final classes / SDKsYesNo — cannot extend a final class, and most SDK classes should be treated as such
TestabilityEasy — inject a mock/fake of the adapteeHarder — the adapter is the adaptee, can't substitute it
⚠️

Prefer the object adapter almost always. It favors composition over inheritance (the same instinct behind the OOP pillars discussion of inheritance misuse), works with final SDK classes, and lets you unit test the adapter by injecting a fake adaptee. Reach for the class adapter only when you genuinely need to override protected behavior inside the adaptee itself — rare in application code.


4. Two-way adapter

Occasionally both sides of an integration need to see each other's interface — for example, during a migration where old code still calls the legacy interface while new code calls the new one, and both need to operate on the same underlying object. A two-way adapter implements both interfaces and delegates each to the appropriate side.

java
interface LegacyNotifier {
    void notifyUser(int userId, String msg);
}
 
interface ModernNotifier {
    void send(NotificationRequest request);
}
 
// Implements BOTH interfaces — can be handed to old callers and new callers alike
class NotifierTwoWayAdapter implements LegacyNotifier, ModernNotifier {
    private final ModernNotifier modernDelegate;
    private final LegacyNotifier legacyDelegate;
 
    NotifierTwoWayAdapter(ModernNotifier modernDelegate, LegacyNotifier legacyDelegate) {
        this.modernDelegate = modernDelegate;
        this.legacyDelegate = legacyDelegate;
    }
 
    @Override
    public void notifyUser(int userId, String msg) {
        // Old caller invokes the legacy method — translate forward to modern
        modernDelegate.send(new NotificationRequest(String.valueOf(userId), msg));
    }
 
    @Override
    public void send(NotificationRequest request) {
        // New caller invokes the modern method — translate backward to legacy
        legacyDelegate.notifyUser(Integer.parseInt(request.getUserId()), request.getMessage());
    }
}

Two-way adapters are most useful as a strangler-fig migration tool: they let you cut a monolith interface over to a new one incrementally, one call site at a time, with both old and new code paths staying correct throughout.


5. When to use vs. when it's overkill

Use Adapter whenSkip it when
Integrating a third-party SDK or library you can't modifyYou own both interfaces and can just change one to match the other
Wrapping a legacy internal service during a migrationThe "incompatibility" is trivial (e.g., just a field rename) — a simple mapper function may suffice
You need to swap vendors/implementations behind one stable interface (Strategy-like use)There's only ever going to be one implementation, ever — the interface indirection buys nothing
Unit-testing code that depends on an external system, via a fake adapterThe adaptee's interface already matches what you need

6. Adapter vs. Facade

Both patterns wrap something. The distinction is why:

AdapterFacade
PurposeTranslate one interface into another expected interfaceSimplify a complex subsystem's interface
Number of interfaces involvedExactly two — the target and the adapteeOne new simplified interface over many subsystem classes
Does it hide the subsystem?No — it exposes the adaptee's functionality, just relabeledNo — it provides a convenient entry point; the subsystem is still directly usable
When it appearsIntegrating something incompatibleOnboarding a caller onto something complicated
Motivating question"These two APIs don't match — how do I make them match?""This subsystem has 12 classes — how do I give callers a front door?"

See Facade Pattern for the full contrast, including Facade vs. Mediator.

💡

A useful gut check: if you're wrapping one incompatible class to match one interface your code already expects, that's Adapter. If you're wrapping many collaborating classes to hand callers one easy method to call, that's Facade. The two frequently appear together — a Facade's internals might use an Adapter to talk to one of the subsystem's external dependencies.


7. Real-world adapters

  • java.util.Collections.list(Enumeration) — adapts the legacy Enumeration interface to the modern List/Iterator world.
  • java.io.InputStreamReader — adapts a byte-oriented InputStream to the character-oriented Reader interface.
  • Spring's HandlerAdapter — adapts wildly different controller method signatures (@RequestMapping methods, Controller implementations, etc.) to a single interface DispatcherServlet can invoke uniformly.
  • JDBC drivers — each vendor's driver adapts a proprietary wire protocol to the single java.sql.Connection / java.sql.Statement interface your JDBC code calls against.
  • Cloud SDK wrappers — most companies write a thin adapter layer (BlobStorage interface with S3Adapter, GcsAdapter, AzureBlobAdapter) so application code never imports a cloud vendor's SDK types directly, keeping cloud migration to a localized blast radius.
🚨

Anti-pattern: an adapter that adds business logic. If StripePaymentAdapter.charge() started doing fraud checks or discount calculations, it's stopped being a translator and become a hidden extra layer of business logic with no name for it. Keep adapters thin — translation only. Business logic belongs in the domain layer that calls the adapter.


8. Testing with an adapter: the seam it creates for free

The moment your code depends on PaymentProcessor instead of StripeLegacyClient, you've created a testing seam — CheckoutService can be unit tested with zero network calls, zero real vendor SDKs, and zero flaky test infrastructure:

java
class InMemoryPaymentProcessor implements PaymentProcessor {
    private final Map<String, PaymentResult> scriptedResults = new HashMap<>();
    List<String> chargedCustomers = new ArrayList<>();
 
    void scriptResultFor(String customerId, PaymentResult result) {
        scriptedResults.put(customerId, result);
    }
 
    @Override
    public PaymentResult charge(String customerId, long amountInCents, String currency) {
        chargedCustomers.add(customerId);
        return scriptedResults.getOrDefault(customerId, PaymentResult.success("test-txn"));
    }
}
 
class CheckoutServiceTest {
    @Test
    void checkoutThrowsWhenPaymentFails() {
        InMemoryPaymentProcessor fakeProcessor = new InMemoryPaymentProcessor();
        fakeProcessor.scriptResultFor("cust-1", PaymentResult.failure("card declined"));
        CheckoutService service = new CheckoutService(fakeProcessor);
 
        assertThrows(PaymentFailedException.class, () -> service.checkout("cust-1", 1_000));
    }
}

Separately, you'd write a small, focused test suite for the adapter itself — usually against a sandbox/test mode of the real vendor SDK, or against a hand-rolled fake of StripeLegacyClient if the SDK is hard to instantiate in tests:

java
class StripePaymentAdapterTest {
    @Test
    void mapsStripeSucceededStatusToPaymentSuccess() {
        StripeLegacyClient fakeStripe = new FakeStripeLegacyClient(); // test double for the adaptee
        StripePaymentAdapter adapter = new StripePaymentAdapter(fakeStripe);
 
        PaymentResult result = adapter.charge("cust-1", 500, "USD");
 
        assertTrue(result.isSuccessful());
    }
 
    @Test
    void amountThatOverflowsIntThrowsRatherThanSilentlyTruncating() {
        StripeLegacyClient fakeStripe = new FakeStripeLegacyClient();
        StripePaymentAdapter adapter = new StripePaymentAdapter(fakeStripe);
 
        assertThrows(ArithmeticException.class,
            () -> adapter.charge("cust-1", Long.MAX_VALUE, "USD")); // Math.toIntExact catches this
    }
}

This split — one test suite that never touches the adaptee, one small test suite dedicated to verifying the translation is correct — is the practical payoff of the pattern: the hard-to-test part (a real network-calling SDK) is isolated into the smallest possible class.


9. Common mistakes

Stateful adapters shared across requests. An adapter should generally be as stateless as the interface it implements. If StripePaymentAdapter starts caching a "last customer charged" field to save a lookup, and the same adapter instance is shared across concurrent requests (common with a singleton-scoped Spring bean), you've introduced a race condition that has nothing to do with Stripe and everything to do with the adapter accidentally becoming a piece of shared mutable state.

Swallowing the adaptee's exceptions instead of translating them. A catch (Exception e) { return PaymentResult.failure("error"); } inside an adapter destroys information a caller might need to distinguish "card declined" from "network timeout" from "invalid API key." Translate exceptions deliberately, the same way you translate the happy path:

java
// MISTAKE: every possible failure looks identical to the caller
try {
    StripeChargeResponse response = stripeClient.createCharge(request);
    return toPaymentResult(response);
} catch (Exception e) {
    return PaymentResult.failure("payment error"); // caller can't distinguish retryable vs not
}
java
// BETTER: translate exception types too, not just the happy-path response
try {
    StripeChargeResponse response = stripeClient.createCharge(request);
    return toPaymentResult(response);
} catch (StripeNetworkException e) {
    throw new PaymentGatewayUnavailableException(e); // caller can retry
} catch (StripeAuthException e) {
    throw new PaymentConfigurationException(e); // caller should page someone, not retry
}

One adapter trying to serve two unrelated target interfaces. If StripePaymentAdapter is asked to also implement RefundProcessor and SubscriptionManager because "it's already got the Stripe client," it has quietly become a facade wearing an adapter's name, and now has three reasons to change instead of one — an ISP violation smuggled in through the adapter layer. One adapter per target interface, even if they wrap the same adaptee instance.


10. Adapter at a glance

QuestionAnswer
What does it change?The interface — same underlying behavior, different method signatures/types
What does it NOT do?Simplify a subsystem (that's Facade), add new behavior (that's Decorator), or control access (that's Proxy)
Preferred implementationObject adapter (composition) over class adapter (inheritance)
Where it lives in a typical codebaseInfrastructure/integration layer, one per external dependency
Biggest risk if misusedBusiness logic creeping into the translation layer; swallowed exceptions

Interview Questions

  • What problem does the Adapter pattern solve, and how is it different from just editing the interface of the class you don't control?
  • Compare the object adapter and class adapter approaches. Which is generally preferred in Java, and why?
  • Why can't you write a class adapter for a final third-party SDK class? What do you do instead?
  • Describe a two-way adapter and a realistic scenario where you'd need one during a system migration.
  • How does Adapter relate to the Dependency Inversion Principle? Where does the adapter sit relative to the abstraction and the concrete detail?
  • What's the difference between Adapter and Facade? Give an example of a class that is clearly one and not the other.
  • Why is it considered an anti-pattern for an adapter to contain business logic?
  • If you added a fifth payment vendor to the CheckoutService example, what exactly would you write, and what would you not need to touch?