Adapter

For two years there was one payment provider. Its client was called from nineteen files: checkout, subscription renewal, retries, refunds, the admin “charge again” button. Every one of them called the vendor directly, and every one of them passed the amount a little differently, because nobody had ever agreed on a shape.

Then the company started selling into a country the provider did not cover, and legal signed a contract with a second gateway for those customers. Both gateways now had to run, side by side, chosen per order.

Here is what one of those nineteen call sites looked like before:

// checkout.js, and eighteen other files, each a little different
const res = await stripeish.charges.create({
  amount: order.totalCents,        // integer cents
  source: order.card.token,
  currency: order.currency,
});
if (res.status !== "succeeded") throw new Error(res.failure_message);

And here is the second provider it now had to share the floor with:

const res = await adyenish.payments({
  value: { amount: order.totalCents / 100, currency: order.currency }, // decimal, not cents
  paymentMethod: order.card,
});
if (res.resultCode !== "Authorised") throw new Error(res.refusalReason);

Different method name. Amount in cents on one, decimal on the other. Success is "succeeded" here and "Authorised" there. The error hides in failure_message versus refusalReason. Now picture an if (order.country === "…") choosing between those two blocks, copied into nineteen files, and picture the on-call engineer six months later trying to work out why some refunds double-charge.

You never had a payments layer. You had nineteen call sites hard-wired to one vendor, and now you need two. The fix is an adapter, and once you see it you will notice you have been writing them for years without the name.

The interface you speak and the interface you get

Strip it down. Your code wants to call some interface: method names, argument shapes, return shapes, the shape of an error, sometimes event names. The thing on the other side offers a different interface. And you cannot change either one. The provider’s SDK is theirs. The nineteen call sites are load-bearing and owned by four teams. Both sides are fixed.

An adapter is the wrapper that sits between them. It exposes the interface your code expects, and inside, it calls the incompatible thing, reshaping the arguments on the way in and the results on the way back out.

your appinterface Acharge(order)adapterreshape argumentsreshape the resultmap the error shapevendor SDKinterface Bpayments.create()calltranslated callraw resultyour shapethe adapter is the only file that knows the vendor’s method names and shapes
Your code speaks interface A. The vendor offers interface B. The adapter is the only code that knows both, translating each call and each result across the gap.

First decide what interface your app wants to speak. This is the target, and it is a design choice, not a translation of the vendor’s docs. For payments, the whole app really only needs “charge this order, tell me if it worked”:

// the target interface, in plain words:
//   charge(order) -> Promise<{ id, status: "ok" | "declined", error?: string }>

Now each provider gets a small wrapper that satisfies exactly that:

// adapters/stripeish.js
export function stripeGateway(client) {
  return {
    async charge(order) {
      const res = await client.charges.create({
        amount: order.totalCents,
        source: order.card.token,
        currency: order.currency,
      });
      return res.status === "succeeded"
        ? { id: res.id, status: "ok" }
        : { id: res.id, status: "declined", error: res.failure_message };
    },
  };
}
// adapters/adyenish.js
export function adyenGateway(client) {
  return {
    async charge(order) {
      const res = await client.payments({
        value: { amount: order.totalCents / 100, currency: order.currency },
        paymentMethod: order.card,
      });
      return res.resultCode === "Authorised"
        ? { id: res.pspReference, status: "ok" }
        : { id: res.pspReference, status: "declined", error: res.refusalReason };
    },
  };
}

The cents-versus-decimal conversion, the two different success words, the two different error fields: all of it is now trapped inside two small files. Every one of those nineteen call sites collapses to the same three lines, and none of them names a vendor:

const gateway = gatewayForCountry(order.country); // returns one of the adapters
const result = await gateway.charge(order);
if (result.status === "declined") flagDecline(order, result.error);

That is the adapter pattern, start to finish. Two components that were never designed to fit, made to fit, without editing either one.

In JavaScript it is an object, not a class hierarchy

Open the classic pattern catalogue and the adapter is drawn as a class: class StripeAdapter implements PaymentGateway, holding a reference to the adaptee, overriding each method of a target interface it formally declares. That framing comes from languages where “satisfies an interface” is a compile-time contract you have to spell out.

JavaScript does not work like that, and the two adapters above show it. Each is a factory function that returns a plain object. No class, no new, no implements. The “interface” is a shape the object happens to have, checked by nothing but the fact that the caller uses it and it works. That is duck typing, and it is why an adapter here is usually the lightest thing in the file.

Reach for an actual class only when an adapter needs its own per-instance state that a closure would make awkward: a persistent connection it opens lazily, a running sequence number, a buffer it drains. Most adapters have none of that. They take a client in, hand a reshaped object back, and hold nothing.

The travel plug, and where the analogy stops

The everyday picture people reach for is the travel adapter, and it is a good one. Your laptop charger has one plug shape. The wall in the hotel has a different socket shape. The little block in your bag makes the two meet, and crucially, it changes nothing about the charger or the wall. Both stay exactly as they were built. The adapter absorbs the whole mismatch itself.

