← Design patterns
Composition Fitting an existing interface

Adapter

Make the interface fit. Keep the meaning.

Your editor opens drafts from local storage. Later, it needs to open them from a document service too. Both sources hold titles, but one returns a string under a prefixed key, while the other returns a status and a record with different field names.

The editor wants to load a draft. Let a small wrapper translate each source into that operation, including what “missing” and “failed” mean.

TypeScriptGoPythonSame load contract · across the comparison

01 / Keep the caller’s contract

The editor wants a draft, not a storage format.

The first call is easy to understand: getItem('draft:' + id). The local archive stores one title string under each draft key. With one source and one caller, keeping that call in the editor makes the behavior visible.

Now the document service joins in. Its fetchRecord(id) operation returns a response with status, key, and heading. The editor branches by source, the preview repeats the branch, and the export tool needs to learn both formats too.

An adapter makes an existing interface usable through the interface a caller expects. Here the editor’s contract is DraftStore.load(id). A local adapter translates that request into a storage key. A remote adapter translates it into a document-client call. Each returns the editor’s Draft shape.

A travel plug adapter is a useful first picture: the device can use an existing socket through a different connector. The limit of that picture matters. Fitting the connector does not automatically convert voltage; fitting method names does not automatically make two systems offer the same guarantees.

Caller → target interface

“Load this draft.”

The editor knows DraftStore and handles a found draft, an absent draft, or an error.

Adapter → existing API

Translate the conversation.

The wrapper maps the input, response fields, and failure signals into the target contract.

Source → its own interface

Keep the existing service.

The storage API or client performs its read. It does not need to know about the editor’s interface.

In pattern vocabulary, target means the interface the caller expects, and adaptee means the existing thing being wrapped. These examples use object adapters: they hold another object and delegate to it. Inheritance is not required.

02 / See the shape

One operation, with three distinct outcomes.

Start with the basic local adapter. It exposes load, prefixes the key, and pairs the returned title with the requested ID. Checking for absence explicitly preserves "" as a real title. In TypeScript, a function returning an object is enough to express that relationship.

The useful version adds a second adapter and an honest shared contract. A nonempty ID produces a fresh draft, absence, or an error. Empty IDs fail before the source is called. Source failures become “Draft storage unavailable.” A remote success with an incomplete or mismatched record becomes “Invalid draft record.”

Both sources below are memory doubles. The document client has a remote-shaped response but answers synchronously, so the translation is easy to follow.

A small local adapter exposes load(id) around getItem(key). It changes the interface and assembles a Draft, preserving an empty string. Input validation and source-error translation come in the useful version.

TypeScriptReading
drafts.ts
export interface Draft {
	id: string;
	title: string;
}

export interface DraftStore {
	load(id: string): Draft | null;
}

export interface TextStorage {
	getItem(key: string): string | null;
}

// Return the interface the editor expects around the existing storage API.
// This basic form leaves input validation and source errors to its caller.
export function adaptTextStorage(storage: TextStorage): DraftStore {
	return {
		load(id) {
			const title = storage.getItem('draft:' + id);
			return title === null ? null : { id, title };
		}
	};
}
GoAlongside
drafts.go
type Draft struct {
	ID    string `json:"id"`
	Title string `json:"title"`
}

type DraftStore interface {
	Load(id string) (*Draft, error)
}
type TextStorage interface {
	GetItem(key string) (title string, found bool, err error)
}

// Adapt the existing API through the interface the editor expects.
// The basic form leaves validation and source errors to its caller.
type BasicLocalAdapter struct{ Storage TextStorage }

