InterviewKata
Design patterns · 06 of 26 · Fitting objects together

Adapter

An adapter sits between code and an object it can't use directly, translating calls and answers so neither side has to change. This page plugs two payment providers with clashing APIs into Pedal's checkout, and follows a charge through each adapter.

1. The pain

Pedal's checkout was written against one idea of payment: charge a rider some pence, get back "charged" with a reference or "declined" with a reason. Then finance signed two payment providers (their names here are invented). Lumen Pay wants pounds as a string like "2.80" and answers with a status dictionary. Orbit Cards wants minor units plus a currency code, and throws an exception when it declines.

Calling the SDKs directly spreads their quirks through checkout, refunds and monthly memberships: unit conversions in three places, two styles of error handling side by side, and every future provider adding a third. We can't change the SDKs; we don't want to change checkout.

2. The idea in one picture

A UK plug doesn't fit a European socket. You don't rewire the hairdryer or the hotel; you carry a travel adapter. One side fits the socket, the other fits the plug, and it does nothing but translate between them.

Adapter wraps an object whose interface doesn't fit and presents the interface the client expects. Checkout keeps calling charge(rider, pence); the Lumen adapter turns that into make_payment("2.80", rider) and turns the status dictionary back into a result. The Orbit adapter passes pence through as minor units and turns an exception into a "declined" result.

An adapter translates and nothing more: units, names, argument order, error styles. If it starts adding business rules, it's become something else. The version here holds the SDK as a field (an object adapter); a class adapter inherits from it instead, which few languages make pleasant.

3. The roles

RoleIts jobIn Pedal
TargetThe interface the client wants to use.PaymentGateway.charge(rider, pence)
AdapteeThe existing object with the wrong interface.LumenPaySdk, OrbitCardsSdk
AdapterImplements the target by translating calls to the adaptee.LumenAdapter, OrbitAdapter
ClientUses only the target.checkout()

4. Before and after

Checkout both ways (Python sketch; the full program is in section 5).

Without the pattern

def checkout(provider, rider, pence):
    if provider == "lumen":
        r = lumen.make_payment(
            f"{pence // 100}.{pence % 100:02d}",
            rider)
        ok = r["status"] == "ok"
    else:
        try:
            orbit.debit(rider, pence, "GBP")
            ok = True
        except OrbitError:
            ok = False
    # refunds() repeats all of this

With it

class LumenAdapter(PaymentGateway):
    def charge(self, rider, pence):
        r = self.sdk.make_payment(
            f"{pence // 100}.{pence % 100:02d}",
            rider)
        return ChargeResult(
            r["status"] == "ok",
            r.get("ref") or r["reason"])

def checkout(gateway, rider, pence):
    return gateway.charge(rider, pence)
The next changeWithout the patternWith it
Sign a third providerA new branch in checkout, refunds and membershipsA new adapter; nothing else changes
Lumen's SDK renames a fieldFind every place that reads its dictionaryOne line in LumenAdapter
Test checkout without a real providerMock the SDK's exact quirksA tiny fake PaymentGateway

Each provider's strangeness lives in exactly one class, next to the SDK it explains.

5. The code

Two provider SDKs we can't change, one adapter each implementing PaymentGateway, and a checkout() written once. The last two calls are declined: one by a status field, one by an exception.

from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass


# ---- What Pedal's checkout wants: pence in, a clear result out. ----
@dataclass
class ChargeResult:
    ok: bool
    detail: str          # a reference when ok, a reason when not


class PaymentGateway(ABC):
    @abstractmethod
    def charge(self, rider: str, pence: int) -> ChargeResult: ...


# ---- Two providers' SDKs, as they come. We can't change them. ----
class LumenPaySdk:
    """Takes pounds as a string, answers with a status dictionary."""
    def __init__(self) -> None:
        self.next_ref = 1001

    def make_payment(self, amount: str, customer: str) -> dict[str, str]:
        if float(amount) > 500:
            return {"status": "declined", "reason": "insufficient funds"}
        ref, self.next_ref = f"LP-{self.next_ref}", self.next_ref + 1
        return {"status": "ok", "ref": ref}


