← Design patterns
Composition Fitting an existing interface

Adapter

Make the interface fit. Keep the meaning.

A curbside-parking dashboard reads permits from two providers. One meter accepts a plate and curb zone, then returns an active, none or down state. A payment partner returns a status and a differently shaped permit record. The dashboard needs one question answered: “Is there a permit for this plate in this zone, and when does it end?” An adapter translates each existing API into that caller-owned contract.

TypeScriptGoRust The same adapters in each language.

Definition

An adapter converts the interface of an existing class into the interface its clients expect, so that classes with incompatible interfaces can work together.

After Gamma et al., Design Patterns (1994), Adapter

01Recognize the mismatch

The first provider is easy to call directly.

A single meter call is clear: lookup(plate, curb). Its response already names the state the dashboard needs, so a small dashboard can branch on that result without extra machinery. Here is that dashboard, as a fragment: the meter’s types appear in the full program below.

dashboard.ts
// The dashboard asks the legacy meter directly.
function showPermit(meter: LegacyMeter, plate: string, zone: string): string {
  const reply = meter.lookup(plate, zone);
  if (reply.state === 'active') {
    return `${plate} in ${zone}: permit through ${reply.end}`;
  }
  if (reply.state === 'none') return `${plate} in ${zone}: no active permit`;
  return `${plate} in ${zone}: check again, source unavailable`;
}

Then the payment partner arrives. It calls the area a district, the vehicle a vehicle, and the expiry paidThrough, and it answers with HTTP-shaped status codes. If the dashboard, sign view, and enforcement export each learn those fields and status codes, each caller now owns a piece of provider translation.

An adapter lets an existing object serve the interface a caller expects. Here each adapter accepts the dashboard’s lookupPermit(plate, zone) request, delegates to one meter API, and returns a permit, no permit, or a useful error.

A travel plug adapter makes the relationship easy to picture: it lets one connector fit another socket. In software, fitting method names is only part of the job. The wrapper must preserve what the source can actually say.

You already use adapters like this:

An existing shape, fitted to the contract a caller expects
A familiar adapterWhat it takesWhat it gives the caller
util.promisify(fn)A callback-last functionA function returning a Promise.
Readable.toWeb(stream)A Node.js streamA web ReadableStream (Node 17+).
useSyncExternalStore(subscribe, getSnapshot)Any outside storeReact’s rendering (React 18+).

02Translate at the boundary

Keep the question. Translate the answers.

Each adapter holds one source and implements the dashboard’s PermitLookup. The legacy adapter maps the meter’s registration, curb and end onto the dashboard’s plate, zone and expiresAt; the partner adapter maps vehicle, district and paidThrough. Each also translates outcomes: a source’s own signal for no permit becomes null, and a source that cannot answer becomes PermitUnavailable. The sources in this program are memory doubles whose replies are already decoded, so the program isolates the boundary mapping. This is the whole program the lab runs: two sources, their adapters, and the dashboard.

permits.ts
// The dashboard's contract: a permit, null for "no permit",
// or PermitUnavailable when the source cannot answer.
type Permit = { plate: string; zone: string; expiresAt: string };
class PermitUnavailable extends Error {}
interface PermitLookup {
  lookupPermit(plate: string, zone: string): Permit | null;
}

// Two existing APIs, as they already are.
type LegacyReply = {
  state: string;
  registration: string;
  curb: string;
  end: string;
};
interface LegacyMeter {
  lookup(plate: string, curb: string): LegacyReply;
}
type PartnerReply = {
  status: number;
  vehicle: string;
  district: string;
  paidThrough: string;
};
interface PartnerMeter {
  fetchPermit(plate: string, zone: string): PartnerReply;
}

// Each adapter holds one source and answers the dashboard's question with it.
class LegacyPermitAdapter implements PermitLookup {
  constructor(private source: LegacyMeter) {}
  lookupPermit(plate: string, zone: string): Permit | null {
    const reply = this.source.lookup(plate, zone);
    if (reply.state === 'none') return null;
    if (reply.state !== 'active') throw new PermitUnavailable(reply.state);
    const { registration, curb, end } = reply;
    return { plate: registration, zone: curb, expiresAt: end };
  }
}

