Intent
Adapter converts the interface of a class into another interface a client expects, letting classes work together that otherwise couldn’t because of incompatible interfaces.
That is the whole pattern in one sentence. Everything below is about when the gap between two interfaces is worth a dedicated object to bridge it, and how to build that bridge without leaking the thing you’re hiding.
The Problem
Your code is written against an interface you control. Then reality intrudes: a third-party SDK, a legacy module, a vendor library, or someone else’s team ships a class whose method names, argument shapes, error model, or return types don’t match what your code calls. You can’t (or shouldn’t) change either side:
- You can’t change the third party — it’s a compiled jar/npm package/
.so, or it’s owned by another team. - You shouldn’t change your client — it’s correct, tested, and coded against a clean abstraction. Bending it to a vendor’s quirks couples your domain to that vendor.
The naive fix is to sprinkle the vendor’s API directly across your codebase. Now every call site knows the vendor’s method names, its weird Result<T, ErrCode> enum, and the fact that amounts are in cents-as-strings. When you swap vendors (or they ship a breaking v2), you edit fifty files.
The Adapter localizes the impedance mismatch into one class. Your client keeps talking to the interface it owns; the adapter does the translation; the vendor stays untouched behind it.
Adapter is a retroactive pattern. You reach for it when an interface mismatch already exists and at least one side is frozen. If you control both sides from the start, you don’t need an Adapter — you just design compatible interfaces. Mentioning this framing in an interview signals you understand it’s a remediation tool, not a default.
Structure
There are two structural variants. The distinction is what every interviewer is probing for.
Object Adapter (composition — strongly preferred)
The adapter holds a reference to the adaptee and delegates.
┌─────────────────┐ ┌──────────────────────┐
│ <<interface>> │ │ Adaptee │
Client─▶│ Target │ │ (third-party/legacy)│
│ + request() │ │ + specificRequest() │
└────────▲────────┘ └──────────▲───────────┘
│ implements │ holds a ref
│ │ (composition)
┌────────┴────────────────────────────┐│
│ Adapter ││
│ - adaptee: Adaptee ────────────────┘│
│ + request() { adaptee.specificRequest() }
└───────────────────────────────────────┘
Class Adapter (inheritance — limited, language-dependent)
In the canonical GoF form, the adapter inherits from both the Target and the Adaptee: it gains the Adaptee’s concrete specificRequest() by inheritance and re-exposes it as request().
┌─────────────────┐ ┌──────────────────────┐
│ <<interface>> │ │ Adaptee │
│ Target │ │ + specificRequest() │
└────────▲────────┘ └──────────▲───────────┘
│ implements │ extends
│ │ (inheritance)
┌────────┴─────────────────────────┴──────┐
│ Adapter │
│ + request() { this.specificRequest() } │
└──────────────────────────────────────────┘
Class adapter requires multiple inheritance, so it’s only natural in C++. In Java/TypeScript/Python you can approximate it by extends Adaptee implements Target, but you can only extend one class — so it’s brittle and rarely worth it. Object adapter is the right default in nearly every language, because it favors composition over inheritance, can adapt the adaptee and its subclasses, and doesn’t expose adaptee methods you didn’t intend to.
Participants
| Participant | Role |
|---|---|
| Target | The interface your client codes against — the one you own. Often called a “port” in hexagonal architecture. |
| Client | Collaborates with objects conforming to Target. Has no knowledge of the adaptee. |
| Adaptee | The existing class with the incompatible-but-useful interface (the vendor SDK, legacy class). |
| Adapter | Implements Target and translates calls into the adaptee’s specificRequest(), including mapping data shapes, errors, and units. |
A Real Example: Wrapping a Payment Gateway
Forget the textbook “round peg in square hole.” Here’s the situation you’ll actually hit. Your checkout service is written against a clean PaymentGateway port:
charge(amount: Money, token: string) -> ChargeResult
You start on Stripe-like vendor “Stripon”, whose SDK takes amounts as integer cents, returns a result object with outcome and id, and throws a StriponError. Marketing now wants a second provider, “PayBuddy”, whose SDK takes amounts as a decimal string in an options object and signals failure via a status code in the response (no exceptions). Your checkout code must not care which is live.
We adapt each vendor to the same PaymentGateway port. Two design decisions are deliberate and worth stating in an interview:
- The result is a closed sum type — success guarantees a transaction id; failure guarantees a reason. We model that invariant in every language (discriminated union in TS, tagged classes in Python,
std::variantin C++) so a client can never read a stale field. - The adapter normalizes all failure modes. Stripon throws; PayBuddy returns status codes; and any unexpected throwable — a network blowup, a vendor bug — must also become a
ChargeResult, never leak as a raw exception through the port. This is identical in all three tabs.
// ---- Target (the port we own) ----
type Money = { cents: number; currency: string };
type ChargeResult =
| { ok: true; transactionId: string }
| { ok: false; reason: string }; // success implies an id; failure implies a reason
interface PaymentGateway {
charge(amount: Money, sourceToken: string): Promise<ChargeResult>;
}
// ---- Adaptee #1: third-party SDK we cannot modify ----
class StriponError extends Error { constructor(public code: string) { super(code); } }
class StriponSdk {
// amount is integer cents; throws StriponError (or anything) on failure
async createCharge(amountCents: number, currency: string, src: string):
Promise<{ id: string; outcome: string }> {
if (amountCents <= 0) throw new StriponError("amount_too_small");
return { id: `ch_${src}_${amountCents}${currency}`, outcome: "succeeded" };
}
}
// ---- Object Adapter ----
class StriponAdapter implements PaymentGateway {
constructor(private readonly sdk: StriponSdk) {} // composition
async charge(amount: Money, sourceToken: string): Promise<ChargeResult> {
try {
const res = await this.sdk.createCharge(amount.cents, amount.currency, sourceToken);
return res.outcome === "succeeded"
? { ok: true, transactionId: res.id }
: { ok: false, reason: res.outcome };
} catch (e) {
// normalize EVERY failure: known vendor errors AND anything unexpected
const code = e instanceof StriponError ? e.code : "unknown_error";
return { ok: false, reason: code };
}
}
}
// ---- Adaptee #2: a totally different shape ----
class PayBuddyClient {
// amount is a decimal string in an options object; no exceptions, status code instead
pay(opts: { amount: string; currency: string; token: string }) {
return { status: 200, ref: `pb_${opts.token}` };
}
}
class PayBuddyAdapter implements PaymentGateway {
constructor(private readonly client: PayBuddyClient) {}
async charge(amount: Money, sourceToken: string): Promise<ChargeResult> {
try {
const decimal = (amount.cents / 100).toFixed(2); // unit conversion
const res = this.client.pay({ amount: decimal, currency: amount.currency, token: sourceToken });
return res.status === 200
? { ok: true, transactionId: res.ref }
: { ok: false, reason: `http_${res.status}` };
} catch (e) {
return { ok: false, reason: "unknown_error" };
}
}
}
// ---- Client: identical regardless of vendor ----
async function checkout(gateway: PaymentGateway) {
const r = await gateway.charge({ cents: 4999, currency: "USD" }, "tok_visa");
return r.ok ? r.transactionId : `declined: ${r.reason}`;
}Notice what the adapter actually does — it’s never just renaming methods:
- Unit conversion —
cents→"49.99". - Error-model translation — exceptions (Stripon) and status codes (PayBuddy), plus any unexpected throwable, all become a
ChargeResultyour client can branch on uniformly. The point is that nothing of the adaptee escapes — not its types, not its exceptions. - Shape mapping — Stripon takes positional args while PayBuddy takes a keyed options object/dict;
idvsrefboth becometransactionId. (In the C++ tab the shape contrast iscreateCharge(int, string, string)vspay(string, string, string)rather than a dict, since C++ has no idiomatic kwargs — the mapping is the same, the surface syntax differs.)
The C++ tab also shows the class adapter via multiple inheritance, inheriting PayBuddyClient privately so its raw pay() method isn’t accidentally re-exposed through the adapter. With private inheritance, external code calling adapter.pay(...) fails to compile — only charge() is visible. That hiding is the whole reason to prefer it over public inheritance here, and it’s a subtlety worth naming in an interview.
An adapter must normalize every way the adaptee can fail, not just the documented ones. If StriponAdapter only caught StriponError and let a TypeError/network exception propagate, the abstraction leaks: your client now has to catch vendor-specific exceptions it was never supposed to know about. Each tab here ends its try with a catch-all that maps to "unknown_error" precisely so a raw vendor failure can never escape through the PaymentGateway port.
A two-way adapter implements two target interfaces so two clients each see the API they expect over a single shared object. Rare, but it’s a slick answer if an interviewer asks “what if both systems need to call each other through the bridge?”
When to Use It
- A third-party or legacy class has the behavior you need but the wrong interface, and you can’t change it.
- You want to isolate vendor churn: swapping Stripon → PayBuddy touches one adapter, not the call sites. (This is the Ports & Adapters / hexagonal philosophy — your domain defines the port, infrastructure provides adapters.)
- You need to make several existing classes with divergent interfaces present a single uniform interface to your client.
When NOT to Use It (and the pitfalls)
Don’t adapt an interface you control. If both Target and Adaptee are your code, an adapter is a code smell — you’re papering over a design you could simply fix. Refactor the interface instead. Adapters earn their keep only across a boundary you can’t cross.
- Adapter chains / “adapter of an adapter.” When you find yourself adapting an adapter, the real problem is that your
Targetport is wrong. Redesign the port. - Leaky adapters. If vendor-specific types (Stripon’s
outcomestring, raw HTTP status, or a vendor exception) escape through yourTarget’s return type, the abstraction has failed — clients are now coupled to the vendor again. The whole point is that nothing of the adaptee escapes. - Adapter doing real work. An adapter that adds caching, retries, logging, or business rules has stopped being an adapter — that’s a Decorator or a Proxy. Keep adapters thin: translate shape, units, and errors; nothing else.
- Over-abstraction up front. Wrapping every library “just in case you swap it” is speculative generality. Add the adapter when a second implementation or a known migration makes the seam real.
Relationship to SOLID
- Dependency Inversion (D): the client depends on the
PaymentGatewayabstraction it owns, not on the concrete vendor SDK. The adapter is exactly the mechanism that lets a high-level policy avoid depending on a low-level detail. - Open/Closed (O): adding PayBuddy meant adding a new adapter class, not editing
checkout()orStriponAdapter. The system is open for extension, closed for modification. - Single Responsibility (S): the adapter’s one reason to change is “the vendor’s interface changed.” Translation logic lives in one place instead of smeared across call sites — but beware violating S by letting the adapter accrete retries/logging.
- Interface Segregation (I): your
Targetshould be the minimal interface the client needs, not a mirror of the vendor’s bloated API.
Adapter vs Facade vs Decorator vs Proxy
These structural patterns all wrap something, and interviewers love to test whether you can tell them apart. The discriminator is intent, not shape.
| Adapter | Facade | Decorator | Proxy | |
|---|---|---|---|---|
| Intent | Convert an interface to a different one the client expects | Simplify a complex subsystem behind one easy entry point | Add responsibilities to an object dynamically | Control access to an object without changing its interface |
| Interface vs wrapped | Different by design — that’s the point | Usually new/simpler; hides many classes | Same as the wrapped object (so it’s transparent) | Same as the wrapped object |
| How many objects wrapped | Typically one adaptee | A whole subsystem of classes | One component (stackable/recursive) | One real subject |
| Adds behavior? | No — only translates | No — only routes/simplifies | Yes — that’s its job | Maybe (lazy load, caching, auth checks) but keeps same contract |
| Tell in interview | ”interfaces don’t match, can’t change either side" | "this subsystem is painful to use" | "I want to layer features without subclass explosion" | "same interface, but I gate/defer/guard access” |
The crisp one-liners:
- Adapter changes an interface you can’t change to match one you need.
- Facade invents a simpler interface over a messy subsystem — even if you could call it directly.
- Decorator keeps the same interface and adds behavior (so decorators stack).
- Proxy keeps the same interface and controls access (lazy init, remoting, auth, caching) without altering what the contract means.
Same-interface ⇒ Decorator or Proxy. Different-interface ⇒ Adapter or Facade. Then ask: adding behavior? (Decorator) vs gating access? (Proxy); one mismatched class? (Adapter) vs a whole subsystem? (Facade). That two-question decision tree is the fastest way to answer “how is this different from X?” under interview pressure.