class OrbitError(Exception):
    pass


class OrbitTxn:
    def __init__(self, txn_id: int) -> None:
        self.txn_id = txn_id


class OrbitCardsSdk:
    """Takes minor units and a currency code, raises on failure."""
    def __init__(self) -> None:
        self.count = 0

    def debit(self, account: str, minor_units: int, currency: str) -> OrbitTxn:
        if minor_units > 50_000:
            raise OrbitError("limit exceeded")
        self.count += 1
        return OrbitTxn(self.count)


# ---- Adapters: each makes one SDK look like a PaymentGateway. ----
class LumenAdapter(PaymentGateway):
    def __init__(self, sdk: LumenPaySdk) -> None:
        self.sdk = sdk

    def charge(self, rider: str, pence: int) -> ChargeResult:
        reply = self.sdk.make_payment(f"{pence // 100}.{pence % 100:02d}", rider)    # pence -> "2.80"
        if reply["status"] == "ok":
            return ChargeResult(True, reply["ref"])
        return ChargeResult(False, reply["reason"])


class OrbitAdapter(PaymentGateway):
    def __init__(self, sdk: OrbitCardsSdk) -> None:
        self.sdk = sdk

    def charge(self, rider: str, pence: int) -> ChargeResult:
        try:
            txn = self.sdk.debit(rider, pence, "GBP")                               # pence are already minor units
            return ChargeResult(True, f"ORB-{txn.txn_id}")
        except OrbitError as e:                                                    # exception -> result
            return ChargeResult(False, str(e))


# ---- The client: written once, against the interface it wanted. ----
def checkout(gateway: PaymentGateway, name: str, rider: str, pence: int) -> str:
    r = gateway.charge(rider, pence)
    return f"checkout via {name}: {pence}p " + (f"charged, ref {r.detail}" if r.ok else f"declined ({r.detail})")


if __name__ == "__main__":
    gateways: list[tuple[str, PaymentGateway]] = [("lumen", LumenAdapter(LumenPaySdk())), ("orbit", OrbitAdapter(OrbitCardsSdk()))]
    for name, gw in gateways:
        print(checkout(gw, name, "rider-7", 280))
    for name, gw in gateways:
        print(checkout(gw, name, "rider-7", 99_999))
// ---- What Pedal's checkout wants: pence in, a clear result out. ----
interface ChargeResult { ok: boolean; detail: string; }   // detail: a reference when ok, a reason when not

interface PaymentGateway { charge(rider: string, pence: number): ChargeResult; }

// ---- Two providers' SDKs, as they come. We can't change them. ----
class LumenPaySdk {   // takes pounds as a string, answers with a status object
  private nextRef = 1001;
  makePayment(amount: string, customer: string): Record<string, string> {
    void customer;
    if (Number(amount) > 500) return { status: "declined", reason: "insufficient funds" };
    return { status: "ok", ref: `LP-${this.nextRef++}` };
  }
}

class OrbitError extends Error {}
class OrbitTxn { constructor(readonly txnId: number) {} }

class OrbitCardsSdk {   // takes minor units and a currency code, throws on failure
  private count = 0;
  debit(account: string, minorUnits: number, currency: string): OrbitTxn {
    void account; void currency;
    if (minorUnits > 50_000) throw new OrbitError("limit exceeded");
    return new OrbitTxn(++this.count);
  }
}

// ---- Adapters: each makes one SDK look like a PaymentGateway. ----
class LumenAdapter implements PaymentGateway {
  constructor(private readonly sdk: LumenPaySdk) {}
  charge(rider: string, pence: number): ChargeResult {
    const reply = this.sdk.makePayment(`${Math.floor(pence / 100)}.${String(pence % 100).padStart(2, "0")}`, rider);   // pence -> "2.80"
    return reply.status === "ok" ? { ok: true, detail: reply.ref } : { ok: false, detail: reply.reason };
  }
}