class PartnerPermitAdapter implements PermitLookup {
  constructor(private source: PartnerMeter) {}
  lookupPermit(plate: string, zone: string): Permit | null {
    const reply = this.source.fetchPermit(plate, zone);
    if (reply.status === 404) return null;
    if (reply.status !== 200) {
      throw new PermitUnavailable(`status ${reply.status}`);
    }
    const { vehicle, district, paidThrough } = reply;
    return { plate: vehicle, zone: district, expiresAt: paidThrough };
  }
}

// The dashboard knows PermitLookup and no provider.
function showPermit(reader: PermitLookup, plate: string, zone: string): string {
  try {
    const permit = reader.lookupPermit(plate, zone);
    if (!permit) return `${plate} in ${zone}: no active permit`;
    return `${permit.plate} in ${permit.zone}: permit through ${permit.expiresAt}`;
  } catch {
    return `${plate} in ${zone}: check again, source unavailable`;
  }
}

// What each provider says about zone C7 today, in memory.
const legacy: LegacyMeter = {
  lookup: (plate) =>
    plate === 'KLM-204'
      ? { state: 'active', registration: plate, curb: 'C7', end: '18:30' }
      : { state: 'none', registration: '', curb: '', end: '' },
};
const blank = { vehicle: '', district: '', paidThrough: '' };
const partner: PartnerMeter = {
  fetchPermit: (plate) =>
    plate === 'KLM-204'
      ? { status: 200, vehicle: plate, district: 'C7', paidThrough: '18:30' }
      : plate === 'RTE-118'
        ? { status: 429, ...blank } // too many requests: try later
        : { status: 404, ...blank },
};

const partnerLookup = new PartnerPermitAdapter(partner);
const fromLegacy = showPermit(new LegacyPermitAdapter(legacy), 'KLM-204', 'C7');
const fromPartner = showPermit(partnerLookup, 'KLM-204', 'C7');
const noPermit = showPermit(partnerLookup, 'BXT-551', 'C7');
const rateLimited = showPermit(partnerLookup, 'RTE-118', 'C7');
console.log(`[legacy] ${fromLegacy}`);
console.log(`[partner] ${fromPartner}`);
console.log(`[partner] ${noPermit}`);
console.log(`[partner] ${rateLimited}`);
Who owns what
RoleResponsibilityWhat it handles
Caller → target“Find this permit.”The dashboard passes a plate and curb zone, then handles a permit, no permit, or failure.
Adapter → sourceTranslate the exchange.The adapter changes argument names, response fields, and provider outcomes into the dashboard contract.
Source → existing APIKeep its own shape.Each meter keeps its established API; it does not need to know how the dashboard is written.
One request contract; each adapter interprets its own source
The dashboard getsLegacy meterPayment partner
Foundactive + registration, curb and end200 + vehicle, district and paidThrough
No permitnone404
Source unavailabledown, or a failed callAny other status, or a failed call
Invalid response (production)Another state, or a row for another plate or curbA 200 for another vehicle or district, or with no time
What a production adapter addsInput checks, failed calls, and replies that answer another question

The same adapters, checked. An empty plate or zone is refused before the source is called. A call that fails is unavailable, like a meter that is down. And a successful reply must answer the question asked: the meter’s row and the partner’s record must name the requested plate and zone and carry an end time, or the reply is an invalid response, InvalidPermitResponse. A missing or mismatched success is never turned into absence: an adapter that cannot read an answer has not been told there is no permit. Only active can carry a permit; an unknown legacy state is invalid even when its row happens to match.

permits.ts
export type Permit = Readonly<{ plate: string; zone: string; expiresAt: string }>;
/** The source could not answer: ask again later. */
export class PermitUnavailable extends Error {}
/** The source answered, but not about this plate and zone, or not completely. */
export class InvalidPermitResponse extends Error {}

export interface PermitLookup {
	lookupPermit(plate: string, zone: string): Permit | null;
}