func (a BasicLocalAdapter) Load(id string) (*Draft, error) {
	title, found, err := a.Storage.GetItem("draft:" + id)
	if err != nil {
		return nil, err
	}
	if !found {
		return nil, nil
	}
	return &Draft{ID: id, Title: title}, nil
}
The same caller contract, two source-specific translations.
At the boundaryLocal text adapterDocument adapter
Input note-1Call getItem("draft:note-1").Call fetchRecord("note-1").
Found draftA returned string becomes title.Status 200 requires the matching key; heading becomes title.
Absent draftA missing storage key.Status 404, as defined by this client contract.
Unavailable sourceThe source read fails.The client call fails, or returns a status other than 200 or 404.
Invalid success recordNot part of the typed string-or-absence response.Status 200, but the record or heading is missing, or the record belongs to another ID.

Every implementation passes the supplied strings through exactly, including whitespace and empty titles, and each valid load makes one source call. The shared cases check both the result and the exact argument passed across the boundary.

Reading the TypeScriptStructural interfaces and explicit absence

DraftStore describes a method shape. The object returned by adaptTextStorage satisfies it without inheriting from a base class. Its method closes over the supplied storage object. The practical classes hold that reference in a field and declare the same interface.

title === null distinguishes no value from an empty string. A truthiness check would accidentally discard an existing empty title. Each successful load creates a new object; the adapter does not return the source’s record.

The try covers the source call. Response validation happens afterward, so “Invalid draft record” remains distinct from an unavailable source. These interfaces describe already-decoded values; TypeScript annotations do not validate JSON received over a network.

Reading the GoInterface satisfaction and found values

A value with a matching Load method satisfies DraftStore implicitly. Each adapter holds a small source interface. Application setup can supply a pointer to a memory double or another implementation of those methods.

GetItem returns a string, a found flag, and an error. An empty string with found == true is present. The target’s (*Draft, error) uses nil, nil for absence and a non-nil error for failure; check the error before interpreting the draft.

*string on Heading distinguishes a missing field from a present empty title. The adapter copies that title into a new Draft. Copying an adapter’s interface field does not duplicate the underlying client or make it safe for concurrent use.

Reading the PythonProtocols, None, and explicit translation

Python’s Protocol types describe the target and source shapes without a base-class hierarchy. The concrete adapters retain their source and return a new frozen Draft. None means absence, while an empty string remains a found title.

The practical adapters catch source exceptions only around the source call and replace them with the target’s unavailable error. A successful remote reply still needs a record with the requested key and a non-None heading; Python’s annotations do not validate untrusted decoded data by themselves.

03 / Follow the translation

Change the source. Keep the request.

Watch the mappings or step through them, then open Try it. Predict first: leave the ID as note-1 and clear the stored title. Should the editor receive an absent value, or a draft whose title is empty? Load it through each source and compare the source response with the caller’s result.

Then select an unavailable source. Did the adapter learn that the draft does not exist? The lab runs the TypeScript implementation above, whichever language you are reading.

Adapter

Translate the meaning.

Editor → DraftStoreload("note-1")
Text storagegetItem("draft:note-1")
Source response"Release notes"
Caller’s resultDraft found
The target contract{ "id": "note-1", "title": "Release notes" }
String + requested ID → { id, title }
01/ 04
Adapt a title string

A string becomes a draft.

Add the storage-key prefix on the way in; pair the returned title with the requested ID.

Reduced motion: choose a scene to see its completed state.

Read this scene

Add the storage-key prefix on the way in; pair the returned title with the requested ID.

load("note-1"). Source call: getItem("draft:note-1"). Source response: "Release notes". Caller result: { "id": "note-1", "title": "Release notes" }. Draft found.

The empty title remains a found draft in both paths. Choosing an unavailable source produces an error instead of absence. Under the document client, try “Wrong record ID”: returning that record would silently open someone else’s requested resource. The adapter rejects the mismatch before giving the caller a Draft.

This is the part a field-renaming diagram can miss. A useful adapter preserves the distinctions that let its caller make the right next decision.

04 / Try the decision

Which answer can the source actually support?

The caller reacts differently to absence and failure. Choose what the adapter can honestly promise after a failed read.