class OrbitAdapter implements PaymentGateway {
  constructor(private readonly sdk: OrbitCardsSdk) {}
  charge(rider: string, pence: number): ChargeResult {
    try {
      const txn = this.sdk.debit(rider, pence, "GBP");                    // pence are already minor units
      return { ok: true, detail: `ORB-${txn.txnId}` };
    } catch (e) {
      if (e instanceof OrbitError) return { ok: false, detail: e.message };   // exception -> result
      throw e;
    }
  }
}

// ---- The client: written once, against the interface it wanted. ----
function checkout(gateway: PaymentGateway, name: string, rider: string, pence: number): string {
  const r = gateway.charge(rider, pence);
  return `checkout via ${name}: ${pence}p ` + (r.ok ? `charged, ref ${r.detail}` : `declined (${r.detail})`);
}

const gateways: [string, PaymentGateway][] = [["lumen", new LumenAdapter(new LumenPaySdk())], ["orbit", new OrbitAdapter(new OrbitCardsSdk())]];
for (const [name, gw] of gateways) console.log(checkout(gw, name, "rider-7", 280));
for (const [name, gw] of gateways) console.log(checkout(gw, name, "rider-7", 99_999));
import java.math.BigDecimal;
import java.util.LinkedHashMap;
import java.util.Map;

public class Main {
    // ---- What Pedal's checkout wants: pence in, a clear result out. ----
    record ChargeResult(boolean ok, String detail) {}   // detail: a reference when ok, a reason when not

    interface PaymentGateway { ChargeResult charge(String rider, int pence); }

    // ---- Two providers' SDKs, as they come. We can't change them. ----
    static class LumenPaySdk {   // takes pounds as a string, answers with a status map
        private int nextRef = 1001;
        Map<String, String> makePayment(String amount, String customer) {
            if (new BigDecimal(amount).compareTo(new BigDecimal(500)) > 0) return Map.of("status", "declined", "reason", "insufficient funds");
            return Map.of("status", "ok", "ref", "LP-" + nextRef++);
        }
    }

    static class OrbitException extends Exception {
        private static final long serialVersionUID = 1L;
        OrbitException(String message) { super(message); }
    }

    record OrbitTxn(int txnId) {}

    static class OrbitCardsSdk {   // takes minor units and a currency code, throws on failure
        private int count;
        OrbitTxn debit(String account, int minorUnits, String currency) throws OrbitException {
            if (minorUnits > 50_000) throw new OrbitException("limit exceeded");
            return new OrbitTxn(++count);
        }
    }

    // ---- Adapters: each makes one SDK look like a PaymentGateway. ----
    static class LumenAdapter implements PaymentGateway {
        private final LumenPaySdk sdk;
        LumenAdapter(LumenPaySdk sdk) { this.sdk = sdk; }
        public ChargeResult charge(String rider, int pence) {
            Map<String, String> reply = sdk.makePayment(String.format("%d.%02d", pence / 100, pence % 100), rider);   // pence -> "2.80"
            return reply.get("status").equals("ok") ? new ChargeResult(true, reply.get("ref")) : new ChargeResult(false, reply.get("reason"));
        }
    }

    static class OrbitAdapter implements PaymentGateway {
        private final OrbitCardsSdk sdk;
        OrbitAdapter(OrbitCardsSdk sdk) { this.sdk = sdk; }
        public ChargeResult charge(String rider, int pence) {
            try {
                OrbitTxn txn = sdk.debit(rider, pence, "GBP");                       // pence are already minor units
                return new ChargeResult(true, "ORB-" + txn.txnId());
            } catch (OrbitException e) {                                           // exception -> result
                return new ChargeResult(false, e.getMessage());
            }
        }
    }

    // ---- The client: written once, against the interface it wanted. ----
    static String checkout(PaymentGateway gateway, String name, String rider, int pence) {
        ChargeResult r = gateway.charge(rider, pence);
        return "checkout via " + name + ": " + pence + "p " + (r.ok() ? "charged, ref " + r.detail() : "declined (" + r.detail() + ")");
    }