export type LegacyReply = { state: string; registration: string; curb: string; end: string };
export interface LegacyMeter {
	lookup(plate: string, curb: string): LegacyReply;
}
export type PartnerReply = {
	status: number;
	vehicle: string;
	district: string;
	paidThrough: string;
};
export interface PartnerMeter {
	fetchPermit(plate: string, zone: string): PartnerReply;
}

function requireInput(plate: string, zone: string) {
	if (!plate || !zone) throw new TypeError('plate and zone must not be empty');
}

export class LegacyPermitAdapter implements PermitLookup {
	constructor(private readonly source: LegacyMeter) {}

	lookupPermit(plate: string, zone: string): Permit | null {
		requireInput(plate, zone);
		let reply: LegacyReply;
		try {
			reply = this.source.lookup(plate, zone);
		} catch (cause) {
			throw new PermitUnavailable('legacy meter call failed', { cause });
		}
		if (reply.state === 'none') return null;
		if (reply.state === 'down') throw new PermitUnavailable('legacy meter is down');
		// Only "active" carries a permit; any other state is one we cannot read.
		if (reply.state !== 'active') throw new InvalidPermitResponse(`state ${reply.state}`);
		if (reply.registration !== plate || reply.curb !== zone || !reply.end) {
			throw new InvalidPermitResponse('row does not match the request');
		}
		return Object.freeze({ plate, zone, expiresAt: reply.end });
	}
}

export class PartnerPermitAdapter implements PermitLookup {
	constructor(private readonly source: PartnerMeter) {}

	lookupPermit(plate: string, zone: string): Permit | null {
		requireInput(plate, zone);
		let reply: PartnerReply;
		try {
			reply = this.source.fetchPermit(plate, zone);
		} catch (cause) {
			throw new PermitUnavailable('partner call failed', { cause });
		}
		if (reply.status === 404) return null;
		if (reply.status !== 200) throw new PermitUnavailable(`partner status ${reply.status}`);
		if (reply.vehicle !== plate || reply.district !== zone || !reply.paidThrough) {
			throw new InvalidPermitResponse('record does not match the request');
		}
		return Object.freeze({ plate, zone, expiresAt: reply.paidThrough });
	}
}

These sources return typed in-memory responses. A real integration also needs runtime decoding, authentication, deadlines, cancellation, diagnostics, and a target contract that represents waiting. The adapter cannot make a stale meter current or turn an unavailable provider into a reliable answer.

The direct-call version was a good first step. The adapter earns its place when several callers need the same permit meaning and the second provider would otherwise spread its vocabulary through them.

03Predict what the dashboard shows

Change one check. Follow one lookup.

Experiment

One dashboard, two meter APIs

permits.ts
// Lines 45–48 of the partner adapter become:
if (reply.status === 503) throw new PermitUnavailable('status 503');
if (!reply.paidThrough) return null;
Suppose the partner adapter checked its reply like this instead: the outage it knows about first, then !reply.paidThrough as no permit. The partner answers RTE-118 with status 429 and an empty record. What does the dashboard show for RTE-118?
Reading the TypeScriptimplements, parameter properties, and error classes

implements PermitLookup is checked when the code compiles and leaves nothing behind at run time. TypeScript’s types are structural, so anything with a matching lookupPermit fits the contract: the program’s in-memory sources are plain object literals that fit LegacyMeter and PartnerMeter the same way.

constructor(private source: LegacyMeter) {} is a parameter property: it declares the field and assigns it in one line. Node’s built-in type stripping (--experimental-strip-types, Node 22.6 and later) rejects it, because it is TypeScript that emits code; tsc, esbuild and the lab’s runner accept it.

