What is Adapter design pattern?
Adapter design pattern converts the interface of one class into another interface that clients expect, so incompatible classes can work together without changing their code.
Theory
TL;DR
- Power adapter analogy: your US plug doesn't change, the adapter bridges the shape mismatch between plug and socket.
- The pattern wraps an existing class and delegates calls with translation, not rewriting.
- Main trigger: interface mismatch blocks integration and you can't modify either side.
- Decision rule: can't change the old class AND can't change the new system? Adapter is the answer.
- Adapter sits at the seam between two worlds that were never designed to meet.
Quick example
// Old printer only knows printRaw(data)
class OldPrinter {
printRaw(data) { console.log(`Raw: ${data}`); }
}
// Adapter wraps old printer, exposes printFormatted(text)
class PrinterAdapter {
constructor(oldPrinter) { this.old = oldPrinter; }
printFormatted(text) {
const formatted = text.toUpperCase(); // translate
this.old.printRaw(formatted); // delegate
}
}
const adapter = new PrinterAdapter(new OldPrinter());
adapter.printFormatted('hello'); // Output: Raw: HELLOTwo things happen here: translation (uppercasing) and delegation (calling the old method). Neither class changes.
Key difference from rewriting
You could rename printRaw to printFormatted. But what if it's a third-party library? Or 50 other places still call printRaw? The adapter sits between the two worlds. Old code keeps working. New code gets the interface it expects. Both sides stay untouched.
When to use
- Legacy API doesn't match what your new code expects, and modifying either side isn't an option.
- Third-party library with its own interface: wrap it without forking it.
- Multiple services with different signatures: one adapter per service, unified interface for your code.
- Test mocks that match the same interface as production code: swap at runtime, no logic changes.
How it works internally
Client calls the adapter's method. The adapter maps parameters to what the adaptee expects, calls the adaptee, then translates the return value back. In JavaScript, structural typing handles this naturally: if the adapter has the right shape, client code accepts it. V8 compiles the wrapper as a thin dispatch layer with no meaningful overhead for typical use cases.
If you're working in TypeScript, add implements IPayment explicitly. Otherwise the compiler won't catch interface mismatches at build time. Structural typing is convenient, but explicit contracts are safer.
Common mistakes
1. Partial interface implementation
// Wrong: only pay(), missing refund()
class PartialAdapter {
pay() { /* ... */ }
}
adapter.refund(); // TypeError: adapter.refund is not a functionImplement the full target interface. If a method has no mapping, throw an explicit NotImplementedError rather than leaving it undefined.
2. Inheritance instead of composition
// Wrong: extends ties adapter to OldGateway's hierarchy
class BadAdapter extends OldGateway {
pay() { super.processPayment(); }
}Use composition. new Adapter(new OldGateway()) is correct. Inheritance creates tight coupling: if OldGateway changes its hierarchy, your adapter breaks.
3. Missing error translation
// Wrong: OldError leaks through to caller
pay(amount) { this.old.process(amount); }
// Right
pay(amount) {
try {
this.old.process(amount);
} catch (e) {
throw new PaymentError(e.message); // translate to expected error type
}
}Old code throws OldAuthError. New code expects UnauthorizedError. The adapter handles that mapping. Otherwise error handling in the client breaks.
4. Assuming structural typing covers everything in TypeScript
JavaScript is flexible about object shapes, but TypeScript with strict mode wants you to explicitly declare implements IPayment. It's a common oversight. You get no type errors until runtime in complex codebases if you skip it.
Real-world usage
express-jwtadapts thejsonwebtokenlibrary to Express middleware signature.react-querywraps Axios and Fetch behind a unified query function.- AWS SDK v2-to-v3 migration ships with adapter utilities to ease the transition.
- Redux-saga adapts between thunk-style and saga-style async effects.
Follow-up questions
Q: What is the difference between Adapter and Decorator?
A: Decorator adds behavior while keeping the same interface. Adapter changes the interface to match what a client expects. Similar structure, different purpose.
Q: What is the difference between Adapter and Bridge?
A: Adapter retrofits existing classes that were never designed to work together. Bridge decouples abstraction from implementation at design time, before the problem exists.
Q: Can you build a two-way adapter?
A: Yes. Each direction has its own translation. Database ORMs do this: SQL toward the database, objects toward your application code.
Q: When does Adapter add noticeable overhead?
A: In deep call chains or when translation is expensive, like converting large data payloads. Profile delegation depth if latency is a concern.
Q: (Senior) How does Adapter fit with an API Gateway in microservices?
A: The gateway acts as a system-level adapter. It translates external protocols like REST or gRPC into whatever format internal services expect, and handles versioning without touching service code.
Examples
Basic: wrapping a legacy printer
class OldPrinter {
printRaw(data) { console.log(`Raw: ${data}`); }
}
class PrinterAdapter {
constructor(printer) { this.printer = printer; }
printFormatted(text) {
this.printer.printRaw(text.toUpperCase());
}
}
const adapter = new PrinterAdapter(new OldPrinter());
adapter.printFormatted('hello'); // Raw: HELLOOld class stays untouched. New callers use printFormatted. The adapter handles the translation in between.
Intermediate: adapting legacy auth to Express middleware
Real scenario: your team inherits a callback-based auth library. New Express routes expect req.user set by middleware.
class OldAuthLib {
authenticate(userId, callback) {
// simulates async DB lookup
callback(null, { id: userId, name: 'User' });
}
}
class AuthMiddlewareAdapter {
constructor(auth) { this.auth = auth; }
middleware(req, res, next) {
this.auth.authenticate(req.body.userId, (err, user) => {
if (err) return next(err);
req.user = user; // maps to Express convention
next();
});
}
}
const authAdapter = new AuthMiddlewareAdapter(new OldAuthLib());
app.post('/login',
(req, res, next) => authAdapter.middleware(req, res, next),
(req, res) => res.json(req.user) // { id: 123, name: 'User' }
);The route handler doesn't know it's talking to a legacy system. I've seen this exact pattern save a migration when a third-party auth SDK changed its API overnight: only the adapter needed updating.
Advanced: payment gateway with full error translation
This shows what a production-ready adapter looks like, including error translation and a complete interface.
class OldPaymentGateway {
processPayment(amount) { console.log(`Processing $${amount}`); }
reverseTransaction(id) { console.log(`Reversing ${id}`); }
}
class PaymentError extends Error {}
class PaymentAdapter {
constructor(legacyGateway) {
this.gateway = legacyGateway;
}
pay(amount, currency) {
try {
const converted = currency === 'EUR' ? amount * 1.1 : amount;
this.gateway.processPayment(converted);
} catch (e) {
throw new PaymentError(`Payment failed: ${e.message}`);
}
}
refund(transactionId) {
try {
this.gateway.reverseTransaction(transactionId);
} catch (e) {
throw new PaymentError(`Refund failed: ${e.message}`);
}
}
}
const adapter = new PaymentAdapter(new OldPaymentGateway());
adapter.pay(100, 'EUR'); // Processing $110
adapter.refund('tx-001'); // Reversing tx-001Both methods are implemented (no partial interface). Errors are translated to a known type. The adapter covers the full contract of the target interface.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.