The document client returns status 503. What should the adapter give the editor?

The editor offers “Create a draft” for an absent value and “Try again” for a storage error. Use the shared load contract.

05 / Give it a real job

Put source-specific knowledge at the boundary.

Application setup creates the local storage or document client and supplies the matching adapter to the editor. The editor calls load when the user opens a document. It handles missing and error states in its own UI. The adapter owns the translation; it does not decide whether to show a toast, retry, or create a replacement.

Suppose the document service renames heading to display_name. With source-shaped code spread through the editor, preview, and export path, each caller changes. With this boundary, update the decoded client type and the remote adapter’s mapping, then run the contract cases. Callers that still need only an ID and title keep their interface.

The boundary is deliberately small and read-only. Adding a new backend means demonstrating that it can fulfill this load contract. Adding a save operation would require a separate agreement about validation, conflicts, durability, and what success means.

What changes outside the memory example?Timing, parsing, ownership, and diagnostics
  • Timing and cancellation: wrapping a remote source cannot remove the waiting, so make it visible in the target contract—for example, a Promise in TypeScript, a context-aware Go call. Pass cancellation and deadlines through. The UI still needs to ignore a late result for a draft the user has stopped viewing.
  • Runtime data: these clients return typed records. Real response decoding must check field types and schema versions before the adapter can rely on them. URL encoding belongs where an ID becomes a URL component; do not silently change the logical ID to achieve it.
  • Ownership: setup owns the client’s credentials, configuration, and connection lifetime. A wrapper around a shared client should not unexpectedly close it.
  • Diagnostics: the teaching errors are short strings. A production API can use named error categories while retaining the original cause for diagnosis. Preserve permission, timeout, and retry information if callers need those distinctions instead of compressing every failure into one bucket.
  • Changing guarantees: an adapter does not make a stale replica current, make a read atomic with a later write, or make a failing source available. Expose a meaningful difference in the contract when translation cannot hide it honestly.
Build UIs?React and Svelte each read an outside source through a contract of their own, and a browser API will not arrive in that shape.

Where it already is in your components

When a component reads something React or Svelte does not own, the framework sets the interface and your code adapts the source to it. React’s useSyncExternalStore takes two functions. Its subscribe “should return a function that cleans up the subscription,” and of getSnapshot the docs say: “While the store has not changed, repeated calls to getSnapshot must return the same value.” Svelte’s $store reads anything that meets the store contract: subscribe calls your function with the current value before it returns, and returns the function that unsubscribes.

Svelte runs an adapter of its own there. “For interoperability with RxJS Observables, the .subscribe method is also allowed to return an object with an .unsubscribe method,” and Svelte 5.57’s store reader turns that object into the teardown function it calls when the component is destroyed. Outside a component, $store is a compile error, “Cannot reference store value outside a .svelte file”, and fromStore adapts the store to an object with a reactive current property instead.

The browser’s position watch fits neither contract. watchPosition hands back a number and calls you back later, and clearWatch(id) ends it. Both textbook samples below turn the number into a cleanup function, and both get the value wrong. React’s getSnapshot copies the accuracy into a new object on every call: in a React 19.1.0 development mount, the first position logged “The result of getSnapshot should be cached to avoid an infinite loop”, then threw “Maximum update depth exceeded”, and the page was left empty. Svelte’s store calls run only once a position arrives, so $position starts as undefined, not null. The === null check let it through, a Svelte 5.57 mount threw “Cannot read properties of undefined (reading 'coords')”, and the watch it had started kept running. Caching the snapshot in React, or calling run(null) first in Svelte, rendered “Finding you…” and then the accuracy.

When you have to own it

Now the checkout needs your position in more than one place: the delivery line, the map pin, and the Deliver here button. Separate watches in each component mean more watches running and more places to get the value half wrong. Write the adapter once, outside both frameworks. position.ts below meets both contracts at once: subscribe hands each new subscriber the current state before it returns, as Svelte asks, and React ignores that argument and calls getSnapshot, which returns the same object until the browser reports again. The first subscriber starts the watch, and the last cleanup clears it.