    public static void main(String[] args) {
        Map<String, PaymentGateway> gateways = new LinkedHashMap<>();
        gateways.put("lumen", new LumenAdapter(new LumenPaySdk()));
        gateways.put("orbit", new OrbitAdapter(new OrbitCardsSdk()));
        for (Map.Entry<String, PaymentGateway> e : gateways.entrySet()) System.out.println(checkout(e.getValue(), e.getKey(), "rider-7", 280));
        for (Map.Entry<String, PaymentGateway> e : gateways.entrySet()) System.out.println(checkout(e.getValue(), e.getKey(), "rider-7", 99_999));
    }
}

Output (the same in all three languages)

checkout via lumen: 280p charged, ref LP-1001
checkout via orbit: 280p charged, ref ORB-1
checkout via lumen: 99999p declined (insufficient funds)
checkout via orbit: 99999p declined (limit exceeded)

6. Try it

Follow one charge through an adapter

Choose a provider and an amount, and watch the call change shape on the way in and the answer change shape on the way out.

Lumen declines above £500.00; Orbit throws above 50,000 minor units. Checkout sees the same kind of result either way.

7. When not to use it

What you get

  • Client code stays put: it keeps the interface it was written for.
  • Quirks are contained: one class per foreign API.
  • Swappable providers: a new adapter, no edits elsewhere.
  • Testable: fake the target, not the SDK.

What you pay

  • One more layer: each call passes through a translator.
  • Lowest common denominator: features one provider has and the target lacks are hidden.
  • Leaky translations: error codes, rounding and timeouts don't always map cleanly.
  • A class per adaptee: many small adapters to maintain.
If…Use instead
You want a simpler front for a whole subsystem08 Facade
You want to add behaviour, keeping the same interface07 Decorator
You control both sidesChange one of them to match; no adapter needed
You need to control access to an object with the right interface09 Proxy

8. How it goes wrong

Business logic in the adapterRetry rules, fraud checks or discounts don't belong in a translator. Keep them where the rest of the domain can see them.
Leaking the adaptee's typesIf charge() returns Orbit's transaction object, checkout depends on Orbit again. Translate the outputs too.
Swallowing errorsTurning every exception into "declined" hides outages. Map the expected declines; let real failures surface.
Designing the target around one providerIf the interface mirrors Lumen's quirks, every other adapter must fake them. Design the target from the client's needs.

9. Remember it and say it

Memory hook: "A travel plug"Same hairdryer, foreign socket: a small piece in between that only translates.
How this hook works: what “A travel plug” means

What the slogan means, phrase by phrase

“Same hairdryer”
Checkout, unchanged.
“foreign socket”
A provider's SDK with its own interface.
“only translates”
Units, names and errors, nothing more.

Why this picture fits: Nobody rewires a hairdryer for a holiday; they carry the cheap thing that fits both.

The story → the concept

The tripThe code
The hairdryercheckout()
The hotel socketLumenPaySdk
The travel adapterLumenAdapter
A second countryOrbitAdapter

Watch it on an example

  1. Checkout calls charge("rider-7", 280).
  2. The Lumen adapter sends make_payment("2.80", "rider-7").
  3. It turns {"status": "ok", "ref": "LP-1001"} into a charged result.

The trap it saves you from

Letting a provider's units and error style leak into every caller.

It still works when…

Yes: wrapping legacy code, third-party SDKs, and old interfaces after a redesign.

In an interview or code review

10. Check yourself

What does an adapter change?

It translates calls and results; behaviour stays the provider's.

Orbit throws on decline; checkout expects a result. Where is that translated?

The adapter maps the adaptee's error style to the target's.

Which belongs in an adapter?

Translation only; rules belong in the domain.

Adapter or decorator: logging around a gateway with the same interface?

Same interface plus behaviour is a decorator.

charge() returns Orbit's own transaction type. What's wrong?

Translate outputs as well as inputs.

11. Go deeper

ResourceWhy
Architecture styles 03: HexagonalPorts and adapters: the same idea as an architecture.
08 FacadeThe neighbour pattern people confuse it with.
Python io.TextIOWrapperA standard-library adapter: bytes stream in, text stream out.
Wikipedia: Adapter patternObject and class adapters.