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()andReadable.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
- Designing the target interface from the first vendor. If
PaymentGateway.chargetakesamount_cents, the second adapter ends up converting into the first vendor's units. Design the interface in your domain's terms. - Leaking vendor errors. Re-throwing the SDK's error object lets vendor types escape through the adapter. Map errors to your own.
- 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.
- Unit bugs. Currency, time zones and cents vs units are where adapters break. Test conversions explicitly, including rounding (
Math.ceilhere never undercharges).
Exercises
- Add a third provider, a bank transfer QR API that returns a payment URL and confirms later through a webhook. How does
ChargeResultneed to change, and does it break the existing adapters? - Write unit tests for
CardGatewayAdapterwith a fake SDK that declines, fails withapi_errorand succeeds. Check the cents conversion for 59,000đ at a rate of 26,000. - Add a
refund(transactionId, amountVnd)method toPaymentGatewayand implement it in both adapters. - 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.