Skip to content
academia.sh

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.

Start typing to search.

↑↓ Esc navigate · open · close