The translation is also where meaning gets decided, as it was for drafts. A position becomes the fields the screens use, and altitude, heading, and speed are dropped. The error’s numeric code becomes denied, timeout, or unavailable, and the browser’s message is dropped. Sharing one watch is the only thing the store adds beyond translation. It does not retry, and it forgets the last position when the last reader leaves, so a new reader sees “Finding you…” rather than where you were.

Mounted in Chromium 153 with two readers, the React and Svelte panes shared one watch, kept the button disabled until the first position, followed a change in accuracy, kept the watch while one reader remained, and cleared it after the last one unmounted. React’s Strict Mode started and cleared one extra watch while mounting and still left one running. With location blocked, both showed the address prompt.

position.ts
// Adapts the browser's position watch, which hands back an ID and calls back
// later, to the store contracts React and Svelte read. Components share one
// store: the browser watch starts with the first subscriber and stops with the last.

export type PositionState =
	| { status: 'locating' }
	| { status: 'located'; latitude: number; longitude: number; accuracyMeters: number; at: number }
	| { status: 'denied' | 'unavailable' | 'timeout' };

export type PositionStore = ReturnType<typeof createPositionStore>;

const locating: PositionState = { status: 'locating' };

export function createPositionStore(geolocation: Geolocation, options?: PositionOptions) {
	let state = locating;
	let watchId: number | null = null;
	const listeners = new Set<(state: PositionState) => void>();

	// A new object only when the browser reports, so getSnapshot returns the same
	// value between reports, as React requires.
	function publish(next: PositionState) {
		state = next;
		for (const listener of [...listeners]) listener(state);
	}

	function start() {
		watchId = geolocation.watchPosition(
			// Keeps what the screens use. Altitude, heading, and speed are dropped.
			({ coords, timestamp }) =>
				publish({
					status: 'located',
					latitude: coords.latitude,
					longitude: coords.longitude,
					accuracyMeters: coords.accuracy,
					at: timestamp
				}),
			// The numeric code becomes a name. The browser's message is dropped.
			(error) =>
				publish({
					status:
						error.code === error.PERMISSION_DENIED
							? 'denied'
							: error.code === error.TIMEOUT
								? 'timeout'
								: 'unavailable'
				}),
			options
		);
	}

	return {
		getSnapshot: () => state,
		subscribe(listener: (state: PositionState) => void) {
			// One entry per call, so the same function subscribed twice needs two cleanups.
			const entry = (next: PositionState) => listener(next);
			listeners.add(entry);
			if (watchId === null) start();
			// Svelte's contract: hand over the current value now. React ignores the
			// argument and calls getSnapshot.
			entry(state);
			return () => {
				if (!listeners.delete(entry) || listeners.size > 0 || watchId === null) return;
				geolocation.clearWatch(watchId);
				watchId = null;
				// A restarted watch has not reported yet, so the next reader starts locating.
				state = locating;
			};
		}
	};
}

A checkout line that says how closely the site has located you. The watch ID becomes a cleanup function, but React’s getSnapshot builds a new object on every call, and Svelte’s store calls run only once a position arrives.

ReactAlready in your code
DeliverHere.tsx
import { useSyncExternalStore } from 'react';

let latest: GeolocationPosition | null = null;

// The cleanup half of React's contract is met: the watch ID becomes a function.
function subscribe(onChange: () => void) {
	const id = navigator.geolocation.watchPosition((position) => {
		latest = position;
		onChange();
	});
	return () => navigator.geolocation.clearWatch(id);
}

// The value half is not. Once a position has arrived, this builds a new object on
// every call, so React never reads the same snapshot twice.
function getSnapshot() {
	return latest && { accuracyMeters: latest.coords.accuracy };
}