class PermitUnavailable extends Error {} gives the failure a type the dashboard can test with instanceof, once the code targets ES2015 or later. The dashboard’s catch { without a binding (ES2019) catches anything, which is fine while the contract has one failure kind; the production version adds a second.

04Meet it in your language

Your standard library already adapts things.

In Node, util.promisify is an adapter you call by name: it takes a function whose last argument is a callback and returns one that returns a promise, the contract await expects. The bug is handing it a method without its object. promisify(client.lookup) receives the function alone and calls it with no this, so the first line that reads this.baseUrl throws, and the promise rejects with a TypeError.

promisify.ts
import { promisify } from 'node:util';

// A callback-style client, as many older Node SDKs are.
class MeterClient {
  constructor(private baseUrl: string) {}
  lookup(plate: string, done: (error: Error | null, state: string) => void) {
    const url = `${this.baseUrl}/permits/${plate}`;
    // The real client sends a request to url; this one answers at once.
    setImmediate(() => done(null, `active (${url})`));
  }
}

const client = new MeterClient('https://meters.example');

// Before: promisify receives the method alone. Called without its object,
// lookup runs with `this` undefined, and the promise rejects with a TypeError.
export const lookupBefore = promisify(client.lookup);

// After: hand the adapter the object as well as the method.
export const lookup = promisify(client.lookup.bind(client));

Hand the adapter the object as well as the method, with bind or an arrow function, for the same reason the lesson’s adapters hold their source rather than one of its functions. typescript-eslint’s unbound-method rule, in its type-checked configurations, reports the first form. Before adapting, check whether the library already ships a promise API, as node:fs/promises does.

In the frontend useSyncExternalStore, and Svelte’s readable Optional

React reads data that lives outside it through one contract: useSyncExternalStore(subscribe, getSnapshot) (React 18 and later). The browser’s geolocation API speaks another: watchPosition hands back an ID and calls you back with each position. The store below is the adapter between them.

The bug is where it translates. In the “before” version, getSnapshot builds the state object when React reads it, so every read returns a new object. React compares snapshots with Object.is, sees a change on every read, warns “The result of getSnapshot should be cached to avoid an infinite loop” and re-renders until it stops with “Maximum update depth exceeded”. Translate when the browser reports, and let reads return that value.

position.tsx
import { useSyncExternalStore } from 'react';

// React's contract for outside data: subscribe(onChange) returns an
// unsubscribe, and getSnapshot() returns the same value until it changes.
// The browser's geolocation API is a watch ID and callbacks.
type Position =
  | { status: 'locating' | 'unavailable' }
  | { status: 'located'; meters: number };
type PositionStore = {
  subscribe(onChange: () => void): () => void;
  getSnapshot(): Position;
};

type Report = (position: GeolocationPosition | null) => void;

function watch(geo: Geolocation, report: Report) {
  const id = geo.watchPosition(report, () => report(null));
  return () => geo.clearWatch(id);
}

// Before: the adapter translates when React reads. Every call makes a new
// object, so React sees a change on every read and re-renders without end.
export function positionStoreBefore(geo: Geolocation): PositionStore {
  let last: GeolocationPosition | null | undefined;
  return {
    subscribe: (onChange) =>
      watch(geo, (position) => {
        last = position;
        onChange();
      }),
    getSnapshot: () =>
      last === undefined
        ? { status: 'locating' }
        : last === null
          ? { status: 'unavailable' }
          : { status: 'located', meters: last.coords.accuracy },
  };
}

// After: translate once, when the browser reports; reads return that value.
export function positionStore(geo: Geolocation): PositionStore {
  let snapshot: Position = { status: 'locating' };
  return {
    subscribe: (onChange) =>
      watch(geo, (position) => {
        snapshot = position
          ? { status: 'located', meters: position.coords.accuracy }
          : { status: 'unavailable' };
        onChange();
      }),
    getSnapshot: () => snapshot,
  };
}

// The app makes one store, outside any component, and passes it down.
export function Accuracy({ store }: { store: PositionStore }) {
  const position = useSyncExternalStore(store.subscribe, store.getSnapshot);
  if (position.status !== 'located') {
    return <p>{position.status === 'locating' ? 'Finding you…' : 'Enter an address.'}</p>;
  }
  return <p>Located to within {Math.round(position.meters)} m</p>;
}

Svelte’s store contract pushes values to subscribers instead of being read, so the snapshot rule does not arise. readable(initial, start) is the adapter: Svelte calls start when the first subscriber arrives and the function it returns when the last one leaves, which is where clearWatch goes.

05Decide when it helps

Name the mismatch you are removing.

An adapter is useful when a caller has a coherent contract and an existing dependency offers a different one that you cannot or should not change. It localizes that mismatch and adds a mapping to keep correct as either contract changes.

What would you have to change tomorrow?
The changeEach caller translatesOne adapter per source
A third source arrives with its own APIEvery caller learns its request format, its reply and its failures.One new adapter. No caller changes.
The partner renames paidThroughEvery caller that reads it changes.One line in one adapter.
The display must tell an outage from bad dataEach caller learns each source’s failure codes.The contract gains a failure kind; each adapter maps its source onto it.
A new source already answers lookupPermit(plate, zone)Call it directly.An adapter would only forward the call: skip it.
Similar wrappers can own different decisions
NeedChoiceResponsibility
Use either meter through the permit lookupAdapterTranslate an existing interface into the caller’s contract.
Coordinate several setup steps as one operationFacadeOffer a focused operation over a subsystem workflow.
Add behavior around an already compatible lookupDecoratorKeep the interface while adding behavior around its use.
Choose a replaceable permit policyStrategySupply another way to perform the same policy responsibility.
Source already fits the callerDirect callKeep it visible; avoid a wrapper that only renames a method.

Its neighbours: Facade coordinates a subsystem workflow; Decorator adds behaviour around a compatible interface; Proxy controls access to an object; Strategy supplies a replaceable policy; and ports and adapters makes this lesson’s move the shape of a whole application.

What translation cannot decideA provider guarantee has to come from the provider

The adapter can map the provider’s explicit “no permit” response to absence. It cannot infer absence from a timeout, decide whether an expired permit deserves a grace period, or make provider data fresher. Keep those policies with the owner that has enough information to make them.

Is this only a data converter?Follow the request and the responsibility

A field-mapping function can be one part of an adapter. The full relationship includes the caller’s request, the existing source operation, and the returned target contract. The dashboard asks one question; the wrapper delegates and interprets the source’s reply.

Choosing which adapter to supply is composition. The objects are adapters because they wrap existing interfaces that do not fit the dashboard.

06Make the call

Translate, or decide?

The city buys a fleet registry that already has lookupPermit(plate, zone) and the dashboard’s permit shape, answers null for no permit and PermitUnavailable when it cannot answer. A teammate adds FleetRegistryAdapter, which forwards each call to it. What do you say in review?
Lookups sometimes fail once and succeed on a second try, from every source. Where does “try once more when the source is unavailable” go?

One more, in another setting: the same move, somewhere this lesson has not been.

A facilities report reads building meters from a vendor whose already-decoded reply has a state (reading, none or down) and wattHours. The report’s contract is a reading in kWh, no reading, or unavailable. A building used no energy yesterday, and the vendor answers reading with 0 watt-hours. What should the adapter return?

Before you open the answer below, say it in your own words: what does an adapter translate, and what must it refuse to decide?

One way to say itWithout the pattern’s name

The dashboard asks one permit question. A wrapper calls the provider’s API and returns what that API can honestly establish. It translates the request on the way in and the reply on the way out: names, shapes and units, and also outcomes, so a provider’s own “no permit” becomes absence, a provider that cannot answer becomes an outage, and a reply about another car or zone becomes an invalid response, never a permit. It refuses to decide anything the provider did not say: absence inferred from a timeout or an empty record, a grace period, an old answer served as current. Those are policies for the caller, or for a wrapper of their own around the lookup. And when a source already fits the caller’s contract, there is no mismatch to remove, so there is no adapter to write.

Practice in the lab

The dashboard from the lesson: it asks one question, “is there a permit for this plate in this zone, and when does it end?”, through PermitLookup, and an adapter answers it for each source. The city adds its resident-permit registry as a third. Each level starts from the previous level’s finished code, so you can begin at any of them.

  • ADAPTER-101 (easy) Residents with permits are shown no permit
  • ADAPTER-102 (medium) Another car’s permit, shown as this one’s Pro
  • ADAPTER-103 (hard) Review: fewer check-again screens Pro