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.
“Load this draft.”
The editor knows DraftStore and handles a found draft, an absent draft, or an error.
Translate the conversation.
The wrapper maps the input, response fields, and failure signals into the target contract.
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.
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 };
}
};
} 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
} | At the boundary | Local text adapter | Document adapter |
|---|---|---|
Input note-1 | Call getItem("draft:note-1"). | Call fetchRecord("note-1"). |
| Found draft | A returned string becomes title. | Status 200 requires the matching key; heading becomes title. |
| Absent draft | A missing storage key. | Status 404, as defined by this client contract. |
| Unavailable source | The source read fails. | The client call fails, or returns a status other than 200 or 404. |
| Invalid success record | Not 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.
Translate the meaning.
load("note-1")getItem("draft:note-1") "Release notes"{ "id": "note-1", "title": "Release notes" }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.
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.
// 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.
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.
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.
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.
| What you need | A useful choice | The responsibility |
|---|---|---|
| Use getItem through DraftStore | Adapter | Translate an existing interface into the caller’s expected interface. |
| Coordinate validate → connect → enable microphone as one join | Facade | Offer one useful operation over a subsystem workflow. |
| Add logging around the same load interface | Decorator | Preserve the interface while adding behavior around its use. |
| Choose a replaceable ranking policy | Strategy | Supply a different way to perform the same responsibility. |
| The source already fits the caller | A direct dependency | Keep 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.