Adapter lets two pieces of code work together when their interfaces do not match. Your code expects one shape; a library, a legacy module or a partner API offers another. Instead of changing either side, you write a small object in between that implements the interface your code expects and translates each call into the one the other side understands.

The physical version is the travel plug adapter: your laptop charger is fine, the wall socket is fine, they just do not fit. A laptop power brick is a second example: it turns 220 V AC from the wall into the 19 V DC the laptop expects.

Difficulty: ★★☆☆☆. Popularity: ★★★★☆. It shows up wherever you integrate with something you do not control, which in backend work is most days.

The problem: vendor APIs leaking into business code

A subscription service renews plans every month. It started with one card provider, and the billing code was written directly against its SDK. Then the business added a local e-wallet, which most customers prefer. The two SDKs could not be more different:

// Vendor 1: a global card SDK. Callback-based, amounts in US cents.
class GlobalCardSdk {
  createCharge(
    params: { amount_cents: number; currency: "USD"; description: string; receipt_email: string },
    callback: (error: { code: "card_declined" | "api_error" } | null, charge?: { id: string; paid: boolean }) => void,
  ) {
    setTimeout(() => {
      if (params.amount_cents > 50_000) callback({ code: "card_declined" });
      else callback(null, { id: `ch_${params.description}`, paid: true });
    }, 10);
  }
}

// Vendor 2: a local e-wallet SDK. Promise-based, amount as a VND string, numeric result codes.
class LocalWalletSdk {
  async requestPayment(body: { partnerRef: string; amount: string; msisdn: string }) {
    if (!/^0\d{9}$/.test(body.msisdn)) return { resultCode: 1001, message: "Invalid phone number" };
    if (Number(body.amount) > 500_000) return { resultCode: 1006, message: "Insufficient balance" };
    return { resultCode: 0, transId: `W-${body.partnerRef}`, message: "Success" };
  }
}

Written directly, the renewal function has to know both:

async function renew(sub: { id: string; amountVnd: number; method: "card" | "wallet"; email: string; phone?: string }) {
  if (sub.method === "card") {
    const cents = Math.ceil((sub.amountVnd / 26_000) * 100);
    return new Promise((resolve, reject) =>
      new GlobalCardSdk().createCharge(
        { amount_cents: cents, currency: "USD", description: `sub-${sub.id}`, receipt_email: sub.email },
        (err, charge) => (err ? reject(err) : resolve(charge!.id)),
      ),
    );
  }
  const res = await new LocalWalletSdk().requestPayment({ partnerRef: `sub-${sub.id}`, amount: String(sub.amountVnd), msisdn: sub.phone! });
  if (res.resultCode !== 0) throw new Error(res.message);
  return res.transId;
}

Currency conversion, callback wrapping, error codes and field names of two vendors are tangled with the renewal logic, and a hard-coded exchange rate is sitting in the middle. A third provider makes it worse, and testing renewal means mocking two SDKs.

The pattern: define your interface, adapt vendors to it

Start from what your code needs, in your terms. This is the target interface:

export type ChargeRequest = { orderId: string; amountVnd: number; customer: { email: string; phone?: string } };

export type ChargeResult =
  | { status: "succeeded"; transactionId: string }
  | { status: "failed"; reason: "insufficient-funds" | "rejected" | "unavailable" };

export interface PaymentGateway {
  readonly name: string;
  charge(request: ChargeRequest): Promise<ChargeResult>;
}

The business code is written against that interface only:

const PRICES = { monthly: 59_000, yearly: 590_000 } as const;

export class SubscriptionBilling {
  constructor(private readonly gateway: PaymentGateway) {}

  async renew(sub: { id: string; plan: keyof typeof PRICES; email: string; phone?: string }) {
    const result = await this.gateway.charge({
      orderId: `sub-${sub.id}`,
      amountVnd: PRICES[sub.plan],
      customer: { email: sub.email, phone: sub.phone },
    });
    return result.status === "succeeded"
      ? `renewed ${sub.plan} via ${this.gateway.name}: ${result.transactionId}`
      : `renewal failed via ${this.gateway.name}: ${result.reason}`;
  }
}

Then one adapter per vendor implements PaymentGateway and wraps the vendor object (the adaptee). Each adapter is where all of that vendor's quirks live:

export class CardGatewayAdapter implements PaymentGateway {
  readonly name = "card";

  constructor(
    private readonly sdk: GlobalCardSdk,
    private readonly vndPerUsd: () => number, // injected: rates change, never hard-code them
  ) {}

  charge({ orderId, amountVnd, customer }: ChargeRequest): Promise<ChargeResult> {
    const amountCents = Math.ceil((amountVnd / this.vndPerUsd()) * 100);
    return new Promise((resolve) => {
      this.sdk.createCharge(
        { amount_cents: amountCents, currency: "USD", description: orderId, receipt_email: customer.email },
        (error, charge) => {
          if (error) resolve({ status: "failed", reason: error.code === "card_declined" ? "rejected" : "unavailable" });
          else resolve({ status: "succeeded", transactionId: charge!.id });
        },
      );
    });
  }
}

