Back to the path
Level 3 intermediate 25 min #adapter#design-patterns#structural-patterns#solid#interface-design#lld

The Adapter Pattern: Making Incompatible Interfaces Talk

A rigorous, interview-grade treatment of the Adapter pattern: object vs class adapter, wrapping third-party payment SDKs behind a port you own, normalizing vendor error models, and the precise distinctions between Adapter, Facade, Decorator, and Proxy.

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.

Key idea

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

ParticipantRole
TargetThe interface your client codes against — the one you own. Often called a “port” in hexagonal architecture.
ClientCollaborates with objects conforming to Target. Has no knowledge of the adaptee.
AdapteeThe existing class with the incompatible-but-useful interface (the vendor SDK, legacy class).
AdapterImplements 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:

  1. 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::variant in C++) so a client can never read a stale field.
  2. 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:

  1. Unit conversioncents"49.99".
  2. Error-model translation — exceptions (Stripon) and status codes (PayBuddy), plus any unexpected throwable, all become a ChargeResult your client can branch on uniformly. The point is that nothing of the adaptee escapes — not its types, not its exceptions.
  3. Shape mapping — Stripon takes positional args while PayBuddy takes a keyed options object/dict; id vs ref both become transactionId. (In the C++ tab the shape contrast is createCharge(int, string, string) vs pay(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.

Watch out

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.

Tip

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)

Common pitfall

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 Target port is wrong. Redesign the port.
  • Leaky adapters. If vendor-specific types (Stripon’s outcome string, raw HTTP status, or a vendor exception) escape through your Target’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 PaymentGateway abstraction 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() or StriponAdapter. 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 Target should 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.

AdapterFacadeDecoratorProxy
IntentConvert an interface to a different one the client expectsSimplify a complex subsystem behind one easy entry pointAdd responsibilities to an object dynamicallyControl access to an object without changing its interface
Interface vs wrappedDifferent by design — that’s the pointUsually new/simpler; hides many classesSame as the wrapped object (so it’s transparent)Same as the wrapped object
How many objects wrappedTypically one adapteeA whole subsystem of classesOne component (stackable/recursive)One real subject
Adds behavior?No — only translatesNo — only routes/simplifiesYes — that’s its jobMaybe (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.
Key idea

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.

Assessment

Pass mark: 70% on the concept questions unlocks the next lesson.

1. You control both the `Target` interface and the `Adaptee` class — both are your own code. A teammate proposes an Adapter to bridge their mismatched method names. What is the best response?

2. In the C++ example, `PayBuddyAdapter` inherits `PaymentGateway` publicly and `PayBuddyClient` privately. What does the *private* inheritance of the adaptee achieve?

3. Which of the following responsibilities legitimately belong inside an Adapter for the payment-gateway port? (Select all that apply.) (select all)

4. A reviewer notes that `StriponAdapter` only catches `StriponError`, letting any other exception (e.g., a network or parsing error) propagate to the client. Why is this a defect rather than a style choice?

Design problem 5

Your app currently sends transactional email through SendGrid's SDK, called directly from ~30 places. Leadership wants the option to switch to Amazon SES (and possibly a third provider) without a large rewrite, and wants delivery failures handled uniformly. Design an Adapter-based solution. Define the `Target` port, describe two concrete adapters with the specific translations each performs, explain how failures are normalized, and state one pitfall you will deliberately avoid. Provide a short code sketch in any one language.