An adapter behind a port you own
Everything the vendor defines stops in the adapter; count the files that use the SDK, and aim for one.
Predict first
Before you read: predict
ExamHall moved its proctoring video from Vidco to Streamly. Only the adapter was rewritten; incidents.ts and proctor-console.ts were not touched. Streamly has no capacity when seat 12 starts:
What does main.ts print?
// streamly-sdk.ts (the new vendor's package)export class StreamlyError extends Error { constructor(public status: number) { super(`streamly ${status}`); } }export class StreamlyClient { connect(seat: string): { id: string } { throw new StreamlyError(503); } } // video-session.ts (ExamHall's port)export class VideoUnavailable extends Error {}export interface VideoSession { startSession(seat: string): string } // streamly-adapter.ts (replaces the Vidco adapter)import { StreamlyClient, StreamlyError } from 'streamly-sdk';import { VideoUnavailable, type VideoSession } from './video-session';export class StreamlyAdapter implements VideoSession { constructor(private client: StreamlyClient) {} startSession(seat: string): string { try { return this.client.connect(seat).id; } catch (err) { if (err instanceof StreamlyError && err.status === 503) throw new VideoUnavailable('no capacity', { cause: err }); throw err; } }} // incidents.ts (unchanged; Vidco's code for "no capacity" was ERR_42)import type { VideoUnavailable } from './video-session';export const incidents: string[] = [];export function record(e: VideoUnavailable, seat: string) { const code = (e.cause as { code?: string } | undefined)?.code; incidents.push(code === 'ERR_42' ? `${seat}: vendor at capacity` : `${seat}: unknown failure`);} // proctor-console.ts (unchanged)import { VideoUnavailable, type VideoSession } from './video-session';import { record } from './incidents';export function openSeat(video: VideoSession, seat: string): string { try { return `watching ${video.startSession(seat)}`; } catch (e) { if (e instanceof VideoUnavailable) { record(e, seat); return 'video busy, retrying'; } return 'seat closed'; }} // main.tsimport { StreamlyClient } from 'streamly-sdk';import { StreamlyAdapter } from './streamly-adapter';import { openSeat } from './proctor-console';import { incidents } from './incidents';console.log(openSeat(new StreamlyAdapter(new StreamlyClient()), 'seat-12'));console.log(`incidents: ${incidents.join('; ') || 'none'}`);The 30-second version
An adapter implements a port your code owns by calling one vendor's SDK. Everything vendor-shaped stops in that file: the client, field names, units, id formats and errors, which become the port's own classes. Count the files that use any SDK name: at one, a vendor swap touches only that file.
Everything the vendor defines stops in the adapter; count the files that use the SDK, and aim for one.
How it works
Step 1 of 11
arch-layered-hexagonal step 2).In the kitchen
- the hatchery manager writes the tanks' own feeding plan: "tank 3, fry, 2 % of body weight a day" → the port, in your terms (step 1)
Analogy: A salmon hatchery's feed buyer
| At the hatchery | In code |
|---|---|
| the hatchery manager writes the tanks' own feeding plan: "tank 3, fry, 2 % of body weight a day" | the port, in your terms (step 1) |
| only the feed buyer holds the feed mill's price list, product codes and order forms | the one file that uses the SDK (steps 2–3) |
| the mill's "SUB-12" substitution code reaches the manager as "a different pellet this week: watch tank 3 at feeding" | errors translated (step 6) |
| the mill closes and the hatchery buys from another; only the feed buyer's binder changes | a swap touches one file (step 9) |
In real terms: before trusting an adapter, list who else uses the vendor's names; anyone on that list changes when the vendor does.
Breaks down when: the feed buyer can phone the mill about a code she has never seen; code meeting an unmapped vendor error cannot ask, so the mapping decides in advance (step 7).
Contrasting case: Java's JDBC: SQLException crosses every layer by design, because a standard owns that interface, not the database vendor, so changing the driver changes no catch block.
Examples, run for real
4.1 ExamHall: Vidco in three files
EXH-4050: ExamHall's grading started each seat's video by calling the Vidco SDK directly, and the proctor console read Vidco's session_token itself:
// grading.ts (as shipped)
import { VidcoClient, VidcoError } from 'vidco-sdk';
export function startExam(client: VidcoClient, seat: string): string {
try { return client.open(seat).session_token; }
catch (e) { if (e instanceof VidcoError && e.code === 'ERR_42') return 'video unavailable'; throw e; }
}
MR !4350 adds ExamHall's own port and one adapter, which builds the client, maps the token to a session id and turns ERR_42 into VideoUnavailable, keeping Vidco's error as cause:
// video-vendor.ts (MR !4350)
import { VidcoClient, VidcoError } from 'vidco-sdk';
import { VideoUnavailable, VideoFailed, type VideoSession } from './video-session';
export class VideoVendorAdapter implements VideoSession {
private client: VidcoClient;
constructor(apiKey: string) { this.client = new VidcoClient(apiKey); }
startSession(seat: string): string {
try { return this.client.open(seat).session_token; }
catch (err) {
if (err instanceof VidcoError && err.code === 'ERR_42') throw new VideoUnavailable(seat, { cause: err });
throw new VideoFailed(seat, { cause: err });
}
}
}
4.1 EXH-4050 as shipped: files using a vidco-sdk name 3 (grading.ts, main.ts, proctor-console.ts)
4.1 MR !4350: files using a vidco-sdk name 1 (video-vendor.ts) · tsc no diagnostics · seat-3 watching vt-seat-3 · seat-9 video unavailable
4.1 swap to Streamly after MR !4350: video-vendor.ts rewritten, other files changed 0 · tsc no diagnostics · seat-3 watching st-seat-3 · seat-9 video unavailable
Before the MR, grading, the console and the startup file each used a Vidco name, so a swap would edit all three. After it, only the adapter does (steps 2–3, 9); the Streamly rewrite changed that one file, and both seats behave as before (steps 4, 6).
4.2 What the MR does not decide
Which vendor errors deserve their own port class is a product question: VideoUnavailable exists because the console offers a retry. Everything else became VideoFailed (step 7). Turning a whole foreign model, with its meanings, into yours is an anti-corruption layer's job, not one adapter's.
Try it
Answer first, then check. Nothing is saved.
Question 1 of 8
A box office holds seats through seatmap-sdk, and wants a later switch to another vendor to touch one file. seatmap-sdk.ts, seat-holds.ts and checkout.ts are the same in every version; seatmap-adapter.ts and main.ts come in three versions. Every version compiles and prints the hold. For each, the team lists the files, other than seatmap-sdk.ts, that use a class, function or field seatmap-sdk.ts declares. The files are below.
Which version's list holds exactly one file?
// seatmap-sdk.ts (the vendor's package)export class SeatmapClient { constructor(private region: string) {} hold(seat: string): { hold_ref: string } { return { hold_ref: `h-${seat}` }; }}export const formatRef = (ref: string) => ref.toUpperCase(); // seat-holds.ts (the box office's port)export interface SeatHolds { hold(seat: string): string } // checkout.tsimport type { SeatHolds } from './seat-holds';export const reserve = (holds: SeatHolds, seat: string) => `held ${holds.hold(seat)}`; // seatmap-adapter.ts (version 1)import { SeatmapClient } from 'seatmap-sdk';import type { SeatHolds } from './seat-holds';export class SeatmapAdapter implements SeatHolds { constructor(private client: SeatmapClient) {} hold(seat: string) { return this.client.hold(seat).hold_ref; }} // main.ts (version 1)import { SeatmapClient } from 'seatmap-sdk';import { SeatmapAdapter } from './seatmap-adapter';import { reserve } from './checkout';console.log(reserve(new SeatmapAdapter(new SeatmapClient('eu')), 'A1')); // seatmap-adapter.ts (versions 2 and 3)import { SeatmapClient } from 'seatmap-sdk';import type { SeatHolds } from './seat-holds';export class SeatmapAdapter implements SeatHolds { private client: SeatmapClient; constructor(region: string) { this.client = new SeatmapClient(region); } hold(seat: string) { return this.client.hold(seat).hold_ref; }} // main.ts (version 2)import { SeatmapAdapter } from './seatmap-adapter';import { reserve } from './checkout';console.log(reserve(new SeatmapAdapter('eu'), 'A1')); // main.ts (version 3)import { formatRef } from 'seatmap-sdk';import { SeatmapAdapter } from './seatmap-adapter';import { reserve } from './checkout';console.log(formatRef(reserve(new SeatmapAdapter('eu'), 'A1')));Question 2 of 8
A box office's Seatmap adapter is complete apart from one line. Seatmap now refuses with RATE_LIMITED, a code the adapter has no mapping for. The files are below.
Which line, in place of // the missing line, gives the buyer one of checkout's own messages and never reports a hold that was not made?
// seatmap-sdk.ts (the vendor's package)export class SeatmapError extends Error { constructor(public code: string) { super(code); } }export class SeatmapClient { hold(seat: string): string { throw new SeatmapError('RATE_LIMITED'); } } // seat-holds.ts (the box office's port)export class SeatTaken extends Error {}export class SeatHoldFailed extends Error {}export interface SeatHolds { hold(seat: string): string } // seatmap-adapter.tsimport { SeatmapClient, SeatmapError } from 'seatmap-sdk';import { SeatTaken, SeatHoldFailed, type SeatHolds } from './seat-holds';export class SeatmapAdapter implements SeatHolds { private client = new SeatmapClient(); hold(seat: string): string { try { return this.client.hold(seat); } catch (err) { if (err instanceof SeatmapError && err.code === 'SEAT_TAKEN') throw new SeatTaken(seat, { cause: err }); // the missing line } }} // checkout.tsimport { SeatTaken, SeatHoldFailed, type SeatHolds } from './seat-holds';export function reserve(holds: SeatHolds, seat: string): string { try { return `held ${holds.hold(seat)}`; } catch (e) { if (e instanceof SeatTaken) return 'seat taken, pick another'; if (e instanceof SeatHoldFailed) return 'try again in a minute'; throw e; }}Question 3 of 8
The box office switched from Seatmap, whose hold references looked like h-A1, to Venuely. The adapter was rewritten; receipt.ts was not. main.ts prints no hold, though Venuely held the seat for 10 minutes. The files are below.
Which line of receipt.ts is the defect?
// venuely-sdk.ts (the new vendor's package)export class VenuelyClient { reserve(seat: string): { ref: string; expires_s: number } { return { ref: `VN${seat}`, expires_s: 600 }; } } // seat-holds.ts (the box office's port)export interface Hold { ref: string; expiresInMs: number }export interface SeatHolds { hold(seat: string): Hold } // venuely-adapter.tsimport { VenuelyClient } from 'venuely-sdk';import type { Hold, SeatHolds } from './seat-holds';export class VenuelyAdapter implements SeatHolds { private client = new VenuelyClient(); hold(seat: string): Hold { const r = this.client.reserve(seat); return { ref: r.ref, expiresInMs: r.expires_s * 1000 }; }} // receipt.tsimport type { Hold } from './seat-holds';export function receipt(hold: Hold): string { const minutes = hold.expiresInMs / 60_000; const valid = hold.ref.startsWith('h-'); const shown = hold.ref.toUpperCase(); return valid ? `hold ${shown}, ${minutes} min` : 'no hold';} // main.tsimport { VenuelyAdapter } from './venuely-adapter';import { receipt } from './receipt';console.log(receipt(new VenuelyAdapter().hold('A1')));Question 4 of 8
Before you write the function, list your tests for mapVidcoError(err, seat), the function ExamHall's Vidco adapter calls in its catch. Vidco's ERR_42 means no capacity and ERR_7 a bad API key.
Name at least five tests, each with its input and what it expects.
Question 5 of 8
Now write it, in 10 minutes, from a blank file that imports the three error classes from ./video-errors.mjs and VidcoError from ./vidco-sdk.mjs, and exports mapVidcoError.
Write mapVidcoError in full.
Question 6 of 8
A box office's checkout is tested by three suites, each run as checkout.test.ts, which prints pass or fail; all three print pass today. Next month the box office moves from Seatmap to Venuely: seatmap-sdk is replaced by venuely-sdk, and holds-adapter.ts is rewritten as its "after the swap" version; no other file changes. The files are below.
Which suite still prints pass after the swap, with its own file unedited?
// seatmap-sdk.ts (today's vendor)export class SeatmapClient { hold(seat: string): { hold_ref: string } { return { hold_ref: `h-${seat}` }; } } // venuely-sdk.ts (next month's vendor)export class VenuelyClient { reserve(seat: string): { ref: string } { return { ref: `VN${seat}` }; } } // seat-holds.ts (the box office's port)export interface SeatHolds { hold(seat: string): string } // holds-adapter.ts (today)import { SeatmapClient } from 'seatmap-sdk';import type { SeatHolds } from './seat-holds';export class HoldsAdapter implements SeatHolds { constructor(private client = new SeatmapClient()) {} hold(seat: string) { return this.client.hold(seat).hold_ref; }} // holds-adapter.ts (after the swap)import { VenuelyClient } from 'venuely-sdk';import type { SeatHolds } from './seat-holds';export class HoldsAdapter implements SeatHolds { constructor(private client = new VenuelyClient()) {} hold(seat: string) { return this.client.reserve(seat).ref; }} // checkout.tsimport type { SeatHolds } from './seat-holds';export const reserve = (holds: SeatHolds, seat: string) => `held ${holds.hold(seat)}`; // checkout.test.ts (suite 1)import { SeatmapClient } from 'seatmap-sdk';import { HoldsAdapter } from './holds-adapter';import { reserve } from './checkout';SeatmapClient.prototype.hold = (seat: string) => ({ hold_ref: `ref-${seat}` });console.log(reserve(new HoldsAdapter(), 'A1') === 'held ref-A1' ? 'pass' : 'fail'); // checkout.test.ts (suite 2)import type { SeatHolds } from './seat-holds';import { reserve } from './checkout';const holds: SeatHolds = { hold: (seat) => `ref-${seat}` };console.log(reserve(holds, 'A1') === 'held ref-A1' ? 'pass' : 'fail'); // checkout.test.ts (suite 3)import { HoldsAdapter } from './holds-adapter';import { reserve } from './checkout';const client = { hold: (seat: string) => ({ hold_ref: `ref-${seat}` }) };console.log(reserve(new HoldsAdapter(client), 'A1') === 'held ref-A1' ? 'pass' : 'fail');Question 7 of 8
A ledger charges cards through payco-sdk. The team lists the files, other than payco-sdk.ts, that use a type, class or field payco-sdk.ts declares. The files are below.
How many files are on the list?
// payco-sdk.ts (the payment provider's package)export type PaycoStatus = 'succeeded' | 'requires_action' | 'failed';export class PaycoClient { charge(cents: number): { status: PaycoStatus } { return { status: cents > 0 ? 'succeeded' : 'failed' }; } } // payments.ts (the ledger's port)import type { PaycoStatus } from 'payco-sdk';export type { PaycoStatus as PaymentStatus };export interface Payments { charge(cents: number): PaycoStatus } // payco-adapter.tsimport { PaycoClient } from 'payco-sdk';import type { Payments } from './payments';export class PaycoAdapter implements Payments { private client = new PaycoClient(); charge(cents: number) { return this.client.charge(cents).status; }} // ledger.tsimport type { Payments, PaymentStatus } from './payments';export const post = (p: Payments, cents: number): PaymentStatus => p.charge(cents); // statement.tsimport type { PaymentStatus } from './payments';export const line = (s: PaymentStatus) => `charge ${s}`;Question 8 of 8
A chat app sends push notifications through Pushly today and will move to Notifyr next quarter. Both rate-limit with a wait of 3 seconds, each in its own way. Each adapter's catch throws RateLimited for its vendor's rate-limit error, in one line. The files are below.
Which pair of lines makes deliver print retry in 3000 ms with either vendor?
// pushly-sdk.ts (today's vendor)export class PushlyError extends Error { constructor(public status: number, public retry_after: number) { super(`pushly ${status}`); } }export class PushlyClient { send(user: string): void { throw new PushlyError(429, 3); } } // notifyr-sdk.ts (next quarter's vendor)export class NotifyrError extends Error { constructor(public code: string, public waitMs: number) { super(code); } }export class NotifyrClient { push(user: string): void { throw new NotifyrError('THROTTLED', 3000); } } // push-port.ts (the chat app's port)export class RateLimited extends Error { constructor(public retryAfterMs: number) { super('rate limited'); } }export interface Push { send(user: string): void } // deliver.tsimport { RateLimited, type Push } from './push-port';export function deliver(push: Push, user: string): string { try { push.send(user); return 'sent'; } catch (e) { return e instanceof RateLimited ? `retry in ${e.retryAfterMs} ms` : 'failed'; }} // pushly-adapter.ts (notifyr-adapter.ts is the same with Notifyr in place of Pushly, push in place of send, and err.code === 'THROTTLED' in place of err.status === 429)import { PushlyClient, PushlyError } from 'pushly-sdk';import { RateLimited, type Push } from './push-port';export class PushlyAdapter implements Push { private client = new PushlyClient(); send(user: string): void { try { this.client.send(user); } catch (err) { if (err instanceof PushlyError && err.status === 429) /* the line */; throw err; } }}Go deeper
Pro tips
- Count the SDK's users before and after an adapter MR. List the files that use any name the vendor's SDK declares; more than the adapter means a swap edits more than one file. When: reviewing any MR that touches a vendor integration (step 9).
- Grep for vendor codes outside the adapter. Search the code base for the vendor's error codes and status values (
ERR_42,RATE_LIMITED,cause.code); a hit outside the adapter is a check that a swap breaks silently, sincetsccannot see it. When: before a vendor swap (steps 6, 8). Verify (RAN,tips.mjs): in t1's files, a search forERR_42and.code ===outsidestreamly-adapter.tsfinds 1 line, inincidents.ts, and after the swap that file filed the capacity problem asseat-12: unknown failure.
Source
- Alistair Cockburn, Hexagonal Architecture (the row's second source): https://alistair.cockburn.us/hexagonal-architecture/ · fetched 2026-10-13. Teaching only; every key here is executed. "the application communicates over ports to external agencies"; "For each external device there is an adapter that converts the API definition to the signals needed by that device and vice versa" (steps 1–2, 4); "The ports-and-adapters pattern is a particular use of the Adapter pattern" (step 11). Valid as of 2026-10-13.
- The row's first source, Gamma, Helm, Johnson and Vlissides, Design Patterns (1994), Adapter, is a book and keys nothing; the adapter's intent is taught by the prerequisite
pat-structural-wrappers, which cites refactoring.guru. - MDN, Error: cause (not named by the row; teaching only): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause · fetched 2026-10-13. "The
causedata property of anErrorinstance indicates the specific original cause of the error. It is used when catching and re-throwing an error with a more-specific or useful error message in order to still have access to the original error" (step 8). Valid as of 2026-10-13.
Related lessons
Checked against the tools and docs as of 2026-10-13.