your applianceplug: shape Aadaptershape A inshape B outthe wallsocket: shape Bit changes the shape of the connection, not the power flowing through it
Same power flowing through. The adapter accepts one plug shape and presents another, so neither the appliance nor the wall has to change.

That last line is where the analogy earns its keep, and also where it warns you. A plug adapter changes the shape. It does not change the voltage. Plug a 120-volt appliance into a 230-volt socket through a shape-only adapter and you get smoke, because the adapter promised a fit it could not actually deliver.

Code adapters have the same failure. If your target interface promises something the underlying provider genuinely cannot do, the adapter cannot translate its way out of that. It has to either build the missing capability for real or admit the mismatch. Say your PaymentGateway interface promises safe retries via an idempotency key, and one provider has no such concept. The adapter cannot fake it with a rename. It has to actually store keys and dedupe, which is real work living in the adapter, or the interface is lying to every caller. Translating a shape is cheap. Manufacturing a capability is not, and pretending you did is the voltage mistake.

One interface, many providers

Swapping one vendor for another is the small win. The bigger one shows up the moment you have more than one provider at a time, which is exactly the situation that started this lesson.

Define the interface once. Write one adapter per provider. Now the core of your app depends only on the interface, and the providers hang off the edge, interchangeable:

your corecalls charge(order)PaymentGateway interface · charge(order)stripe adapteractiveadyen adapterEU ordersfake adaptertestsstripe SDKadyen SDKin-memory mapone interface, interchangeable adapters; the core binds to whichever is configured
The core binds to one interface. Each provider is a separate adapter behind it. Adding or replacing a provider is one new adapter and the core never notices.

Notice the third column. Once every provider is reachable through the same interface, a fake provider is just one more adapter, and it is the cheapest reliable test you will ever write. Your checkout tests run against an in-memory gateway that never touches the network, returns whatever result the test needs, and never charges a real card. That, not vendor-swapping, is the benefit you will feel every single day.

The other thing you get for free is choice at runtime. Because both providers answer to the identical shape, routing between them, or falling back from one to another when the first is down, is now trivial logic that lives above the adapters and knows nothing about either vendor:

async function chargeWithFallback(order, gateways) {
  for (const gateway of gateways) {
    const result = await gateway.charge(order);
    if (result.status === "ok") return result;
  }
  return { status: "declined", error: "all gateways failed" };
}

You could not write that loop against the raw SDKs. Their return shapes do not line up, so there is nothing to loop over. The adapters made them uniform, and uniformity is what makes fallbacks and routing possible at all.

This exact move powers a lot of code you have used. An SDK that lets you switch model providers with a one-line change is a stack of adapters under the hood: each provider’s wire format normalised to one chat()-shaped interface, which is the only reason calling a model feels the same no matter who serves it. Auth libraries do it for databases (one adapter per store, all answering to createUser, getUser, linkAccount). Object storage libraries do it for S3, GCS, and the rest. Every time a library says “works with any provider,” an adapter is doing the work.

The adapter you have already written a hundred times

Here is the one nobody calls an adapter, and everybody writes: turning a callback-based API into a promise-based one.

Old JavaScript APIs hand you results through a callback, usually error-first. Modern code wants await. Those are two different interfaces for the same capability, and the wrapper that bridges them is a textbook adapter:

// the incompatible interface: result comes back through a callback
legacyCache.get("session:42", (err, value) => {
  if (err) return handle(err);
  use(value);
});

// the interface you want: a promise you can await
const value = await get("session:42");

For error-first callbacks, you do not even write the wrapper by hand. Node ships the adapter factory:

import { promisify } from "node:util";

const get = promisify(legacyCache.get).bind(legacyCache);
const value = await get("session:42"); // now it is a promise

promisify is a generic adapter: give it any function shaped (...args, (err, data) => …) and it returns one shaped (...args) => Promise. Same capability, converted interface.

callback shapeget(key, cb)cb(err, data)control returns through cbpromise shapeconst d = await get(key)value returns inlineerrors throwpromisify()wrap oncea generic adapter: same capability, callback interface converted to a promise interface
Same operation, two interfaces. The callback shape returns through a function you pass in; the promise shape returns inline and throws on error. The adapter wraps one into the other.

Two caveats keep this honest. promisify only understands the error-first convention. Anything with a different callback shape you wrap by hand with new Promise, which is still an adapter, just written out:

// browser geolocation splits success and error into two callbacks; promisify can't read that
function getPosition(options) {
  return new Promise((resolve, reject) => {
    navigator.geolocation.getCurrentPosition(resolve, reject, options);
  });
}

And do not adapt what already ships adapted. For files, do not promisify the old fs callbacks. Node already gives you node:fs/promises with the promise interface built in, and reaching past it to wrap the callback version is doing by hand what the platform already did for you. The prerequisite here is promises; the promisify lesson goes deeper on the conversion itself. The point for this lesson is only that you recognise the shape: it is an adapter.

Adapter or facade?

