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.
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:
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:
// 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:
// 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.
// 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 PaymentProcessor — CheckoutService 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):
// 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) | |
|---|---|---|
| Coupling | Loose — holds a reference to any subtype of the adaptee | Tight — bound to one specific adaptee class at compile time |
| Flexibility | Can adapt the adaptee and its subclasses through one field | Locked to the exact class extended |
| Multiple adaptees | Can hold and translate between several adaptees in one adapter | Can only extend one class (Java has no multiple class inheritance) |
| Overriding adaptee behavior | Not possible without the adaptee exposing hooks | Possible — you can override the adaptee's own methods |
Works with final classes / SDKs | Yes | No — cannot extend a final class, and most SDK classes should be treated as such |
| Testability | Easy — inject a mock/fake of the adaptee | Harder — 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.
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 when | Skip it when |
|---|---|
| Integrating a third-party SDK or library you can't modify | You own both interfaces and can just change one to match the other |
| Wrapping a legacy internal service during a migration | The "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 adapter | The adaptee's interface already matches what you need |
6. Adapter vs. Facade
Both patterns wrap something. The distinction is why:
| Adapter | Facade | |
|---|---|---|
| Purpose | Translate one interface into another expected interface | Simplify a complex subsystem's interface |
| Number of interfaces involved | Exactly two — the target and the adaptee | One new simplified interface over many subsystem classes |
| Does it hide the subsystem? | No — it exposes the adaptee's functionality, just relabeled | No — it provides a convenient entry point; the subsystem is still directly usable |
| When it appears | Integrating something incompatible | Onboarding 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 legacyEnumerationinterface to the modernList/Iteratorworld.java.io.InputStreamReader— adapts a byte-orientedInputStreamto the character-orientedReaderinterface.- Spring's
HandlerAdapter— adapts wildly different controller method signatures (@RequestMappingmethods,Controllerimplementations, etc.) to a single interfaceDispatcherServletcan invoke uniformly. - JDBC drivers — each vendor's driver adapts a proprietary wire protocol to the single
java.sql.Connection/java.sql.Statementinterface your JDBC code calls against. - Cloud SDK wrappers — most companies write a thin adapter layer (
BlobStorageinterface withS3Adapter,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:
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:
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:
// 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
}// 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
| Question | Answer |
|---|---|
| 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 implementation | Object adapter (composition) over class adapter (inheritance) |
| Where it lives in a typical codebase | Infrastructure/integration layer, one per external dependency |
| Biggest risk if misused | Business 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
finalthird-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
CheckoutServiceexample, what exactly would you write, and what would you not need to touch?