export function DeliverHere() {
	const fix = useSyncExternalStore(subscribe, getSnapshot);
	return (
		<p>{fix === null ? 'Finding you…' : `Located to within ${Math.round(fix.accuracyMeters)} m`}</p>
	);
}
A fetch rejection is not the only failure

fetch returns a Promise. HTTP error responses such as 404 still produce a Response; the caller must inspect its status. Network failures can reject the Promise. A real document adapter therefore needs both response interpretation and rejection handling. Read the fetch response and error behavior.

The lesson’s client hands the adapter a decoded response and assigns 404 the meaning “draft missing.” Apply that mapping only when the service defines it that way. Once the real source is asynchronous, use an asynchronous DraftStore contract for both adapters so the editor can await either one.

06 / Already in your toolbox

You may already pass through an adapter.

Both examples come from documented public API contracts, read at the interface they expose.

Go: a function can become an HTTP handler

http.HandlerFunc(f) lets a function with the required signature satisfy the http.Handler interface. Its ServeHTTP method invokes that function. This is a compact adapter: the HTTP consumer expects a method-bearing handler, and the supplied behavior is an ordinary function. There is no need to build a large wrapper class.

Go HandlerFunc documentation ↗

Node.js: cross between two stream interfaces

Readable.fromWeb accepts a Web ReadableStream and returns a Node Readable. Readable.toWeb provides the other direction (both still marked experimental in Node’s docs). Code written for one stream interface can use an existing stream through an adapter. Both sides still read one underlying source, so buffering, cancellation, and consumption deserve attention.

Node stream interoperability documentation ↗

07 / Make the call

Name the incompatibility you are removing.

An adapter is useful when a caller has a coherent interface and an existing dependency offers a different one that you cannot or should not change. It localizes that mismatch. It also adds another place to navigate and a mapping that must stay correct when either contract changes.

Similar wrappers can have different responsibilities.
What you needA useful choiceThe responsibility
Use getItem through DraftStoreAdapterTranslate an existing interface into the caller’s expected interface.
Coordinate validate → connect → enable microphone as one joinFacadeOffer one useful operation over a subsystem workflow.
Add logging around the same load interfaceDecoratorPreserve the interface while adding behavior around its use.
Choose a replaceable ranking policyStrategySupply a different way to perform the same responsibility.
The source already fits the callerA direct dependencyKeep the call visible; avoid a wrapper whose only work is a rename.
Is a data converter an adapter?

A function that converts one record into another may be part of an adapter, but field mapping alone does not show the whole relationship. Our wrapper accepts a request through DraftStore, delegates to a source, interprets its outcome, and serves that caller’s contract.

Likewise, swapping local and remote adapters at setup uses dependency injection. The reason those objects are adapters is the source-interface translation they perform. If you designed two implementations of DraftStore from scratch and neither wrapped a different existing interface, having a shared interface alone would not make them adapters.

08 / Take the idea with you

Follow a request through both interfaces.

Explain the design without its name: “The editor asks for a draft through one interface. A wrapper translates that request into the storage API we already have, then translates the answer without confusing missing data with failure.”

From memory, trace note-1 through each source. Name the method called, the value returned, and what an empty title means. Then explain why a 503 response cannot become a missing draft just to make the caller easier to write.

Choose an integration in your own code. What does its caller need? Which source-specific detail is leaking into several places? Name one translation a wrapper could own and one guarantee it could never manufacture.

Connections to follow nextRelated lessons
  • Facade turns a subsystem workflow into a focused operation.
  • Strategy supplies a replaceable behavior to a consumer.
  • Abstract factory can supply related collaborators at setup, including adapted ones.
  • Decorator adds behavior around a compatible interface. Proxy explores controlling access to an underlying object.

Copy the complete example. Rename the remote heading field, update its adapter, and check that the openDraft consumer still works unchanged.

Back to design patterns →