Both wrap something. That is why they get confused, and the confusion matters because building the wrong one leaves you with a wrapper that solves a problem you did not have.

The difference is intent. An adapter converts: it has a specific target interface it must match, dictated by the code that will call it, and its job is to make the wrapped thing satisfy that target exactly. A facade simplifies: it takes a subsystem with a large, awkward surface and hands you a smaller one, and it is free to design that surface however reads best, because nothing external is dictating the shape.

adapterconvert to a target interfaceshape Aadaptertarget Bfixedmust match B exactly, whatever B isthe caller chose the shape, not youfacadesimplify a subsystemstep onestep twostep threestep fourdoTask()any surfacecollapse the steps into whatever reads bestboth wrap; the adapter has a target it must fit, the facade designs its own
Same wrapping act, different job. An adapter must hit a target interface it did not choose. A facade invents a smaller surface over a mess it is free to shape.

The tell is whether a shape is being dictated to you. Wrapping a payment SDK so it satisfies the PaymentGateway your app already calls from nineteen places is an adapter, because those nineteen call sites are the target and you do not get to move them. Wrapping the same SDK to hide its five-step setup behind a pay() you invented from scratch is a facade, because you chose pay().

They stack, and this is the common real setup. You define a small, pleasant interface for your domain (that design act is the facade), and then each provider gets an adapter that makes it satisfy that interface. The PaymentGateway example is both: the interface is a facade you designed, and stripeGateway is an adapter to it.

Two-way adapters, and the cost of a thick one

Everything so far is a one-way adapter: your code calls through it to the vendor. Occasionally you need both directions. New code speaks the new interface, old code still speaks the old one, and both have to drive the same underlying object. An adapter that satisfies both interfaces at once, so either side can hold it and call the methods it knows, is a two-way adapter. They are rare and worth naming when you hit one, usually during a migration where you cannot convert every caller in a single change.

The more important thing to watch is thickness. An adapter is cheap when the two interfaces are close: rename a method, move a field, divide by a hundred. It gets expensive fast when they are far apart, and the distance is where adapters rot.

There is also a “do not build this yet” case, and it is the most common mistake with adapters specifically. If you have exactly one provider, will realistically only ever have one, and are not writing an adapter for the test-fake benefit, then an adapter is a layer of indirection guarding against a swap that will never come. That is speculative generality, and it reads as over-engineering. The honest trigger is the rule of three, bent a little: build the adapter when you get the second provider, or when the test fake pays for it now, not on the theory that a second provider might appear someday. An interface with a single implementation and no test seam is usually just a longer way to call one function.

See it swap

Two mock libraries do the same job with completely different surfaces. One has getUser(id) and returns firstName / lastName / email_address. The other has fetch_account(userId) and returns a nested full_name and contact.email. An adapter presents both through one loadUser(id) shape. Flip the active provider and watch the caller code and its output stay identical while the adapter absorbs the difference.

interactiveOne interface, two incompatible libraries behind it

The caller runs adapters[current].loadUser("u_42") and gets back the same four fields no matter which library is live. Left side changes wildly. Right side never moves. That invariance is the entire reason the pattern exists.

Summary

  • An adapter wraps a component whose interface does not match what your code expects, and exposes the interface your code does expect, reshaping arguments and results across the gap. It lets two things that were never designed to fit work together, without changing either one.
  • Reach for it when you cannot or should not change either side: a third-party SDK, a legacy module, an API many callers already depend on.
  • In JavaScript an adapter is usually a plain object or a factory returning one, not a class ... implements hierarchy. Duck typing carries the “interface.” Add a TypeScript type when you want the compiler to catch an adapter that drifts from its target. Reach for a class only when the adapter holds real per-instance state.
  • The travel-plug analogy is exact and it warns you: an adapter changes the shape of a connection, not the capability behind it. If your interface promises something the provider genuinely lacks, the adapter has to build it for real, not fake it with a rename.
  • The big payoff is normalising many providers behind one interface you own. The core stays provider-agnostic, a test fake becomes just another adapter, and uniform shapes are what make fallbacks and routing and provider-agnostic model calls possible. This is ports and adapters in miniature.
  • You already write adapters constantly: turning a callback API into a promise is one. promisify is a generic adapter for error-first callbacks; new Promise wraps the rest by hand. Do not adapt what already ships adapted (use node:fs/promises, not a promisified fs).
  • Adapter vs facade: both wrap, different intent. An adapter converts to a target interface someone else fixed. A facade simplifies a subsystem into a surface it invents. They stack: you design a facade-style interface, then write adapters to it.
  • Watch thickness. Unit and shape conversions hide bugs (test them directly), emulating a missing capability is real logic not translation, and flattening many providers can throw away the best one’s strengths (leave an escape hatch).
  • When to use: an interface mismatch you cannot edit away, or two-plus providers you want to keep interchangeable and testable. When to avoid: one provider you will never swap and are not faking in tests (that is speculative generality), or when you are really simplifying rather than converting (that is a facade).