Adapter Pattern
Learn how the Adapter pattern lets an application keep using the interface it already depends on while swapping out the concrete implementation behind it, without touching a single existing call site.
Learning objectives
- Explain why a third-party class with a different method shape can't simply be swapped in
- Build an adapter class that translates one interface into another the client already expects
- Judge when reaching for an adapter is the right call versus fixing a mismatch directly
- Spot when an adapter is quietly picking up responsibilities it shouldn't have
◆ Story
A laptop charger built for a US-style plug doesn't magically fit a UK-style wall socket — the physical shapes simply don't match. A travel adapter doesn't rewire the laptop or rebuild the socket; it just sits in between the two, translating one shape into the other, so both sides work exactly as they were designed to, completely unmodified.
That's the entire idea behind the Adapter pattern, applied to code instead of plugs. This chapter builds it around a common real situation: an application currently sends emails through its own in-house EmailNotificationService, and the team wants to migrate to a third-party provider, SendGrid, without rewriting every place in the codebase that already calls the existing send() method.
◆ The problem
The application already depends on a small NotificationService interface with one method, send(to, subject, body), and every call site in the codebase already calls it that way through EmailNotificationService. SendGrid ships its own class, SendGridService, with a method called sendEmail() instead — different name, different parameter order, and it doesn't implement NotificationService at all, because it was written by someone else entirely, for a different codebase.
Try the obvious swap — declare a NotificationService reference and assign it a new SendGridService() — and it simply won't compile. SendGridService doesn't fulfill the contract the rest of the app depends on.
This isn't a small annoyance. If send() is called from a dozen places, migrating to SendGridService's different signature by hand means finding and correctly editing every one of them, and getting the parameter order right at each site. Worse, SendGridService is a third-party library — there's no option to just rename its method or reorder its parameters to match what the rest of the code expects, because that code isn't yours to change. And if the team ever needs to switch providers again down the line, all of that rewriting has to happen a second time.
💻 Code example
interface NotificationService { void send(String to, String subject, String body); } class EmailNotificationService implements NotificationService { public void send(String to, String subject, String body) { System.out.println("Sending email to " + to + ", subject: " + subject); } } // SendGrid's own class -- a third-party library, its shape is fixed class SendGridService { void sendEmail(String recipient, String title, String content) { System.out.println("Sending via SendGrid to " + recipient + ", title: " + title); } } // the client, already written against NotificationService NotificationService emailService = new EmailNotificationService(); emailService.send("customer@example.com", "Order confirmation", "Your order has been received"); // this line does not compile -- SendGridService has no send() method at all // NotificationService emailService = new SendGridService();
The client only genuinely cares about the shape it was already written against — send(to, subject, body) — not about which specific provider fulfills it underneath. SendGridService can't be changed, but something new can sit between it and the client, translating one shape into the other.
That in-between class should implement NotificationService, the interface the client already expects, and internally call SendGridService's actual method, converting parameters as needed. This is the Adapter pattern: write one small class that implements the interface your application already depends on, and have it delegate to — and translate for — the incompatible class underneath. It's the travel plug, sitting between two things that were never designed to fit together directly.
The adapter's constructor takes the real SendGridService instance it's wrapping, storing it in a field typed exactly the way SendGrid wrote it. Its one public method, send(), matches NotificationService's exact signature — and internally, it calls sendGridService.sendEmail(...), mapping to to recipient, subject to title, and body to content. That translation happens in exactly one place in the whole codebase.
💻 Code example
class SendGridAdapter implements NotificationService { private final SendGridService sendGridService; SendGridAdapter(SendGridService sendGridService) { this.sendGridService = sendGridService; } public void send(String to, String subject, String body) { // the only place in the whole codebase this translation happens sendGridService.sendEmail(to, subject, body); // to->recipient, subject->title, body->content } }
With SendGridAdapter in place, migrating providers comes down to a single line at each construction site — everything else stays exactly as it was.
Every other line in the codebase that already calls emailService.send(to, subject, body) keeps compiling and keeps working, completely unaware that the underlying provider changed. That's the entire "rewrite every call site" problem from the naive approach, gone. Notice too that the rest of the application only ever depends on the NotificationService abstraction, never on SendGridService directly — the adapter is what makes that possible even when the concrete implementation you actually need to use doesn't naturally conform to your abstraction. Switch providers again in the future, and the fix is one new adapter class; nothing else in the application changes.
💻 Code example
// before: NotificationService emailService = new EmailNotificationService(); // after: NotificationService emailService = new SendGridAdapter(new SendGridService()); // every other line, everywhere in the codebase, is unchanged: emailService.send("customer@example.com", "Order confirmation", "Your order has been received");
▲ Edge case — mismatched semantics, not just mismatched signatures
This example is a clean rename-and-reorder: every parameter has a direct one-to-one match. Real adapters often face a harder problem. What if SendGridService required an API key per call, or returned a success/failure code the client's interface has no place for? The adapter has to absorb that mismatch too — sometimes by holding extra configuration itself, sometimes by translating a return value into an exception the client's interface already knows how to handle.
▲ Edge case — an adapter should not accumulate new business logic
It's tempting to slip extra behavior — retry logic, logging, validation — into an adapter simply because it's already sitting in the call path. Resist that. An adapter's entire job is translating between two shapes. Adding unrelated responsibilities on top is a quiet Single Responsibility violation, better handled by a separate class the adapter can be composed with instead.
▲ Worth being honest about
An adapter is inherently a workaround for something outside your control, and it adds a real, permanent layer of indirection. It's the right tool specifically when you genuinely can't modify the incompatible class — a third-party library, a legacy system you're not allowed to touch. If you own both sides of the mismatch, it's usually better to just fix it directly rather than reaching for an adapter.
◆ Where this shows up
Legacy system migration is the exact scenario this chapter builds: wrapping an old or third-party interface so the rest of the codebase doesn't need to change while the underlying implementation does.
Java's own I/O classes include a genuine adapter: InputStreamReader adapts a byte-oriented InputStream into a character-oriented Reader, letting character-based code work with a byte-based source without either side needing to know about the other's native shape. Third-party API integration is the broader category this all falls under — wrapping an external library's specific method names, parameter shapes, and data formats to match what the rest of your application already expects, so the integration is contained in one place instead of scattered across every call site that needs that external service.
Q: When is the Adapter pattern actually the right tool, versus just fixing the mismatch directly? A: When you genuinely can't modify the incompatible class -- a third-party library or a legacy system. If you own both sides, fixing the mismatch directly is usually simpler.
Q: How does Adapter relate to the Dependency Inversion Principle? A: It's what makes depending on an abstraction achievable even when a concrete implementation you must use doesn't naturally conform to that abstraction -- the adapter bridges the gap in one place.
Q: In the SendGridAdapter example, why does the adapter implement NotificationService rather than mirror SendGridService's own shape? A: Because NotificationService is the interface the client already depends on. Implementing it means every existing call site keeps working unchanged; mirroring SendGridService's shape instead would defeat the entire point.
Q: What's a real risk of putting extra logic, like retries or logging, inside an adapter? A: It quietly violates Single Responsibility -- an adapter's job is translating between two interface shapes, not accumulating unrelated behavior just because it happens to sit in the call path.
Q: What would need to change if the app switched providers a second time? A: Only the single line constructing the NotificationService -- a new adapter class gets written for the new provider, and every existing call to send() elsewhere stays untouched.
Want a visual for this concept?
Generate a diagram tailored to “Adapter Pattern” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →