Lesson 07 / 30
Adapter
Bridging incompatible interfaces: counting the files that touch two external carrier providers' dictionaries in the arrangement where translation spreads to the client and in the arrangement behind an adapter, comparing the edited files and added lines when a third provider is added, and deriving the pattern's cost in files, lines, and indirection.
Contents
Creational patterns solved who constructs an object and how: factory method gathered type selection in one place, builder sequenced multi-step construction, dependency injection took creation out of the using code. In each case, the object built was the library’s own, with an interface the library wrote too.
This topic addresses how objects get combined. The first problem: an interface whose shape the library does not determine. A shipping fee library connects to one external provider per carrier, and each brings its own dictionary, its own unit, its own way of saying “I do not carry this.” The adapter resolves this in a single place. Its gain is measured with two numbers: files touching a provider’s dictionary, and existing files edited when a third provider is added.
Problem: Two Dictionaries, Two Error Formats
Two external providers do the same job and agree on nothing. The first takes weight in
grams and cost in cents, and returns null outside its tiers.
// provider/ground.mjs — external provider: works in grams and cents, returns null outside its tiers const TIER = [[1000, 4990], [5000, 8490], [15000, 14990], [30000, 24990]]; const COEFFICIENT = { 34: 100, "06": 115, 35: 120, 65: 145 }; export function calculateCost({ weightGrams, destinationCode }) { const tier = TIER.find(([cap]) => weightGrams <= cap); if (tier === undefined) return null; const coefficient = COEFFICIENT[destinationCode.slice(0, 2)] ?? 165; return { costCents: Math.round((tier[1] * coefficient) / 100), dayCount: coefficient > 130 ? 4 : 2 }; }
The second works in kilograms and lira, expects a zone name instead of a postal code, and throws for a shipment it does not carry.
// provider/air.mjs — external provider: works in kilograms and lira, expects a zone name instead of a postal code, throws for out-of-coverage shipments const ZONE_BASE = { near: 39.9, mid: 64.5, far: 98 }; export function quotePrice({ weightKg, zoneName }) { const base = ZONE_BASE[zoneName]; if (base === undefined) throw new RangeError(`out of coverage: ${zoneName}`); if (weightKg > 20) throw new RangeError("maximum weight 20 kg"); return { lira: Math.round((base + weightKg * 8.4) * 100) / 100, transferDays: zoneName === "far" ? 3 : 1, }; }
The library’s own contract is single: a quote is a { carrier, amount, days } object, the
amount in cents, and null for a shipment that cannot be carried. The zone table and
sample shipments are the library’s own property, shared by both arrangements.
// zone.mjs — the library's own zone table and sample shipments const POSTAL_ZONE = { 34: "near", "06": "mid", 35: "mid", 65: "far" }; export const findZone = (postalCode) => POSTAL_ZONE[postalCode.slice(0, 2)] ?? "unknown"; export const SAMPLE = [ { code: "G-1", weight: 0.8, postalCode: "34100" }, { code: "G-2", weight: 12, postalCode: "06500" }, { code: "G-3", weight: 26, postalCode: "65100" }, { code: "G-4", weight: 3, postalCode: "48200" }, { code: "G-5", weight: 8, postalCode: "35400" }, ];
The Arrangement Where Translation Spreads to the Client
In the first arrangement, every caller of a provider does its own translation: the module ranking quotes carries the gram conversion, the lira conversion, and two separate error formats in its own body.
// direct/selection.mjs — selection translates both providers' dictionaries and units itself import { calculateCost } from "../provider/ground.mjs"; import { quotePrice } from "../provider/air.mjs"; import { findZone } from "../zone.mjs"; export function quotes(shipment) { const out = []; const g = calculateCost({ weightGrams: Math.round(shipment.weight * 1000), destinationCode: shipment.postalCode }); if (g !== null) out.push({ carrier: "ground", amount: g.costCents, days: g.dayCount }); try { const a = quotePrice({ weightKg: shipment.weight, zoneName: findZone(shipment.postalCode) }); out.push({ carrier: "air", amount: Math.round(a.lira * 100), days: a.transferDays }); } catch (e) { if (!(e instanceof RangeError)) throw e; } return out.sort((a, b) => a.amount - b.amount); }
A second question — how many carriers can take a shipment — spawns a second copy of the same translation. What gets copied is not the unit but the fact that the “I do not carry this” statement takes two different forms.
// direct/coverage.mjs — the second copy of the same translation: how many carriers can carry it import { calculateCost } from "../provider/ground.mjs"; import { quotePrice } from "../provider/air.mjs"; import { findZone } from "../zone.mjs"; export function carrierCount(shipment) { let count = 0; if (calculateCost({ weightGrams: Math.round(shipment.weight * 1000), destinationCode: shipment.postalCode }) !== null) count += 1; try { quotePrice({ weightKg: shipment.weight, zoneName: findZone(shipment.postalCode) }); count += 1; } catch (e) { if (!(e instanceof RangeError)) throw e; } return count; }
Solution: One Adapter per Provider
The pattern’s solution places, in front of each provider, an object that satisfies the library’s contract. The adapter translates three things: field names, units, and the error format.
// adapter/ground-adapter.mjs — translates the grams/cents dictionary to the library's quote contract import { calculateCost } from "../provider/ground.mjs"; export const groundAdapter = { quote(shipment) { const g = calculateCost({ weightGrams: Math.round(shipment.weight * 1000), destinationCode: shipment.postalCode }); return g === null ? null : { carrier: "ground", amount: g.costCents, days: g.dayCount }; }, };
// adapter/air-adapter.mjs — translates the lira/zone dictionary and the error to the same contract import { quotePrice } from "../provider/air.mjs"; import { findZone } from "../zone.mjs"; export const airAdapter = { quote(shipment) { try { const a = quotePrice({ weightKg: shipment.weight, zoneName: findZone(shipment.postalCode) }); return { carrier: "air", amount: Math.round(a.lira * 100), days: a.transferDays }; } catch (e) { if (e instanceof RangeError) return null; throw e; } }, };
The two modules calling a provider now see a single contract and know no provider names at all.
// adapter/selection.mjs — no provider name passes through the dictionary export const quotes = (shipment, adapters) => adapters .map((a) => a.quote(shipment)) .filter((q) => q !== null) .sort((a, b) => a.amount - b.amount);
// adapter/coverage.mjs — counts through the single contract import { quotes } from "./selection.mjs"; export const carrierCount = (shipment, adapters) => quotes(shipment, adapters).length;
// adapter/main.mjs — composition root: only this file knows which adapters are used import { groundAdapter } from "./ground-adapter.mjs"; import { airAdapter } from "./air-adapter.mjs"; export const ADAPTERS = [groundAdapter, airAdapter];
For the comparison to mean anything, both arrangements must give the same answer for the same shipments.
// run.mjs — do the two arrangements give the same quote order and the same carrier count? import { SAMPLE } from "./zone.mjs"; import { quotes as directQuotes } from "./direct/selection.mjs"; import { carrierCount as directCount } from "./direct/coverage.mjs"; import { quotes as adapterQuotes } from "./adapter/selection.mjs"; import { carrierCount as adapterCount } from "./adapter/coverage.mjs"; import { ADAPTERS } from "./adapter/main.mjs"; let mismatched = 0; for (const g of SAMPLE) { const a = `${JSON.stringify(directQuotes(g))} carriers=${directCount(g)}`; const b = `${JSON.stringify(adapterQuotes(g, ADAPTERS))} carriers=${adapterCount(g, ADAPTERS)}`; if (a !== b) mismatched += 1; console.log(`${g.code} ${a}`); } console.log(`mismatched results = ${mismatched} / ${SAMPLE.length}`);
G-1 [{"carrier":"air","amount":4662,"days":1},{"carrier":"ground","amount":4990,"days":2}] carriers=2
G-2 [{"carrier":"air","amount":16530,"days":1},{"carrier":"ground","amount":17239,"days":2}] carriers=2
G-3 [{"carrier":"ground","amount":36236,"days":4}] carriers=1
G-4 [{"carrier":"ground","amount":14009,"days":4}] carriers=1
G-5 [{"carrier":"air","amount":13170,"days":1},{"carrier":"ground","amount":17988,"days":2}] carriers=2
mismatched results = 0 / 5
Same order, same amount, same carrier count across five shipments — including an out-of-tier weight (G-3) and a postal code missing from the table (G-4). The two arrangements do not diverge in behavior.
Measuring Coupling
The measure is the one used by the Keeping Framework Code at Arm’s Length lesson in the Design Principles course: files where at least one name from the external dictionary appears. A second column is added — how many places repeat the unit conversion.
// dictionary-count.mjs — counts files with a name from the provider dictionary and unit conversions import { readdirSync, readFileSync } from "node:fs"; const DICTIONARY = ["calculateCost", "weightGrams", "destinationCode", "costCents", "dayCount", "quotePrice", "weightKg", "zoneName", "lira", "transferDays"]; const CONVERSION = [/\* 1000/g, /lira \* 100/g]; for (const dir of ["direct", "adapter"]) { const files = readdirSync(dir).filter((d) => d.endsWith(".mjs")).sort(); let touched = 0; console.log(`${dir}/`); for (const d of files) { const text = readFileSync(`${dir}/${d}`, "utf8"); const name = DICTIONARY.filter((a) => new RegExp(`\\b${a}\\b`).test(text)).length; const conversion = CONVERSION.reduce((t, k) => t + (text.match(k)?.length ?? 0), 0); if (name > 0) touched += 1; console.log(` ${d.padEnd(24)} provider names=${String(name).padStart(2)} unit conversions=${conversion}`); } console.log(` files touching dictionary = ${touched} / ${files.length}`); }
direct/ coverage.mjs provider names= 6 unit conversions=1 selection.mjs provider names=10 unit conversions=2 files touching dictionary = 2 / 2 adapter/ air-adapter.mjs provider names= 5 unit conversions=1 coverage.mjs provider names= 0 unit conversions=0 ground-adapter.mjs provider names= 5 unit conversions=1 main.mjs provider names= 0 unit conversions=0 selection.mjs provider names= 0 unit conversions=0 files touching dictionary = 2 / 5
In the first arrangement both files touch the dictionary; in the second, two of five — the adapters themselves; selection and coverage touch zero names. The second column also shows the kind of coupling: unit conversion appears in three places in the first arrangement and two in the second, and those two are the same provider’s single conversion point, not a copy of the same translation.
That lesson used the adapter as an isolation tool: one file translating a framework’s context object into the library’s plain request shape, control running the other way. Here the situation differs — more than one provider, control belongs to the library, and the translation’s purpose is not isolation but comparability under a shared contract. Ranking five shipments’ quotes in a single list is a consequence of that shared contract.
When a Third Provider Is Added
The numbers pay off when a change arrives. The third provider resembles neither of the
first two: positional arguments, an array return, undefined when it does not carry the
shipment. Both trees are copied and the change applied to each.
mkdir -p new && cp -r provider zone.mjs direct adapter run.mjs new/ ls new
adapter direct provider run.mjs zone.mjs
// new/provider/sea.mjs — third external provider: takes positional arguments, returns an array, undefined when not covered const PORT = { 34: 1, 35: 1.1 }; export function quoteFreight(weightKg, postalCode) { const multiplier = PORT[postalCode.slice(0, 2)]; if (multiplier === undefined) return undefined; return [Math.round((2600 + weightKg * 260) * multiplier), 6]; }
// new/adapter/sea-adapter.mjs — translates the positional arguments and array response to the same contract import { quoteFreight } from "../provider/sea.mjs"; export const seaAdapter = { quote(shipment) { const d = quoteFreight(shipment.weight, shipment.postalCode); return d === undefined ? null : { carrier: "sea", amount: d[0], days: d[1] }; }, };
The only edit needed in the adapter arrangement is the list in the composition root; in the
direct arrangement, a third error format enters the body of two files. The script below
applies both edits, runs both trees, and counts the difference — the in-place edit carries
a backup extension, so GNU and BSD sed behave the same way.
cd new sed -i.y -e 's#^import { findZone } from "../zone.mjs";#&\nimport { quoteFreight } from "../provider/sea.mjs";#' direct/selection.mjs direct/coverage.mjs sed -i.y 's#^ return out.sort# const d = quoteFreight(shipment.weight, shipment.postalCode);\n if (d !== undefined) out.push({ carrier: "sea", amount: d[0], days: d[1] });\n&#' direct/selection.mjs sed -i.y 's#^ return count;# if (quoteFreight(shipment.weight, shipment.postalCode) !== undefined) count += 1;\n&#' direct/coverage.mjs sed -i.y -e 's#^import { airAdapter } from "./air-adapter.mjs";#&\nimport { seaAdapter } from "./sea-adapter.mjs";#' \ -e 's#airAdapter\];#airAdapter, seaAdapter];#' adapter/main.mjs rm -f direct/*.y adapter/*.y node run.mjs | head -2 for d in direct adapter; do echo "$d" echo " edited existing file = $(diff -rq "../$d" "$d" | grep -c '^Files ')" echo " new file = $(diff -rq "../$d" "$d" | grep -c '^Only in ')" echo " added lines = $(diff -rN "../$d" "$d" | grep '^>' | grep -cvE '^> *(//|$)')" done
G-1 [{"carrier":"sea","amount":2808,"days":6},{"carrier":"air","amount":4662,"days":1},{"carrier":"ground","amount":4990,"days":2}] carriers=3
G-2 [{"carrier":"air","amount":16530,"days":1},{"carrier":"ground","amount":17239,"days":2}] carriers=2
direct
edited existing file = 2
new file = 0
added lines = 5
adapter
edited existing file = 1
new file = 1
added lines = 9
Two existing files were edited in the direct arrangement, one in the adapter arrangement — and that one is the composition root, carrying no business rule. The adapter arrangement added more lines (9 versus 5), because the new provider’s translation stands as a complete object in its own file. The Open–Closed Principle distinction holds here too: an added line needs no re-verification, an edited line does. The two edited bodies in the direct arrangement also held the error paths of two already-correct providers.
Cost and When Not to Apply It
The pattern’s cost can be counted on the same scale.
for d in direct adapter; do echo "$d: $(ls $d/*.mjs | wc -l | tr -d ' ') files, $(cat $d/*.mjs | grep -cvE '^ *(//)?$') lines" done
direct: 2 files, 31 lines adapter: 5 files, 35 lines
Three extra files, four extra lines, plus a level of indirection. Tracing a quote means
reading two files in the direct arrangement (selection, provider) and three in the adapter
arrangement (selection, adapter, provider). Because the adapter list is injected, the
adapter does not even show up in the import graph: adapter/selection.mjs imports no
provider — the binding is established at run time, in the composition root. Debugging means
tracing more calls.
The pattern does not pay off in three situations. If the provider is single and its contract is already under the library’s control, pulling out the translation only adds a file. If the provider’s dictionary already overlaps the library’s, the adapter body drops to a layer copying field names one for one. The third bites more subtly: an adapter passes through only what it maps. A capability with no counterpart in the contract — split shipment, a delivery-time window — cannot be used by the business rule; using it first requires growing the shared contract, with every adapter answering for that field. Its cost grows in direct proportion to the provider count.
Summary
- The problem the adapter solves is the mismatch between an interface the library does not determine and the library’s own contract; the solution is to gather the translation in a single object per provider.
- Translation covers three things: field names, units, and the format of the “I do not
carry this” statement — the last one is the most copied piece of knowledge because it is
the difference between
nulland an error. - The number of files touching the external dictionary is 2/2 in the direct arrangement and 2/5 in the adapter arrangement, and the two files that touch it are the adapters themselves; unit conversion drops from 3 places to 2.
- When a third provider was added, 2 existing files were edited in the direct arrangement; in the adapter arrangement, 1 (the composition root) was edited and 1 new file was added; the responses for five shipments stayed the same across both arrangements.
- The cost is 3 files, 4 lines, and one level of indirection; this cost goes unpaid for when the provider is single, its dictionary already overlaps, or its capabilities do not fit the shared contract.
Next Step
The adapter fitted an existing interface after the fact: the providers were already written, and so was the library’s contract; the pattern stepped in between the two. The next problem shows up at the design stage itself, before anything is written. A fee report has two independent axes: type (summary, tier breakdown, zone breakdown) and output format (plain text, delimited values, aligned table). Combining both through inheritance produces a class count that is the product of the two. The next lesson counts this product for three types and two formats, writes the bridge pattern that splits the axes into separate hierarchies, and compares the added type and lines across both arrangements when a third format is added.
To keep your progress and take notes, Log in
My notes
Log in to take notes.