---
title: 'Component-Based Architecture'
source: 'https://academia.sh/en/courses/architectural-styles/component-based-architecture'
course: 'Architectural Styles'
language: en
updated: '2026-08-23T07:01:10+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```sh
mkdir -p components/shared components/zone components/fee components/route components/desk
```

```js
// 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`;
```

```js
// 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";
```

```js
// 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)];
```

```js
// 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;
```

```js
// 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);
};
```

```js
// 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) };
};
```

```js
// 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));
```

```js
// 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 };
};
```

```js
// 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.

```js
// 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)}`);
  }
}
```

```sh
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.

```sh
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.

```js
// 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(", ")})`);
  }
}
```

```sh
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.

```sh
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; `fee` keeps four of its five names inside, `data` keeps 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 `zone` component'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.
