Lesson 04 / 18
Component-Based Architecture
Moving the unit from the ring to the releasable component: counting the number of names each component exposes and contains, comparing the co-release ratio across two partitions of the same files, measuring how a consumer that bypasses the surface raises the exposed name count, and counting the files a new capability edits, adds, and forces to re-release.
Contents
The previous lesson’s measures were all about the ring, and a ring is a unit of rule. Which files get packaged together, which can be released on their own, and how many names a consumer binds to for one capability did not show up.
This lesson changes the unit. A component here is a separately packaged, separately released set of files with declared exposed names; in the Component-Based Interface Development curriculum, a component was a view function drawing a piece of the screen — the same word names two different units. The measures change too: the number of names a component exposes, the number it contains, the co-release ratio of two components, and the number of files a new capability edits.
Component as the Unit of Release
Where a component’s boundary runs was established in the Design Principles course, in the Component Cohesion Principles and Component Coupling Principles lessons: the reuse–release equivalence, common closure, common reuse, and stable dependencies principles. Those principles are not retold here; they are applied as a pattern.
The pattern’s load-bearing decision is this: each component declares its exposed names in a single file and keeps everything else inside itself. This decision separates two counts. An exposed name is a name a file outside the component imports from it. A contained name is a name the component exports that nothing outside it imports. The second is the freedom margin: changing a contained name breaks no consumer.
The Library’s Components
The library splits into five components. Three sit in the pricing context (shared, zone,
fee), one in the delivery operation (route), and one combines both into a single request
(desk). Each component’s surface.mjs file declares that component’s surface.
mkdir -p components/shared components/zone components/fee components/route components/desk
// components/shared/surface.mjs — shared component: currency conversion export const cents = (lira) => Math.round(lira * 100); export const format = (c) => `${(c / 100).toFixed(2)} TL`;
// components/zone/mapping.mjs — zone component internal: postal code equivalent and coefficients export const POSTAL_ZONE = { 34: "near", "06": "mid", 35: "mid", 65: "far" }; export const COEFFICIENT = { near: 1, mid: 1.35, far: 1.8 }; export const findZone = (postalCode) => POSTAL_ZONE[postalCode.slice(0, 2)] ?? "far";
// components/zone/surface.mjs — the zone component's exposed surface import { COEFFICIENT, findZone } from "./mapping.mjs"; export const zone = (postalCode) => findZone(postalCode); export const coefficient = (postalCode) => COEFFICIENT[findZone(postalCode)];
// components/fee/tariff.mjs — fee component internal: tiers, minimum amount, rounding import { cents } from "../shared/surface.mjs"; export const TIERS = [[1, 49.9], [5, 84.9], [15, 149.9], [30, 249.9]] .map(([weightCap, lira]) => ({ weightCap, fee: cents(lira) })); export const MINIMUM = cents(39.9); export const round = (c) => Math.round(c / 50) * 50;
// components/fee/rule.mjs — fee component internal: fee rule import { MINIMUM, TIERS, round } from "./tariff.mjs"; export const fee = (shipment, coefficient) => { const tier = TIERS.find((t) => shipment.weight <= t.weightCap); if (tier === undefined) throw new RangeError("no weight tier"); return Math.max(round(tier.fee * coefficient), MINIMUM); };
// components/fee/surface.mjs — the fee component's exposed surface import { coefficient } from "../zone/surface.mjs"; import { format } from "../shared/surface.mjs"; import { fee } from "./rule.mjs"; export const quote = (shipment) => { const amount = fee(shipment, coefficient(shipment.postalCode)); return { amount, display: format(amount) }; };
// components/route/carrier.mjs — route component internal: carrier list and coverage export const CARRIERS = [ { name: "north", coverage: ["near", "mid"], transfer: 1 }, { name: "general", coverage: ["near", "mid", "far"], transfer: 2 }, ]; export const eligible = (z) => CARRIERS.filter((c) => c.coverage.includes(z));
// components/route/surface.mjs — the route component's exposed surface import { zone } from "../zone/surface.mjs"; import { eligible } from "./carrier.mjs"; export const buildRoute = (shipment) => { const selected = eligible(zone(shipment.postalCode)) .sort((x, y) => x.transfer - y.transfer)[0]; if (selected === undefined) throw new RangeError("no eligible carrier"); return { carrier: selected.name, transfer: selected.transfer }; };
// components/desk/surface.mjs — desk component: dispatches an external request to two components import { buildRoute } from "../route/surface.mjs"; import { quote } from "../fee/surface.mjs"; export const request = (shipment) => ({ ...quote(shipment), ...buildRoute(shipment) });
Exposed and Contained Names
The tool reads the names each file exports and the names in its import lines, then groups the
same files by two partitions: by capability (directory name) and by role (surface.mjs
interface, rule.mjs rule, the rest data). The files and imports are identical across both
partitions; only where the boundary runs changes.
// surface-measure.mjs — measures the exported, exposed and contained name count of each unit import { readdirSync, readFileSync } from "node:fs"; import { join, normalize } from "node:path"; const EXPORT = /^export const (\w+)/gm; const IMPORT = /^import\s*\{([^}]*)\}\s*from\s+"([^"]+)"/gm; export const PARTITIONS = { capability: (f) => f.dir, role: (f) => (f.name === "surface.mjs" ? "interface" : f.name === "rule.mjs" ? "rule" : "data"), }; export function read(root) { const files = []; for (const dir of readdirSync(root).sort()) { for (const name of readdirSync(join(root, dir)).sort()) { const text = readFileSync(join(root, dir, name), "utf8"); files.push({ path: `${dir}/${name}`, dir, name, exports: [...text.matchAll(EXPORT)].map((m) => m[1]), imports: [...text.matchAll(IMPORT)].map((m) => ({ names: m[1].split(",").map((s) => s.trim()).filter((s) => s !== ""), target: normalize(join(dir, m[2])), })), }); } } return files; } if (import.meta.filename === process.argv[1]) { const root = process.argv[2]; const files = read(root); const totalExports = files.reduce((n, f) => n + f.exports.length, 0); console.log(`${root}/ file = ${files.length}, total exports = ${totalExports}`); for (const [partitionName, unitOf] of Object.entries(PARTITIONS)) { const exportCount = new Map(); const exposed = new Map(); for (const f of files) { exportCount.set(unitOf(f), (exportCount.get(unitOf(f)) ?? 0) + f.exports.length); exposed.set(unitOf(f), exposed.get(unitOf(f)) ?? new Set()); } for (const f of files) { for (const i of f.imports) { const target = files.find((d) => d.path === i.target); if (target === undefined || unitOf(target) === unitOf(f)) continue; for (const n of i.names) exposed.get(unitOf(target)).add(n); } } console.log(` ${partitionName} partition (${exportCount.size} units)`); let totalExposed = 0; for (const u of [...exportCount.keys()].sort()) { const e = exposed.get(u).size; totalExposed += e; console.log(` ${u.padEnd(9)} exports ${String(exportCount.get(u)).padStart(2)}` + ` exposed ${String(e).padStart(2)} contained ${String(exportCount.get(u) - e).padStart(2)}`); } console.log(` ${"total".padEnd(9)} exports ${String(totalExports).padStart(2)}` + ` exposed ${String(totalExposed).padStart(2)} contained ${String(totalExports - totalExposed).padStart(2)}`); } }
node surface-measure.mjs components
components/ file = 9, total exports = 16
capability partition (5 units)
desk exports 1 exposed 0 contained 1
fee exports 5 exposed 1 contained 4
route exports 3 exposed 1 contained 2
shared exports 2 exposed 2 contained 0
zone exports 5 exposed 2 contained 3
total exports 16 exposed 6 contained 10
role partition (3 units)
data exports 8 exposed 6 contained 2
interface exports 7 exposed 1 contained 6
rule exports 1 exposed 1 contained 0
total exports 16 exposed 8 contained 8
Of sixteen exported names, 6 are exposed and 10 are contained in the capability partition; in the
role partition, 8 are exposed and 8 are contained. The data unit is forced to expose six of its
eight names, because the boundary separating it from the rule and the interface runs exactly
where those names pass. The fee component, in contrast, keeps four of its five names inside;
only quote reaches outward. The desk component’s exposed name count is 0: no other component
depends on it, putting it at the upper end of the Component Coupling Principles lesson’s
instability measure.
The Consumer That Bypasses the Surface
The exposed name count is not a property of the component alone; it is a shared property of the component and its consumers. The block below adds a component to a copy of the tree that bypasses surfaces and binds directly into its neighbors’ internals.
cp -r components deep-tree mkdir -p deep-tree/deep cat > deep-tree/deep/surface.mjs <<'EOF' // deep-tree/deep/surface.mjs — a consumer that bypasses surfaces and reaches into its neighbors import { COEFFICIENT, findZone } from "../zone/mapping.mjs"; import { format } from "../shared/surface.mjs"; import { eligible } from "../route/carrier.mjs"; import { fee } from "../fee/rule.mjs"; export const request = (shipment) => { const z = findZone(shipment.postalCode); const amount = fee(shipment, COEFFICIENT[z]); return { amount, display: format(amount), carrier: eligible(z)[0].name }; }; EOF node surface-measure.mjs deep-tree | head -9 node -e "import('./deep-tree/deep/surface.mjs').then(m => console.log('deep consumer ' + JSON.stringify(m.request({ weight: 3, postalCode: '06800' }))))"
deep-tree/ file = 10, total exports = 17
capability partition (6 units)
deep exports 1 exposed 0 contained 1
desk exports 1 exposed 0 contained 1
fee exports 5 exposed 2 contained 3
route exports 3 exposed 2 contained 1
shared exports 2 exposed 2 contained 0
zone exports 5 exposed 4 contained 1
total exports 17 exposed 10 contained 7
deep consumer {"amount":11450,"display":"114.50 TL","carrier":"north"}
The consumer runs correctly and produces the same amount as the path through the surface: 11,450
cents. What changes is the boundary. The exposed name count rose from 6 to 10, the contained name
count fell from 10 to 7; the zone component’s contained name count dropped from 3 to 1. A
single file spent the freedom margin of three components it never touched. This is the common
reuse principle’s measurable counterpart: a deep import binds the consumer to names outside the
component’s version contract.
Co-Release Ratio
The second measure concerns release. When a file changes, every file that imports it is retested and released, so the import graph determines which units a change forces to re-release. Two units’ co-release ratio is the number of files that force both of them to re-release divided by the number of files that force at least one of them to re-release.
// release-measure.mjs — counts which units must be re-released when a file changes import { PARTITIONS, read } from "./surface-measure.mjs"; const [root, target] = process.argv.slice(2); const files = read(root); const importedBy = new Map(files.map((f) => [f.path, []])); for (const f of files) { for (const i of f.imports) if (importedBy.has(i.target)) importedBy.get(i.target).push(f.path); } function closure(path) { const seen = new Set([path]); for (const p of seen) for (const k of importedBy.get(p)) seen.add(k); return seen; } for (const [partitionName, unitOf] of Object.entries(PARTITIONS)) { const unitOfPath = new Map(files.map((f) => [f.path, unitOf(f)])); const affected = files.map((f) => new Set([...closure(f.path)].map((p) => unitOfPath.get(p)))); const units = [...new Set(unitOfPath.values())].sort(); console.log(`${root}/ ${partitionName} partition (${units.length} units)`); for (const u of units) { console.log(` ${u.padEnd(9)} file ${files.filter((f) => unitOf(f) === u).length}` + ` re-released file ${affected.filter((e) => e.has(u)).length} / ${files.length}`); } let total = 0, pairs = 0, highest = ["none", 0]; for (let i = 0; i < units.length; i += 1) { for (let j = i + 1; j < units.length; j += 1) { const [a, b] = [units[i], units[j]]; const ratio = affected.filter((e) => e.has(a) && e.has(b)).length / affected.filter((e) => e.has(a) || e.has(b)).length; total += ratio; pairs += 1; if (ratio > highest[1]) highest = [`${a}+${b}`, ratio]; } } console.log(` pairs ${pairs}, average co-release ratio ${(total / pairs).toFixed(2)}` + `, highest ${highest[0]} ${highest[1].toFixed(2)}`); if (target !== undefined) { const set = new Set([...closure(target)].map((p) => unitOfPath.get(p))); const fileCount = files.filter((f) => set.has(unitOf(f))).length; console.log(` ${target} changes: ${set.size} / ${units.length} units,` + ` ${fileCount} / ${files.length} files (${[...set].sort().join(", ")})`); } }
node release-measure.mjs components
components/ capability partition (5 units) desk file 1 re-released file 9 / 9 fee file 3 re-released file 6 / 9 route file 2 re-released file 4 / 9 shared file 1 re-released file 1 / 9 zone file 2 re-released file 2 / 9 pairs 10, average co-release ratio 0.27, highest desk+fee 0.67 components/ role partition (3 units) data file 3 re-released file 4 / 9 interface file 5 re-released file 9 / 9 rule file 1 re-released file 3 / 9 pairs 3, average co-release ratio 0.39, highest data+interface 0.44
The average co-release ratio is 0.27 in the capability partition and 0.39 in the role partition.
The sharper number is in the table: in both partitions, one unit re-releases on all nine files,
but in the capability partition that unit is the 1-file desk, and in the role partition it is
the 5-file interface. The re-released piece is five times larger for every change. The zone
component, by contrast, re-releases on only two of the nine files; this line is the common
closure principle’s counterpart.
A New Capability
The third measure is extension cost. A fuel surcharge is added to pricing: the rate goes into its own file, and the rule applies it.
cp -r components fueled cat > fueled/fee/surcharge.mjs <<'EOF' // fueled/fee/surcharge.mjs — fee component internal: fuel surcharge rate export const FUEL_SURCHARGE_RATE = 0.06; export const addFuelSurcharge = (c) => c * (1 + FUEL_SURCHARGE_RATE); EOF cat > fueled/fee/rule.mjs <<'EOF' // fueled/fee/rule.mjs — fee component internal: fee rule, with fuel surcharge import { MINIMUM, TIERS, round } from "./tariff.mjs"; import { addFuelSurcharge } from "./surcharge.mjs"; export const fee = (shipment, coefficient) => { const tier = TIERS.find((t) => shipment.weight <= t.weightCap); if (tier === undefined) throw new RangeError("no weight tier"); return Math.max(round(addFuelSurcharge(tier.fee * coefficient)), MINIMUM); }; EOF diff -rq components fueled node surface-measure.mjs fueled | head -8 node release-measure.mjs fueled fee/rule.mjs | grep changes node -e "import('./components/desk/surface.mjs').then(m => console.log('no fuel ' + JSON.stringify(m.request({ weight: 3, postalCode: '06800' }))))" node -e "import('./fueled/desk/surface.mjs').then(m => console.log('with fuel ' + JSON.stringify(m.request({ weight: 3, postalCode: '06800' }))))"
Files components/fee/rule.mjs and fueled/fee/rule.mjs differ
Only in fueled/fee: surcharge.mjs
fueled/ file = 10, total exports = 18
capability partition (5 units)
desk exports 1 exposed 0 contained 1
fee exports 7 exposed 1 contained 6
route exports 3 exposed 1 contained 2
shared exports 2 exposed 2 contained 0
zone exports 5 exposed 2 contained 3
total exports 18 exposed 6 contained 12
fee/rule.mjs changes: 2 / 5 units, 5 / 10 files (desk, fee)
fee/rule.mjs changes: 2 / 3 units, 6 / 10 files (interface, rule)
no fuel {"amount":11450,"display":"114.50 TL","carrier":"north","transfer":1}
with fuel {"amount":12150,"display":"121.50 TL","carrier":"north","transfer":1}
The change edited one file and added one file. The fee component’s exported name count rose
from 5 to 7, but its exposed name count stayed at 1: the two new names were written to the
contained side, the surface did not change, and no consumer was edited. The re-released piece is
2 of 5 units and 5 of 10 files in the capability partition, and 2 of 3 units and 6 of 10 files in
the role partition. The delivery operation side was untouched: the carrier and transfer fields
stayed the same in both runs, and only the amount rose from 11,450 to 12,150.
The cost’s count is here too. Five components mean five surface files and five versions; even
desk, which exports a single name, is kept as a separate release unit. Narrowing the surface
raises the contained name count, and adding components raises the number of units to release —
the trade-off between maintainability and deployment simplicity is these two numbers.
Summary
- A component here is a set of files that is separately packaged, separately released, and declares the names it exposes in a single surface file; the contained name count is that component’s freedom margin.
- When the same nine files are split by capability, 6 of 16 names are exposed; split by role, it
is 8;
feekeeps four of its five names inside,datakeeps only two of its eight. - A single consumer bypassing surfaces raised the exposed name count of the components from 6 to
10 and dropped the
zonecomponent’s contained name count from 3 to 1; the amount it produced was the same as the path through the surface. - The average co-release ratio is 0.27 by capability and 0.39 by role; the unit re-released on every change is 1 file in the capability partition and 5 files in the role partition.
- The fuel surcharge caused 1 file to be edited, 1 file to be added, left the exposed name count at 6, and forced 2 of 5 units to re-release in the capability partition and 2 of 3 units in the role partition.
Next Step
The component boundary produced a unit of reuse: adding a new capability to the fee component
left the surface unchanged and four components untouched. But these measures are all at
release time. Which of these units can be plugged in and out while the program is
running? The desk component imports two components by name, so a third capability still
requires editing it; the component boundary leaves this question open. The next lesson splits
units into two classes: an unchanging core that can run on its own, and
plugins that attach to it through a registry. The measures change too: the number of names the
core knows about its plugins, the number of lines a new plugin edits in the core, and whether the
core runs without any plugins attached.
To keep your progress and take notes, Log in
My notes
Log in to take notes.