Lesson 16 / 19
Keeping Framework Code at Arm's Length
Isolating a framework dependency: counting, under two layouts, the files that touch a small external tool's contract; comparing how many files must be fixed in each layout when the tool's contract changes; and measuring the adapter's cost in files and lines.
Contents
The previous lesson measured the contract surface with three numbers and showed that the narrow boundary protected the library’s own internal naming. Both sides of that boundary were the library’s own code: the library wrote the contract, and the library also decided to narrow it.
Outside the library stands a class of code whose contract someone else wrote — tools that handle requests, match paths, call the handler. These impose a contract the library is not consulted on when it changes. This lesson counts, under two layouts, how many files touch such a tool’s contract, then changes the contract and compares which files need fixing.
The Difference Between a Framework and a Library
The split is not in function, it is in the direction of control. A library is called: the side writing the order of calls is the library. A framework calls: what gets written is a handler in the shape it expects, and the framework decides when it runs. The Hollywood principle from the Coupling and Cohesion topic is the name for this direction; the same inversion was established on the interface side in the Component-Based Interface Development course.
The cost of the reversed direction is that the dependency does not stay put in a signature. If a library changes, the call sites get fixed, and they can be searched for. If a framework changes, every place reading fields off the context object it hands over gets fixed. How many such places there are is not a product choice, it is a measurable design decision.
A small outside tool is written for the measurement: a router that matches path patterns and calls the matching handler with its own context object, in a six-name vocabulary.
mkdir -p tool direct adapter
// tool/request-router.mjs — external tool v1: matches path patterns, calls the handler with its own context export function createRouter() { const registrations = []; const matchPattern = (pattern, path) => { const d = pattern.split("/"), p = path.split("/"); if (d.length !== p.length) return null; const captured = {}; for (let i = 0; i < d.length; i += 1) { if (d[i][0] === ":") captured[d[i].slice(1)] = p[i]; else if (d[i] !== p[i]) return null; } return captured; }; return { addPattern: (pattern, handler) => registrations.push([pattern, handler]), handleRequest(path, fields = {}) { for (const [pattern, handler] of registrations) { const match = matchPattern(pattern, path); if (match === null) continue; let response = null; handler({ params: match, query: fields, reply: (code, body) => { response = { code, body }; } }); return response ?? { code: 500, body: { error: "no response produced" } }; } return { code: 404, body: { error: "path not found" } }; }, }; }
The fee tables are shared between both layouts and never mention the tool.
// tariff.mjs — the tables both layouts use; never mentions the tool const TIERS = [ { maxWeight: 1, fee: 4990 }, { maxWeight: 5, fee: 8490 }, { maxWeight: 15, fee: 14990 }, { maxWeight: 30, fee: 24990 }, ]; const ZONE_FACTOR = { near: 1, mid: 1.35, far: 1.8 }; const POSTAL_ZONE = { 34: "near", "06": "mid", 35: "mid", 65: "far" }; const MINIMUM_FEE = 3990; export function baseFee(weight, postalCode) { const tier = TIERS.find((t) => weight <= t.maxWeight); if (tier === undefined) return null; const factor = ZONE_FACTOR[POSTAL_ZONE[postalCode.slice(0, 2)] ?? "far"]; return Math.max(Math.round((tier.fee * factor) / 50) * 50, MINIMUM_FEE); }
The Directly Bound Layout
In the first layout, the business rule reads the tool’s context directly and hands its response back the same way. Even validation is bound to the tool’s vocabulary.
// direct/validation.mjs — validation also returns its response through the tool's context export function validateWeight(context) { const weight = Number(context.params.weight); if (Number.isFinite(weight) && weight > 0) return weight; context.reply(400, { error: "invalid weight" }); return null; }
// direct/fee-endpoint.mjs — the calculation reads from and writes to the tool's context directly import { baseFee } from "../tariff.mjs"; import { validateWeight } from "./validation.mjs"; export function feeHandler(context) { const weight = validateWeight(context); if (weight === null) return; const amount = baseFee(weight, context.params.postalCode); if (amount === null) { context.reply(422, { error: "no weight tier" }); return; } const discount = Number(context.query.discount ?? 0); context.reply(200, { amount: Math.round(amount * (1 - discount)), currency: "cents" }); }
// direct/carrier-endpoint.mjs — a second endpoint, bound to the same context contract import { baseFee } from "../tariff.mjs"; const CARRIERS = [ { name: "fast", multiplier: 1.25 }, { name: "standard", multiplier: 1 }, { name: "economy", multiplier: 0.85 }, ]; export function carrierHandler(context) { const base = baseFee(Number(context.params.weight), context.params.postalCode); if (base === null) { context.reply(422, { error: "no weight tier" }); return; } const cheapest = CARRIERS .map((c) => ({ carrier: c.name, amount: Math.round(base * c.multiplier) })) .sort((a, b) => a.amount - b.amount)[0]; context.reply(200, cheapest); }
// direct/app.mjs — sets up the tool and registers the endpoints import { createRouter } from "../tool/request-router.mjs"; import { feeHandler } from "./fee-endpoint.mjs"; import { carrierHandler } from "./carrier-endpoint.mjs"; export function app() { const r = createRouter(); r.addPattern("/fee/:weight/:postalCode", feeHandler); r.addPattern("/carrier/:weight/:postalCode", carrierHandler); return (path, fields) => r.handleRequest(path, fields); }
The Layout Behind the Adapter
In the second layout, the business rule is a plain operation: (request) => response. Both the
request’s and the response’s field names are the library’s own choice, not the tool’s. A
single file does the translation between the tool and this shape; this file is called an
adapter.
// adapter/validation.mjs — validation returns a plain result, no tool name appears export function validateWeight(values) { const weight = Number(values.weight); if (Number.isFinite(weight) && weight > 0) return { valid: true, weight }; return { valid: false, response: { status: 400, content: { error: "invalid weight" } } }; }
// adapter/fee-operation.mjs — the operation takes a plain request, returns a plain response import { baseFee } from "../tariff.mjs"; import { validateWeight } from "./validation.mjs"; export function feeOperation(request) { const v = validateWeight(request.values); if (!v.valid) return v.response; const amount = baseFee(v.weight, request.values.postalCode); if (amount === null) return { status: 422, content: { error: "no weight tier" } }; const discount = Number(request.options.discount ?? 0); return { status: 200, content: { amount: Math.round(amount * (1 - discount)), currency: "cents" } }; }
// adapter/carrier-operation.mjs — second operation, same plain contract import { baseFee } from "../tariff.mjs"; const CARRIERS = [ { name: "fast", multiplier: 1.25 }, { name: "standard", multiplier: 1 }, { name: "economy", multiplier: 0.85 }, ]; export function carrierOperation(request) { const base = baseFee(Number(request.values.weight), request.values.postalCode); if (base === null) return { status: 422, content: { error: "no weight tier" } }; const cheapest = CARRIERS .map((c) => ({ carrier: c.name, amount: Math.round(base * c.multiplier) })) .sort((a, b) => a.amount - b.amount)[0]; return { status: 200, content: cheapest }; }
// adapter/tool-bridge.mjs — the one file touching the tool: translates its context to a plain request, plain response to its context import { createRouter } from "../tool/request-router.mjs"; export function setupApp(registrations) { const r = createRouter(); for (const [pattern, operation] of registrations) { r.addPattern(pattern, (context) => { const response = operation({ values: context.params, options: context.query }); context.reply(response.status, response.content); }); } return (path, fields) => r.handleRequest(path, fields); }
// adapter/app.mjs — registration file; no name from the tool's vocabulary appears import { setupApp } from "./tool-bridge.mjs"; import { feeOperation } from "./fee-operation.mjs"; import { carrierOperation } from "./carrier-operation.mjs"; export const app = () => setupApp([ ["/fee/:weight/:postalCode", feeOperation], ["/carrier/:weight/:postalCode", carrierOperation], ]);
For the comparison to mean anything, the two layouts have to answer the same requests the same way.
// run.mjs — do the two layouts answer the same requests the same way import { app as directApp } from "./direct/app.mjs"; import { app as bridged } from "./adapter/app.mjs"; const REQUESTS = [ ["/fee/3/06800", {}], ["/fee/12/65100", { discount: 0.1 }], ["/fee/0/34710", {}], ["/carrier/28/35400", {}], ["/route/1/34000", {}], ]; const a = directApp(), b = bridged(); let diverged = 0; for (const [path, query] of REQUESTS) { const x = JSON.stringify(a(path, query)), y = JSON.stringify(b(path, query)); if (x !== y) diverged += 1; console.log(`${path.padEnd(22)} ${x}`); } console.log(`diverged responses = ${diverged} / ${REQUESTS.length}`);
/fee/3/06800 {"code":200,"body":{"amount":11450,"currency":"cents"}}
/fee/12/65100 {"code":200,"body":{"amount":24300,"currency":"cents"}}
/fee/0/34710 {"code":400,"body":{"error":"invalid weight"}}
/carrier/28/35400 {"code":200,"body":{"carrier":"economy","amount":28688}}
/route/1/34000 {"code":404,"body":{"error":"path not found"}}
diverged responses = 0 / 5
The same status code and the same body for all five requests. Including the invalid weight, the off-tier weight, and the undefined path, the difference is not in the behavior.
The Number of Files Touching the Tool
The measure of isolation is the number of files carrying at least one name from the tool’s vocabulary. The vocabulary consists of six names: the constructor, the registration and execution names, and three fields of the context.
// count-touched-files.mjs — counts files carrying at least one name from the tool's vocabulary import { readdirSync, readFileSync } from "node:fs"; const VOCABULARY = ["createRouter", "addPattern", "handleRequest", "params", "query", "reply"]; for (const folder of ["direct", "adapter"]) { const files = readdirSync(folder).filter((d) => d.endsWith(".mjs")).sort(); let touched = 0; console.log(`${folder}/`); for (const f of files) { const text = readFileSync(`${folder}/${f}`, "utf8"); const matches = VOCABULARY.filter((name) => new RegExp(`\\b${name}\\b`).test(text)); if (matches.length > 0) touched += 1; console.log(` ${f.padEnd(24)} ${matches.length > 0 ? matches.join(" ") : "-"}`); } console.log(` files touching the tool = ${touched} / ${files.length}`); }
direct/ app.mjs createRouter addPattern handleRequest carrier-endpoint.mjs params reply fee-endpoint.mjs params query reply validation.mjs params reply files touching the tool = 4 / 4 adapter/ app.mjs - carrier-operation.mjs - fee-operation.mjs - tool-bridge.mjs createRouter addPattern handleRequest params query reply validation.mjs - files touching the tool = 1 / 5
In the first layout, all four files are bound to the tool; in the second, one of five. A
notable line is direct/validation.mjs: even without an endpoint in it, weight validation
ended up in the tool’s vocabulary. Binding to the framework does not stay at the endpoints; it
walks inward, following the error path.
When the Tool’s Contract Changes
Numbers only pay off once a change arrives. The tool’s second version changes three names on
the context: pathValues instead of params, queryValues instead of query, and a
single-object send instead of the two-argument reply. The new version is derived from the
first and placed in a separate tree.
mkdir -p v2 && cp -r tool direct adapter tariff.mjs run.mjs v2/ sed -e 's/params: match, query: fields,/pathValues: match, queryValues: fields,/' \ -e 's/reply: (code, body) => { response = { code, body }; }/send: ({ status, body }) => { response = { code: status, body }; }/' \ tool/request-router.mjs > v2/tool/request-router.mjs grep -n "pathValues\|send:" v2/tool/request-router.mjs
21: handler({ pathValues: match, queryValues: fields,
22: send: ({ status, body }) => { response = { code: status, body }; } });
The only place changing inside the tool itself is the two lines where the context object is
built. Now the same change is applied to both layouts and the fixed files are counted. The
in-place edit flag is spelled differently between BSD and GNU sed, so the -i.bak form is
used; it leaves a backup file alongside.
sed -i.bak -e 's/context\.params/context.pathValues/g' \ -e 's/context\.query/context.queryValues/g' \ -e 's/context\.reply(\([^,]*\), \(.*\))/context.send({ status: \1, body: \2 })/g' \ v2/direct/*.mjs v2/adapter/*.mjs for d in direct adapter; do n=0; t=0 for f in "$d"/*.mjs; do t=$((t + 1)); cmp -s "$f" "v2/$f" || n=$((n + 1)) done echo "$d: fixed files = $n / $t" done cd v2 && node run.mjs
direct: fixed files = 3 / 4
adapter: fixed files = 1 / 5
/fee/3/06800 {"code":200,"body":{"amount":11450,"currency":"cents"}}
/fee/12/65100 {"code":200,"body":{"amount":24300,"currency":"cents"}}
/fee/0/34710 {"code":400,"body":{"error":"invalid weight"}}
/carrier/28/35400 {"code":200,"body":{"carrier":"economy","amount":28688}}
/route/1/34000 {"code":404,"body":{"error":"path not found"}}
diverged responses = 0 / 5
Three files were fixed under the directly bound layout, one under the adapter-backed one. The
registration file direct/app.mjs did not change this time, because the changed names were
context fields, not the constructor or registration names. Had a version also changed the
tool’s constructor name, four files would have been fixed under the direct layout and still
just one under the adapter-backed one: the bridge’s count is independent of how large the
change to the tool’s contract is.
The two versions’ responses to the five requests stayed the same too; the migration did not change behavior.
The Cost of Isolation
The adapter is not free, and its 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 | wc -l | tr -d ' ') lines" done
direct: 4 files, 45 lines adapter: 5 files, 55 lines
One extra file and ten extra lines. In exchange, the number of files fixed on a change to the tool’s contract dropped from three to one. The fixed cost of ten lines is weighed against the two-file difference repeated at every contract change; if the tool’s contract never changes, isolation does not pay for itself.
The second cost is measured not in a number but in scope: the bridge passes through only what it translates. A capability the tool offers but the bridge does not map — a streaming response, request cancellation — cannot be used by the business rule until it is added to the bridge’s contract. This blocks reading the principle as “every framework must always be wrapped.” The reason for isolation is that the tool’s contract is outside the library’s control and has a history of changing; without that reason, the bridge is only a layer of indirection.
Summary
- A framework and a library are separated not by function but by the direction of control: a library is called, a framework calls; this is why the framework dependency does not stay at the call sites, it stands wherever the context gets read.
- The measure of isolation is the number of files carrying at least one name from the tool’s vocabulary: 4/4 under the directly bound layout, 1/5 under the adapter-backed one.
- Binding to the framework does not stay at the endpoints; it walked inward, following the error path, all the way to the validation module.
- When three names in the tool’s context contract changed, 3 files were fixed under the direct layout, 1 under the adapter-backed layout; the response to the five requests did not change in either layout.
- The cost of isolation is 1 file and 10 lines, and on top of that the bridge passes through only the capability it maps; if the tool’s contract does not change, this cost goes unrewarded.
Next Step
The three lessons so far discussed where to draw the boundary one file at a time: which file holds the policy, how many names the contract consists of, how many files touch the tool. Once a codebase grows, the unit stops being the file; a set of files packaged, versioned, and released together becomes a component. The question then is: which files should go into the same component? The next lesson answers this question from the change log — it writes a tool that clusters files that change together, measures the number of components and the number of links between components once the component boundary is drawn along these clusters, and compares how many components a single change touches and how many files a single capability binds to across three splits.
To keep your progress and take notes, Log in
My notes
Log in to take notes.