export class WalletGatewayAdapter implements PaymentGateway {
  readonly name = "wallet";

  constructor(private readonly sdk: LocalWalletSdk) {}

  async charge({ orderId, amountVnd, customer }: ChargeRequest): Promise<ChargeResult> {
    if (!customer.phone) return { status: "failed", reason: "rejected" };
    const response = await this.sdk.requestPayment({
      partnerRef: orderId,
      amount: String(amountVnd),
      msisdn: customer.phone,
    });
    switch (response.resultCode) {
      case 0:
        return { status: "succeeded", transactionId: response.transId! };
      case 1006:
        return { status: "failed", reason: "insufficient-funds" };
      default:
        return { status: "failed", reason: "rejected" };
    }
  }
}

The two adapters translate four different things, which is typical:

Concern Card SDK Wallet SDK Your interface
Async style Callback Promise Promise
Amount US cents, integer VND, string VND, number
Customer identity Receipt email Phone number (msisdn) customer object
Errors error.code strings numeric resultCode reason union

Now billing works with either, without knowing which:

const card = new SubscriptionBilling(new CardGatewayAdapter(new GlobalCardSdk(), () => 26_000));
const wallet = new SubscriptionBilling(new WalletGatewayAdapter(new LocalWalletSdk()));

console.log(await card.renew({ id: "42", plan: "monthly", email: "an@example.com" }));
// renewed monthly via card: ch_sub-42
console.log(await wallet.renew({ id: "43", plan: "monthly", email: "binh@example.com", phone: "0901234567" }));
// renewed monthly via wallet: W-sub-43
console.log(await wallet.renew({ id: "44", plan: "yearly", email: "chi@example.com", phone: "0907654321" }));
// renewal failed via wallet: insufficient-funds

The client never sees an adapter. It holds a PaymentGateway and calls charge. That is the defining property of the pattern: if your calling code has to call a method named after the adapter (payWithVisaUsing(adapter)), you have written a converter function, which is fine, but you have not decoupled the caller from the vendor.

Adapters you already use

  • util.promisify(fs.readFile) adapts a callback API to promises. The card adapter above does the same thing by hand.
  • Readable.fromWeb() and Readable.toWeb() adapt between Node streams and Web streams.
  • ORMs adapt one query interface to PostgreSQL, MySQL and SQLite drivers.
  • Logging libraries' "transports" adapt one log() call to files, stdout, or a SaaS API.
  • In React, a hook that wraps a non-React library (useMapbox, useChart) adapts an imperative API to a declarative one.

Adapters as a boundary

At the scale of a system, a set of adapters around an external dependency is called an anti-corruption layer: a boundary that keeps the vendor's model (its names, units, error codes, quirks) from spreading into yours. It is also the "ports and adapters" in hexagonal architecture: PaymentGateway is a port your domain defines, and each vendor integration is an adapter plugged into it.

The payoff is concrete. When a vendor releases v2 of its SDK, you change one adapter. When a vendor shuts down, you write one new adapter. When you test billing, you pass a three-line fake PaymentGateway.

When to use it, and when not to

Use it when:

  • You integrate a third-party or legacy API whose interface you cannot change.
  • Several providers do the same job with different APIs.
  • You want vendor types to stop at a boundary.

Skip it when:

  • You control both sides. Change one of them to match the other.
  • The vendor API already is the interface you want. Wrapping it in an identical interface is ceremony; introduce the adapter the day a second vendor appears.

Common pitfalls

  1. Designing the target interface from the first vendor. If PaymentGateway.charge takes amount_cents, the second adapter ends up converting into the first vendor's units. Design the interface in your domain's terms.
  2. Leaking vendor errors. Re-throwing the SDK's error object lets vendor types escape through the adapter. Map errors to your own.
  3. Hidden business logic. Adapters translate; they should not decide prices or retry policies. Keep them thin enough that a reviewer can check them against the vendor docs.
  4. Unit bugs. Currency, time zones and cents vs units are where adapters break. Test conversions explicitly, including rounding (Math.ceil here never undercharges).

Exercises

  1. Add a third provider, a bank transfer QR API that returns a payment URL and confirms later through a webhook. How does ChargeResult need to change, and does it break the existing adapters?
  2. Write unit tests for CardGatewayAdapter with a fake SDK that declines, fails with api_error and succeeds. Check the cents conversion for 59,000đ at a rate of 26,000.
  3. Add a refund(transactionId, amountVnd) method to PaymentGateway and implement it in both adapters.
  4. Find a place in a codebase you work on where a vendor's response type is used outside the module that calls the vendor. Sketch the adapter that would stop it.