---
title: 'Architectural Styles'
source: 'https://academia.sh/en/courses/architectural-styles'
language: en
updated: '2026-08-23T07:01:08+00:00'
license: 'CC BY-SA 4.0'
lessons: 18
outcomes:
    - 'Compares common architectural styles through quality attributes'
    - 'Chooses and justifies a style against the constraints of a context'
    - 'Judges the cost of moving from one style to another'
---

# Architectural Styles

Counting the difference between different arrangements of the same software through quality attributes: layered, hexagonal, clean, component-based, plugin, and blackboard arrangements; client-server, peer-to-peer, model-view, pipes-and-filters, publish-subscribe, and queue-based interactions; monolithic, service-oriented, microservice, event-driven, and serverless deployment arrangements, together with the trade-off analysis behind choosing a style.

## Structural Styles

Arranging the units inside a program and the direction of dependency between them: what a layer rule is and what a leak looks like, ring arrangements where dependency turns inward, merging layer and ring into a single rule, the boundary of deployable software components, separating core from extension, and working through a shared blackboard for ill-structured problems.

### Layered Architecture

Source: https://academia.sh/en/courses/architectural-styles/layered-architecture

Writing the layered style as a declared set of permitted edges: splitting the shipment fee library into four layers, measuring the layers a request touches and the boundaries it crosses, counting the layers that pass a request through unchanged, and separating a declared open layer from an undeclared leak by violation count.

The Domain-Driven Design course left one question open: every decision up to that point sat
inside a single application. The context map named several bounded contexts, but all lived in
the same running program. When contexts are split across separate programs, or deliberately
kept in one, what gets measured changes: the units a request touches, the module boundaries it
crosses, the parts that must ship together for a version to be deployed.

This course compares architectural arrangements by those measures, and every lesson measures
the same library — a shipment fee and routing library — built under that arrangement. The
first arrangement sorts units into ordered stacks by responsibility and bounds the direction of
calls between them with a rule.

#### Layer as a Style

Layer responsibilities and the direction of dependency between them were established in the
Layer Responsibilities lesson of the Data Access Layer and Business Logic course. The procedure
for extracting layer edges from import lines and counting violations against a declared rule was
written in the Dependency Graph Health lesson of the Design Principles course. Neither is
retold here; both are used.

What is new is the style itself. Layered architecture says three things together: every unit
belongs to exactly one layer; the layers are ordered; and, decisively, which layer may call
which is a set of edges **declared in advance**. Without the third point, the first two are
nothing more than a directory layout: if the rule is not written down, a violation is not
defined either.

The shape of the edge set draws a distinction. If a layer is **closed**, the layer above it
cannot skip over it; a request must pass through it. If it is **open**, skipping it is
permitted. An open layer is not a violation but part of the rule: it is written into the set of
permitted edges. Whether a call is permitted or a violation cannot be told from the code — only
from the edge set.

#### The Library's Layered Arrangement

The pricing context is split into four layers. Dependencies are wired by parameter, not by
import; the only place that connects the units to one another is the composition root.

```sh
mkdir -p layered/infrastructure layered/domain layered/application layered/presentation
```

```js
// layered/infrastructure/tariff-repository.mjs — infrastructure layer: tariff rows and postal code equivalent
const TIERS = [
  { capWeight: 1, fee: 4990 },
  { capWeight: 5, fee: 8490 },
  { capWeight: 15, fee: 14990 },
  { capWeight: 30, fee: 24990 },
];
const COEFFICIENT = { near: 1, mid: 1.35, far: 1.8 };
const POSTAL_ZONE = { 34: "near", "06": "mid", 35: "mid", 65: "far" };

export const tariffRepository = {
  tiers: () => TIERS.map((t) => ({ ...t })),
  coefficient: (postalCode) => COEFFICIENT[POSTAL_ZONE[postalCode.slice(0, 2)] ?? "far"],
};
```

```js
// layered/domain/fee-rule.mjs — domain layer: fee rule, imports no other layer
const MINIMUM_FEE = 3990;
const ROUNDING_STEP = 50;

export const feeRule = {
  calculate: (shipment, tiers, coefficient) => {
    const tier = tiers.find((t) => shipment.weight <= t.capWeight);
    if (tier === undefined) throw new RangeError("no weight tier");
    const raw = tier.fee * coefficient;
    return Math.max(Math.round(raw / ROUNDING_STEP) * ROUNDING_STEP, MINIMUM_FEE);
  },
};
```

```js
// layered/application/quote.mjs — application layer: the order of the scenario
export const applicationLayer = (domain, infrastructure) => ({
  quote: (shipment) => {
    const tiers = infrastructure.tiers();
    const coefficient = infrastructure.coefficient(shipment.postalCode);
    return { amount: domain.calculate(shipment, tiers, coefficient) };
  },
  listTiers: () => infrastructure.tiers(),
});
```

```js
// layered/presentation/desk.mjs — presentation layer: body parsing and amount formatting
export const presentationLayer = (application) => ({
  quoteRequest: (body) => {
    const shipment = { weight: Number(body.weight), postalCode: String(body.postalCode) };
    const { amount } = application.quote(shipment);
    return { status: 200, amount: `${(amount / 100).toFixed(2)} TL` };
  },
  tiersRequest: () => application.listTiers(),
});

// open layer: when the rule declares this edge, the read request skips the application layer
export const openTiersDesk = (infrastructure) => ({
  tiersRequest: () => infrastructure.tiers(),
});
```

The presentation module holds two desks. The first is the closed arrangement's desk; it calls
the application layer. The second is the open layer declaration's counterpart and calls
infrastructure directly, but only for the read request. Their sitting in the same file is not a
coincidence: both fall under the same rule, and the difference is which edge the rule counts as
permitted.

#### A Request's Path

Because dependencies arrive as parameters, counters can be placed on the boundaries. The
measurement script does the composition root's job and passes every layer object through a
wrapper that builds the call tree. Three numbers come out of the tree: the layers a request
touches, the calls between layers, and the number of **pass-through** layers. If a node has a
single child, the arguments it received match what it passed to the child, and it returns the
child's value unchanged, that layer is pass-through: it added nothing to the request.

```js
// boundary-count.mjs — layers touched, boundaries crossed and pass-through layers for one request
import { tariffRepository } from "./layered/infrastructure/tariff-repository.mjs";
import { feeRule } from "./layered/domain/fee-rule.mjs";
import { applicationLayer } from "./layered/application/quote.mjs";
import { presentationLayer, openTiersDesk } from "./layered/presentation/desk.mjs";

const roots = [];
const stack = [];

function wrap(layer, object) {
  const wrapped = {};
  for (const [name, fn] of Object.entries(object)) {
    wrapped[name] = (...args) => {
      const node = { layer, name, args, child: [], value: null };
      (stack.at(-1)?.child ?? roots).push(node);
      stack.push(node);
      try { return (node.value = fn(...args)); } finally { stack.pop(); }
    };
  }
  return wrapped;
}

function traverse(node, visited = new Set(), crossings = [], passthrough = []) {
  visited.add(node.layer);
  for (const c of node.child) {
    crossings.push(`${node.layer}->${c.layer}`);
    traverse(c, visited, crossings, passthrough);
  }
  const single = node.child.length === 1 ? node.child[0] : null;
  if (single !== null && JSON.stringify(node.args) === JSON.stringify(single.args)
      && JSON.stringify(node.value) === JSON.stringify(single.value)) {
    passthrough.push(node.layer);
  }
  return { visited, crossings, passthrough };
}

const domain = wrap("domain", feeRule);
const infrastructure = wrap("infrastructure", tariffRepository);
const application = wrap("application", applicationLayer(domain, infrastructure));
const presentation = wrap("presentation", presentationLayer(application));
const openPresentation = wrap("presentation", openTiersDesk(infrastructure));

const REQUESTS = [
  ["quote / closed      ", () => presentation.quoteRequest({ weight: "3", postalCode: "06800" })],
  ["tiers / closed      ", () => presentation.tiersRequest()],
  ["tiers / open layer  ", () => openPresentation.tiersRequest()],
];

for (const [name, call] of REQUESTS) {
  roots.length = 0;
  const result = call();
  const { visited, crossings, passthrough } = traverse(roots[0]);
  const summary = Array.isArray(result) ? `${result.length} tiers` : JSON.stringify(result);
  console.log(`${name}  result = ${summary}`);
  console.log(`  layers touched = ${visited.size}, boundary crossings = ${crossings.length}, pass-through layers = ${passthrough.length}`);
  console.log(`  path = ${crossings.join(", ")}`);
  if (passthrough.length > 0) console.log(`  pass-through = ${passthrough.join(", ")}`);
}
```

```sh
node boundary-count.mjs
```

```
quote / closed        result = {"status":200,"amount":"114.50 TL"}
  layers touched = 4, boundary crossings = 4, pass-through layers = 0
  path = presentation->application, application->infrastructure, application->infrastructure, application->domain
tiers / closed        result = 4 tiers
  layers touched = 3, boundary crossings = 2, pass-through layers = 2
  path = presentation->application, application->infrastructure
  pass-through = application, presentation
tiers / open layer    result = 4 tiers
  layers touched = 2, boundary crossings = 1, pass-through layers = 1
  path = presentation->infrastructure
  pass-through = presentation
```

#### The Pass-Through Layer

The first request shows the style doing its job. The quote request touched four layers, crossed
four boundaries, and the pass-through count came out at 0: presentation parsed the body and
formatted the amount, application built the call order, infrastructure supplied the two tables,
domain computed the rule. Every layer added something.

The second request shows the same style's cost. The tier list request touched three layers, but
two came out pass-through: the application layer passed the empty argument list straight down
and handed the returned list back up unchanged; the presentation layer did the same. Two of the
three layers were nothing but a relay point. Calling this symptom **layer skipping** is wrong:
nothing here is being skipped — the opposite is true, there are two stops that do nothing.

The third run shows how far the measure drops. The open layer declaration produced the same
result while touching two layers and crossing one boundary: layers touched went from 3 to 2,
boundary crossings from 2 to 1, pass-through layers from 2 to 1. The gain is not free; the
difference between the two desks is that the same read request can now be served from two
separate places, and the day a business rule gets added to that read, the rule is either
written in two places or the open edge is withdrawn.

#### The Leak Showing Up in the Import Graph

An open layer and a leak look alike at run time: in both, a layer gets skipped. The difference
is whether it was declared, and that difference is measured in the import graph. The block below
takes a copy of the closed arrangement and changes two files.

```sh
cp -r layered leaky
cat > leaky/presentation/desk.mjs <<'EOF'
// leaky/presentation/desk.mjs — read request skips the application layer and calls infrastructure directly
import { tariffRepository } from "../infrastructure/tariff-repository.mjs";

export const presentationLayer = (application) => ({
  quoteRequest: (body) => {
    const shipment = { weight: Number(body.weight), postalCode: String(body.postalCode) };
    const { amount } = application.quote(shipment);
    return { status: 200, amount: `${(amount / 100).toFixed(2)} TL` };
  },
  tiersRequest: () => tariffRepository.tiers(),
});
EOF
cat > leaky/domain/zone-resolver.mjs <<'EOF'
// leaky/domain/zone-resolver.mjs — domain layer helper reaching into the infrastructure table
import { tariffRepository } from "../infrastructure/tariff-repository.mjs";

export const mostExpensiveTier = () =>
  tariffRepository.tiers().reduce((a, b) => (a.fee >= b.fee ? a : b));
EOF
find leaky -name '*.mjs' | sort
```

```
leaky/application/quote.mjs
leaky/domain/fee-rule.mjs
leaky/domain/zone-resolver.mjs
leaky/infrastructure/tariff-repository.mjs
leaky/presentation/desk.mjs
```

Each change looks innocent on its own. The presentation desk sees no reason to place a layer in
between for a list request; the helper in the domain layer just wants the most expensive tier.
The audit tool reads each file's layer from its directory, reduces import lines to layer edges,
and counts them against two edge sets.

```js
// layer-violation.mjs — reduces the import graph to layer edges, counts violations against a declared rule
import { readdirSync, readFileSync } from "node:fs";
import { basename, dirname, join, normalize } from "node:path";

const CLOSED = new Set(["presentation->application", "application->domain", "application->infrastructure"]);
const OPEN = new Set([...CLOSED, "presentation->infrastructure"]);

function layerEdges(root) {
  const edge = new Set();
  for (const layer of readdirSync(root).sort()) {
    for (const file of readdirSync(join(root, layer)).sort()) {
      const text = readFileSync(join(root, layer, file), "utf8");
      for (const m of text.matchAll(/^import\s.*?from\s+"(\.[^"]+)"/gm)) {
        const target = basename(dirname(normalize(join(root, layer, m[1]))));
        if (target !== layer) edge.add(`${layer}->${target}`);
      }
    }
  }
  return [...edge];
}

for (const root of ["layered", "leaky"]) {
  const edges = layerEdges(root);
  console.log(`${root}/  layer edges = ${edges.length}  (${edges.join(", ") || "none"})`);
  for (const [name, rule] of [["closed rule", CLOSED], ["open layer rule", OPEN]]) {
    const violation = edges.filter((k) => rule.has(k) === false);
    console.log(`  ${name.padEnd(19)} allowed edges = ${rule.size}, violation = ${violation.length}${violation.length ? ` (${violation.join(", ")})` : ""}`);
  }
}
```

```sh
node layer-violation.mjs
```

```
layered/  layer edges = 0  (none)
  closed rule         allowed edges = 3, violation = 0
  open layer rule     allowed edges = 4, violation = 0
leaky/  layer edges = 2  (domain->infrastructure, presentation->infrastructure)
  closed rule         allowed edges = 3, violation = 2 (domain->infrastructure, presentation->infrastructure)
  open layer rule     allowed edges = 4, violation = 1 (domain->infrastructure)
```

The closed arrangement came out with zero layer edges. That is not a gap but a consequence of
the composition-root arrangement: no layer module imports another layer, the wiring is built in
a single file. In an arrangement like this, the layer rule is audited not in the import graph
but in that one file — and a leak becomes visible in the import graph the moment that file gets
bypassed.

The leaky arrangement's two edges are of two separate kinds. The `presentation->infrastructure`
edge is permitted under the open layer rule and a violation under the closed rule: the same line
gets two different verdicts. The `domain->infrastructure` edge, by contrast, is a violation
under both rules, because no edge set declares an outgoing edge from the domain layer. That is
the difference between the two kinds of leak: the first is a place the rule may deserve
revisiting, the second breaks the style's load-bearing rule. The domain layer reaching into a
table module also ends that layer's ability to be imported on its own; testing the rule now
requires loading the table too.

#### The Trade-off's Numbers

The style improves one quality attribute and worsens another. On maintainability, the gain was
measured: the fee rule sits in a single file, its outgoing edge count is 0, and the closed
arrangement's layer violation count is 0. When the rule changes, exactly one file is touched,
and the tables' format never enters the rule at all.

The cost is written in the same measures. The same style touches 3 layers for a read request
that needs no business rule, and 2 of those 3 add nothing to it. The ratio grows with the layer
count: every new layer means one more pass-through stop on requests that carry no rule. The
style itself is the trade-off between these two numbers — the tighter the declared edge set,
the more certain the rule's location and the higher the pass-through stop count.

#### Summary

- The layered style says three things together: every unit belongs to one layer, the layers are
  ordered, and permitted call edges are declared in advance; without the third, a violation is
  not defined.
- The quote request touched 4 layers, crossed 4 boundaries, and the pass-through count came out
  at 0; the tier list request touched 3 layers, and 2 of those 3 added nothing to it.
- The open layer declaration brought the same read request down to 2 layers and 1 boundary; its
  cost is that the request can now be served from two separate places.
- The closed arrangement's layer edge count came out at 0, because the wiring is built in the
  composition root; a leak becomes visible the moment that root is bypassed.
- Of the leaky arrangement's two edges, `presentation->infrastructure` is permitted under the
  open rule and a violation under the closed rule; `domain->infrastructure` is a violation under
  both and ends the domain layer's ability to load on its own.

#### Next Step

One edge in the closed arrangement's permitted edge set stands out: `application->infrastructure`.
The rule lets the layer that builds the workflow reach the table directly — in the ordering, that
makes infrastructure just another downward layer alongside domain. Built this way, what sits at the
library's center is not the domain model but the path a request takes. The next lesson takes up two
arrangements that reverse the ordering: ring layouts where every dependency looks inward, toward
the domain. Outside the hexagonal architecture sits an unordered ring of adapters; in the onion
architecture the rings are ordered. The two arrangements are compared by the ring edges each rule
forbids and by the violation each rule catches or misses in the same codebase.

### Hexagonal and Onion Architecture

Source: https://academia.sh/en/courses/architectural-styles/hexagonal-and-onion-architecture

Comparing, as rules, two arrangements where dependency turns inward: counting the sixteen possible ring edges in a pricing context placed into four rings, separately deriving the edge sets forbidden by the hexagonal and onion rules, each rule seeing only one of two defects in the same codebase, and both counts dropping to zero after the repair.

The closed arrangement's permitted edge set had one edge: `application->infrastructure`. The
rule allowed the layer that builds the workflow to reach the table directly; in the ordering,
infrastructure was just another downward layer alongside domain. Built this way, what sits at
the library's center is not the domain model but the path a request takes.

This lesson takes up two arrangements that reverse the ordering. Both share one rule in a single
sentence: dependency arrows always look inward, toward the domain. Where they diverge is how the
inside gets divided, and that divergence produces a measurable result — the two rules do not
see the same defects.

#### Where the Two Arrangements Diverge

Hexagonal architecture, ports, and adapters were established and measured in the Ports and
Adapters lesson of the Domain-Driven Design course: once the places where the domain opens
outward were named, the count of direct bonds going out to the outside world dropped from 3 to
0. That measurement is not repeated here, and the concepts are not redefined.

What is new is this. The hexagonal arrangement does not divide the inside: the domain, the
ports, and the use cases are a single inner set, while outside sits an **unordered** ring made
of adapters. Adapters are peers and do not know one another. **Onion architecture** keeps the
same inward-facing rule but divides the inside into ordered rings: the domain model at the
center, domain services above it, application services above that, and adapters at the
outermost ring. The rule is written by ring order: a ring may import only the rings inside it
and its own ring.

When the two rules are applied to the same four compartments, they forbid different things, and
that difference can be counted.

#### The Pricing Context Placed into Rings

The pricing context is spread across four directories; the directory name gives the ring number.

```sh
mkdir -p rings/domain-model rings/domain-services rings/application-services rings/outer
```

```js
// rings/domain-model/tier.mjs — ring 0: weight tier value
export const tier = (capWeight, fee) => ({ capWeight, fee: Math.round(fee) });
```

```js
// rings/domain-services/discount-service.mjs — ring 1: contracted discount rate
const CONTRACT = { none: 0, standard: 0.05, bulk: 0.12 };

export const discountService = {
  rate: (contract, amount) => (CONTRACT[contract] ?? 0) + (amount >= 20000 ? 0.03 : 0),
};
```

```js
// rings/domain-model/fee-rule.mjs — ring 0: rule, reaches into the discount service
import { discountService } from "../domain-services/discount-service.mjs";

const MINIMUM_FEE = 3990;
const ROUNDING_STEP = 50;
const round = (t) => Math.round(t / ROUNDING_STEP) * ROUNDING_STEP;

export const feeRule = {
  calculate: (shipment, tiers, coefficient) => {
    const tier = tiers.find((t) => shipment.weight <= t.capWeight);
    if (tier === undefined) throw new RangeError("no weight tier");
    const raw = tier.fee * coefficient;
    const rate = discountService.rate(shipment.contract, raw);
    return Math.max(round(raw * (1 - rate)), MINIMUM_FEE);
  },
};
```

```js
// rings/domain-services/tariff-point.mjs — ring 1: two points opening outward and the names they expect
export const POINTS = { tariff: ["tiers", "coefficient"], announcements: ["send"] };

export const missingNames = (environment) => Object.entries(POINTS)
  .flatMap(([n, names]) => names
    .filter((a) => typeof environment?.[n]?.[a] !== "function")
    .map((a) => `${n}.${a}`));
```

```js
// rings/application-services/quote-flow.mjs — ring 2: the order of the scenario
import { feeRule } from "../domain-model/fee-rule.mjs";
import { missingNames } from "../domain-services/tariff-point.mjs";

export const quoteFlow = (environment) => {
  const missing = missingNames(environment);
  if (missing.length > 0) throw new TypeError(`unmet point: ${missing.join(", ")}`);
  return {
    quote: (shipment) => {
      const amount = feeRule.calculate(shipment, environment.tariff.tiers(), environment.tariff.coefficient(shipment.postalCode));
      environment.announcements.send({ postalCode: shipment.postalCode, amount });
      return { amount };
    },
  };
};
```

```js
// rings/outer/tariff-adapter.mjs — ring 3: converts outer rows to a ring 0 value
import { tier } from "../domain-model/tier.mjs";

const ROWS = [[1, 49.9], [5, 84.9], [15, 149.9], [30, 249.9]];
const COEFFICIENT = { near: 1, mid: 1.35, far: 1.8 };
const POSTAL_ZONE = { 34: "near", "06": "mid", 35: "mid", 65: "far" };

export const tariffAdapter = {
  tiers: () => ROWS.map(([weight, tl]) => tier(weight, tl * 100)),
  coefficient: (postalCode) => COEFFICIENT[POSTAL_ZONE[postalCode.slice(0, 2)] ?? "far"],
};
```

```js
// rings/outer/notification-adapter.mjs — ring 3: adapter meeting the announcements point
const OUTGOING = [];

export const notificationAdapter = { send: (m) => OUTGOING.push(m), count: () => OUTGOING.length };
```

```js
// rings/outer/desk.mjs — ring 3: asks its neighbor adapter for the list request
import { tariffAdapter } from "./tariff-adapter.mjs";

export const desk = (flow) => ({
  quoteRequest: (body) => flow.quote({
    weight: Number(body.weight), postalCode: String(body.postalCode), contract: body.contract,
  }),
  tiersRequest: () => tariffAdapter.tiers(),
});
```

Two lines in the arrangement are deliberately placed wrong: the fee rule imports the discount
service, and the desk asks its neighboring adapter for the tier list. Both look innocent on
their own.

#### The Edges the Two Rules Forbid

Among four rings there are $4 \times 4 = 16$ possible edges. The tool first derives which of
these sixteen edges each rule forbids, then reads the tree's actual edges from the import lines
and counts them against both rules. The procedure for extracting edges from the import graph was
established in the Dependency Graph Health lesson of the Design Principles course.

```js
// ring-rule.mjs — counts the edges forbidden by two ring rules and the violations in the tree
import { readdirSync, readFileSync } from "node:fs";
import { basename, dirname, join, normalize } from "node:path";

const RING = { "domain-model": 0, "domain-services": 1, "application-services": 2, "outer": 3 };
const ONION = (i, j) => j <= i;
const HEXAGON = (i, j) => j !== 3;
const RULES = [["hexagon", HEXAGON], ["onion", ONION]];

const allEdges = [0, 1, 2, 3].flatMap((i) => [0, 1, 2, 3].map((j) => [i, j]));
const forbidden = new Map(RULES.map(([name, allow]) =>
  [name, new Set(allEdges.filter(([i, j]) => allow(i, j) === false).map(([i, j]) => `${i}->${j}`))]));

console.log(`possible ring edges = ${allEdges.length}`);
for (const [name, set] of forbidden) console.log(`  ${name.padEnd(8)} forbidden edges = ${set.size}  ${[...set].join(" ")}`);
const common = [...forbidden.get("hexagon")].filter((k) => forbidden.get("onion").has(k));
const union = new Set([...forbidden.get("hexagon"), ...forbidden.get("onion")]);
console.log(`  common forbidden = ${common.length}  ${common.join(" ")}`);
console.log(`  union forbidden = ${union.size}`);

function ringEdges(root) {
  const edge = new Map();
  for (const ring of readdirSync(root).sort()) {
    for (const file of readdirSync(join(root, ring)).sort()) {
      const text = readFileSync(join(root, ring, file), "utf8");
      for (const m of text.matchAll(/^import\s.*?from\s+"(\.[^"]+)"/gm)) {
        const target = basename(dirname(normalize(join(root, ring, m[1]))));
        const key = `${RING[ring]}->${RING[target]}`;
        edge.set(key, `${ring}/${file} -> ${target}/${basename(m[1])}`);
      }
    }
  }
  return edge;
}

for (const root of process.argv.slice(2)) {
  const edge = ringEdges(root);
  console.log(`\n${root}/  ring edges = ${edge.size}  (${[...edge.keys()].sort().join(" ")})`);
  for (const [name, set] of forbidden) {
    const violation = [...edge].filter(([k]) => set.has(k));
    console.log(`  ${name.padEnd(8)} violation = ${violation.length}`);
    for (const [k, source] of violation) console.log(`    ${k}  ${source}`);
  }
}
```

```sh
node ring-rule.mjs rings
```

```
possible ring edges = 16
  hexagon  forbidden edges = 4  0->3 1->3 2->3 3->3
  onion    forbidden edges = 6  0->1 0->2 0->3 1->2 1->3 2->3
  common forbidden = 3  0->3 1->3 2->3
  union forbidden = 7

rings/  ring edges = 5  (0->1 2->0 2->1 3->0 3->3)
  hexagon  violation = 1
    3->3  outer/desk.mjs -> outer/tariff-adapter.mjs
  onion    violation = 1
    0->1  domain-model/fee-rule.mjs -> domain-services/discount-service.mjs
```

#### Each Rule's Blind Spot

The first three lines give the size of the two rules. The hexagon rule forbids 4 of the sixteen
edges: every edge going to the outer ring, and the edge inside the outer ring itself. The onion
rule forbids 6: because an order is also declared inside, every outward-facing edge is
forbidden. The common forbidden set is 3 edges — meaning the only thing the two rules agree on
is the sentence "no bond from inside to outside." The union climbs to 7; the two rules'
prohibitions are not nested inside one another, they intersect.

The cost of this shows up in the tree. Two of the five ring edges were defective, and each rule
caught only one. The hexagon rule caught the `desk -> tariff-adapter` edge: two adapters know
each other, yet the outer ring is unordered and peers do not bond to one another. The onion rule
cannot see this, because both sit in the same ring and an edge within a ring is permitted. The
onion rule, in turn, caught the `fee-rule -> discount-service` edge: the domain model reaches
out to a domain service that sits outside it. The hexagon rule cannot see this, because both are
in the "inside" set, and the inside has no order.

The point is not which of the two rules is correct. Both state the same direction, and each is
blind to a defect the other one catches.

#### Removing the Two Edges

The repair consists of two moves. The fee rule takes the discount rate as a parameter instead
of importing it; the caller — the application service — computes the rate. The desk, meanwhile,
asks the flow for the tier list rather than its neighbor.

```sh
cp -r rings repaired
cat > repaired/domain-model/fee-rule.mjs <<'EOF'
// repaired/domain-model/fee-rule.mjs — ring 0: discount rate arrives as a parameter
const MINIMUM_FEE = 3990;
const ROUNDING_STEP = 50;
const round = (t) => Math.round(t / ROUNDING_STEP) * ROUNDING_STEP;

export const feeRule = {
  rawFee: (shipment, tiers, coefficient) => {
    const tier = tiers.find((t) => shipment.weight <= t.capWeight);
    if (tier === undefined) throw new RangeError("no weight tier");
    return tier.fee * coefficient;
  },
  calculate: (raw, rate) => Math.max(round(raw * (1 - rate)), MINIMUM_FEE),
};
EOF
cat > repaired/application-services/quote-flow.mjs <<'EOF'
// repaired/application-services/quote-flow.mjs — ring 2: runs the discount and the list request itself
import { feeRule } from "../domain-model/fee-rule.mjs";
import { discountService } from "../domain-services/discount-service.mjs";
import { missingNames } from "../domain-services/tariff-point.mjs";

export const quoteFlow = (environment) => {
  const missing = missingNames(environment);
  if (missing.length > 0) throw new TypeError(`unmet point: ${missing.join(", ")}`);
  return {
    quote: (shipment) => {
      const raw = feeRule.rawFee(shipment, environment.tariff.tiers(), environment.tariff.coefficient(shipment.postalCode));
      const amount = feeRule.calculate(raw, discountService.rate(shipment.contract, raw));
      environment.announcements.send({ postalCode: shipment.postalCode, amount });
      return { amount };
    },
    listTiers: () => environment.tariff.tiers(),
  };
};
EOF
cat > repaired/outer/desk.mjs <<'EOF'
// repaired/outer/desk.mjs — ring 3: also asks the flow for the list request, does not know its neighbor
export const desk = (flow) => ({
  quoteRequest: (body) => flow.quote({
    weight: Number(body.weight), postalCode: String(body.postalCode), contract: body.contract,
  }),
  tiersRequest: () => flow.listTiers(),
});
EOF
node ring-rule.mjs repaired
```

```
possible ring edges = 16
  hexagon  forbidden edges = 4  0->3 1->3 2->3 3->3
  onion    forbidden edges = 6  0->1 0->2 0->3 1->2 1->3 2->3
  common forbidden = 3  0->3 1->3 2->3
  union forbidden = 7

repaired/  ring edges = 3  (2->0 2->1 3->0)
  hexagon  violation = 0
  onion    violation = 0
```

The ring edge count dropped from 5 to 3, and all three remaining edges are permitted under both
rules: the application service looks at the two inner rings, and the adapter produces the
innermost ring's value. The same tree now passes both rules' combined prohibition.

#### Behavior and Closure

That a refactor preserves behavior is not claimed, it is tested. The composition root sits
outside the rings; the script below builds both trees itself and calls them with the same
shipments. It also measures the import closure of the rule file in the innermost ring, on both
trees.

```js
// equivalence-check.mjs — composition root sits outside the rings: builds both trees, compares result and closure
import { readFileSync } from "node:fs";
import { dirname, join, normalize } from "node:path";
import { desk as brokenDesk } from "./rings/outer/desk.mjs";
import { quoteFlow as brokenFlow } from "./rings/application-services/quote-flow.mjs";
import { tariffAdapter as brokenTariff } from "./rings/outer/tariff-adapter.mjs";
import { notificationAdapter as brokenNotification } from "./rings/outer/notification-adapter.mjs";
import { desk as repairedDesk } from "./repaired/outer/desk.mjs";
import { quoteFlow as repairedFlow } from "./repaired/application-services/quote-flow.mjs";
import { tariffAdapter as repairedTariff } from "./repaired/outer/tariff-adapter.mjs";
import { notificationAdapter as repairedNotification } from "./repaired/outer/notification-adapter.mjs";

const SHIPMENTS = [
  { weight: "0.4", postalCode: "34710", contract: "none" },
  { weight: "3", postalCode: "06800", contract: "standard" },
  { weight: "12", postalCode: "65100", contract: "bulk" },
  { weight: "28", postalCode: "35400", contract: "none" },
  { weight: "5", postalCode: "34100", contract: "bulk" },
];

function importClosure(root) {
  const visited = new Set(), stack = [root];
  while (stack.length > 0) {
    const file = stack.pop();
    if (visited.has(file)) continue;
    visited.add(file);
    for (const [, path] of readFileSync(file, "utf8").matchAll(/^import[^"']*["'](\.[^"']+)["']/gm)) {
      stack.push(normalize(join(dirname(file), path)));
    }
  }
  return visited.size;
}

const broken = brokenDesk(brokenFlow({ tariff: brokenTariff, announcements: brokenNotification }));
const repaired = repairedDesk(repairedFlow({ tariff: repairedTariff, announcements: repairedNotification }));

let diverged = 0;
for (const s of SHIPMENTS) {
  const a = broken.quoteRequest(s).amount, b = repaired.quoteRequest(s).amount;
  if (a !== b) diverged += 1;
  console.log(`${s.weight.padStart(4)} kg  ${s.postalCode}  ${s.contract.padEnd(8)} ${String(a).padStart(6)}  ${String(b).padStart(6)}`);
}
console.log(`diverged result = ${diverged} / ${SHIPMENTS.length}`);
console.log(`tiers request: ${broken.tiersRequest().length} / ${repaired.tiersRequest().length} tiers, sent = ${brokenNotification.count()} / ${repairedNotification.count()}`);
for (const root of ["rings", "repaired"]) {
  console.log(`${root}/domain-model/fee-rule.mjs import closure = ${importClosure(`${root}/domain-model/fee-rule.mjs`)}`);
}
```

```sh
node equivalence-check.mjs
```

```
 0.4 kg  34710  none       5000    5000
   3 kg  06800  standard  10900   10900
  12 kg  65100  bulk      22950   22950
  28 kg  35400  none      32700   32700
   5 kg  34100  bulk       7450    7450
diverged result = 0 / 5
tiers request: 4 / 4 tiers, sent = 5 / 5
rings/domain-model/fee-rule.mjs import closure = 2
repaired/domain-model/fee-rule.mjs import closure = 1
```

The amount matched in all five shipments, five announcements went out on both trees, and the
tier list returned four rows either way. The repair did not change behavior. What changed is the
innermost ring's import closure: it dropped from 2 files to 1, meaning testing the rule no
longer requires loading the discount table. That is the counterpart on the quality-attribute
side — the innermost ring's ability to load independently, as a maintainability measure, is the
two rules' shared goal; how tightly the ring order is declared determines how much of that goal
can be checked automatically.

#### Summary

- The two arrangements' shared rule is that dependency looks inward; where they diverge is
  whether the inside is divided: in hexagonal, the inside is a single set and the outer ring is
  unordered, in onion the inside is ordered rings.
- Of the 16 possible edges among four rings, the hexagon rule forbids 4 and the onion rule
  forbids 6; their common forbidden set is 3 edges, the union is 7.
- Of the two defects in the same tree, the hexagon rule caught only the `3->3` edge and the
  onion rule only the `0->1` edge; each rule is blind to the defect the other catches.
- After the repair, the ring edge count dropped from 5 to 3 and both rules' violation count fell
  to 0; the amount stayed the same in all five shipments.
- The rule file's import closure in the innermost ring dropped from 2 to 1: loading the rule no
  longer requires loading another ring.

#### Next Step

This lesson's last number leaves a question open: the union forbidden set was 7 edges, but no
single arrangement forbade all seven together. One rule declares an order for the inside but
treats the outer ring as a single piece; the other does not order the outer ring but does not
divide the inside either. When the union of the two rules is written as a named arrangement, the
resulting layout is called **clean architecture**. The next lesson builds that union and
measures two new things: the field and character count of the data crossing the boundary, and
the number of edges where the direction of control flow diverges from the direction of the
dependency arrow.

### Clean Architecture

Source: https://academia.sh/en/courses/architectural-styles/clean-architecture

Merging two ring rules into a single predicate: writing the rule that forbids seven of sixteen ring edges, counting the edges — at the boundaries passing through the composition root — where the direction of control flow diverges from the dependency arrow, and comparing two data shapes crossing the boundary by accessible name and character count.

The previous lesson applied two ring rules to the same four compartments and measured that each
is blind to the defect the other catches. The union forbidden set was 7 edges, but no single
arrangement forbade all seven together: one declares an inside order but treats the outer ring
as a single piece, the other leaves the outer ring unordered.

This lesson builds that union as a named arrangement. **Clean architecture** says that the
rings are ordered and that the dependency arrow always looks inward; what is new is writing the
rule as a single predicate over every pair of rings. Two measures come out of that writing: the
field and character count of the data crossing the boundary, and the number of edges where
control flow and the dependency arrow diverge.

#### The Unified Rule

Separating policy from detail and where to draw the boundary were established in the Policy and
Detail Separation and Drawing Boundaries lessons of the Design Principles course; layer
responsibilities were established in the Layer Responsibilities lesson of the Data Access Layer
and Business Logic course. What is described here is not a principle but an **arrangement**.

Ring 0 holds the **entity objects** and the rule, looking at nothing outside itself. Ring 1
holds the **use cases**: a request's steps, their order, and the declared names the use case
expects from the outside. Ring 2 is the **adapters**: it converts the outer shape to the input
model, and the output model to the outer shape. Ring 3 is outermost: raw rows and the entry
desk. The rule is the product of two conditions — from onion, that a ring may import only the
rings inside it or its own ring; from hexagon, that no edge may enter the outermost ring.

#### The Library Placed into Four Rings

The pricing context is spread across four directories; the directory name gives the ring
number. Wiring is built by parameter, and the composition root sits outside the rings.

```sh
mkdir -p clean/entities clean/use-cases clean/adapters clean/infrastructure
```

```js
// clean/entities/shipment.mjs — ring 0: priceable shipment and its chargeable weight
const VOLUME_DIVISOR = 5000;

export const shipment = (input) => Object.freeze({
  ...input,
  chargeableWeight: () => Math.max(input.weight, input.volume / VOLUME_DIVISOR),
});
```

```js
// clean/entities/fee-rule.mjs — ring 0: rule; tier, coefficient and rate arrive from outside
const MINIMUM_FEE = 3990;
const ROUNDING_STEP = 50;
const INSURANCE_RATE = 0.004;

export const feeRule = {
  calculate: (s, tiers, coefficient, discountRate) => {
    const weight = s.chargeableWeight();
    const tier = tiers.find((t) => weight <= t.capWeight);
    if (tier === undefined) throw new RangeError("no weight tier");
    const raw = tier.fee * coefficient + Math.round(s.declaredValue * INSURANCE_RATE);
    const net = Math.round((raw * (1 - discountRate)) / ROUNDING_STEP) * ROUNDING_STEP;
    return { amount: Math.max(net, MINIMUM_FEE), tier, chargeableWeight: weight };
  },
};
```

Ring 1's first file is not code but a declaration: the input and output model's fields, and the
names the use case expects from the gateway.

```js
// clean/use-cases/boundary.mjs — ring 1: input model, output model, and the names the gateway is expected to provide
export const INPUT_FIELDS = ["weight", "volume", "zone", "declaredValue", "contract"];
export const OUTPUT_FIELDS = ["amount", "currency", "tierCapWeight", "chargeableWeight"];
export const GATEWAY_NAMES = ["tiers", "coefficient", "discountRate"];

export const missingNames = (object, names) => names.filter((a) => typeof object?.[a] !== "function");

export const undeclaredNames = (object, allowed, prefix = "") =>
  Object.entries(object ?? {}).flatMap(([name, value]) => {
    const path = prefix === "" ? name : `${prefix}.${name}`;
    if (value !== null && typeof value === "object") return undeclaredNames(value, allowed, path);
    return allowed.includes(path) ? [] : [path];
  });
```

```js
// clean/use-cases/quote-use-case.mjs — ring 1: from input model to output model
import { shipment } from "../entities/shipment.mjs";
import { feeRule } from "../entities/fee-rule.mjs";
import { GATEWAY_NAMES, missingNames } from "./boundary.mjs";

export const quoteUseCase = (gateway) => {
  const missing = missingNames(gateway, GATEWAY_NAMES);
  if (missing.length > 0) throw new TypeError(`unmet gateway name: ${missing.join(", ")}`);
  return {
    run: (input) => {
      const s = shipment(input);
      const result = feeRule.calculate(s, gateway.tiers(), gateway.coefficient(s.zone),
        gateway.discountRate(s.contract));
      return {
        amount: result.amount,
        currency: "cents",
        tierCapWeight: result.tier.capWeight,
        chargeableWeight: result.chargeableWeight,
      };
    },
  };
};
```

```js
// clean/adapters/quote-controller.mjs — ring 2: converts an outer body to the input model, the output model to text
import { OUTPUT_FIELDS, INPUT_FIELDS, undeclaredNames } from "../use-cases/boundary.mjs";

const CONVERT = { weight: Number, volume: Number, zone: String, declaredValue: Number, contract: String };

export const quoteController = (useCase) => ({
  quoteRequest: (body) => {
    const output = useCase.run(
      Object.fromEntries(INPUT_FIELDS.map((a) => [a, CONVERT[a](body[a])])));
    const undeclared = undeclaredNames(output, OUTPUT_FIELDS);
    if (undeclared.length > 0) return { status: 500, body: `name outside the contract: ${undeclared.join(", ")}` };
    return { status: 200, body: `${(output.amount / 100).toFixed(2)} TL / ${output.chargeableWeight.toFixed(2)} kg` };
  },
});

export const errorFormatter = (error) =>
  error instanceof RangeError ? { status: 422, body: error.message } : { status: 500, body: "unexpected" };
```

```js
// clean/adapters/tariff-gateway.mjs — ring 2: converts raw rows to a tier value
import { GATEWAY_NAMES, missingNames } from "../use-cases/boundary.mjs";

export const SOURCE_NAMES = ["rows", "coefficients", "contracts"];

export const tariffGateway = (source) => {
  const gateway = {
    tiers: () => source.rows()
      .map(([capWeight, tl]) => ({ capWeight, fee: Math.round(tl * 100) })),
    coefficient: (zone) => source.coefficients()[zone] ?? 1.8,
    discountRate: (contract) => source.contracts()[contract] ?? 0,
  };
  const missing = [...missingNames(source, SOURCE_NAMES), ...missingNames(gateway, GATEWAY_NAMES)];
  if (missing.length > 0) throw new TypeError(`unmet name: ${missing.join(", ")}`);
  return gateway;
};
```

```js
// clean/infrastructure/tariff-rows.mjs — ring 3: raw rows, zone coefficients, contract rates
const ROWS = [[1, 49.9], [5, 84.9], [15, 149.9], [30, 249.9]];
const COEFFICIENTS = { near: 1, mid: 1.35, far: 1.8 };
const CONTRACTS = { none: 0, standard: 0.05, bulk: 0.12 };

export const tariffRows = {
  rows: () => ROWS,
  coefficients: () => COEFFICIENTS,
  contracts: () => CONTRACTS,
};
```

```js
// clean/infrastructure/desk.mjs — ring 3: hands what comes from outside to the controller, runs errors through the formatter
import { errorFormatter } from "../adapters/quote-controller.mjs";

export const desk = (controller) => ({
  request: (body) => {
    try { return controller.quoteRequest(body); } catch (error) { return errorFormatter(error); }
  },
});
```

#### Writing the Rule and the Violation

The tool derives the rule from the two rules, tests that it matches their union, then reads the
tree's ring edges from the import lines and counts them against the prohibition. The
edge-extraction procedure was established in the Dependency Graph Health lesson of the Design
Principles course.

```js
// clean-rule.mjs — builds the clean rule as the union of two rules, counts violations in the tree
import { readdirSync, readFileSync } from "node:fs";
import { basename, dirname, join, normalize } from "node:path";

const RING = { entities: 0, "use-cases": 1, adapters: 2, infrastructure: 3 };
const HEXAGON = (i, j) => j !== 3;
const ONION = (i, j) => j <= i;
const CLEAN = (i, j) => ONION(i, j) && HEXAGON(i, j);

const all = [0, 1, 2, 3].flatMap((i) => [0, 1, 2, 3].map((j) => [i, j]));
const forbid = (allow) => new Set(all
  .filter(([i, j]) => allow(i, j) === false).map(([i, j]) => `${i}->${j}`));
export const FORBIDDEN = forbid(CLEAN);

export function ringEdges(root) {
  const edge = new Map();
  for (const ring of readdirSync(root).sort()) {
    for (const file of readdirSync(join(root, ring)).sort()) {
      for (const m of readFileSync(join(root, ring, file), "utf8")
        .matchAll(/^import\s.*?from\s+"(\.[^"]+)"/gm)) {
        const target = basename(dirname(normalize(join(root, ring, m[1]))));
        edge.set(`${RING[ring]}->${RING[target]}`, `${ring}/${file}`);
      }
    }
  }
  return edge;
}

if (process.argv.length > 2) {
  const union = new Set([...forbid(HEXAGON), ...forbid(ONION)]);
  console.log(`possible ring edges = ${all.length}`);
  console.log(`clean rule: forbidden = ${FORBIDDEN.size}, permitted = ${all.length - FORBIDDEN.size}`);
  console.log(`  forbidden edges = ${[...FORBIDDEN].join(" ")}`);
  console.log(`  same as union = ${FORBIDDEN.size === union.size
    && [...FORBIDDEN].every((k) => union.has(k))}`);
  for (const root of process.argv.slice(2)) {
    const edge = ringEdges(root);
    const violation = [...edge].filter(([k]) => FORBIDDEN.has(k));
    console.log(`\n${root}/  ring edges = ${edge.size}  (${[...edge.keys()].sort().join(" ")})`);
    console.log(`  violation = ${violation.length}`);
    for (const [k, source] of violation) console.log(`    ${k}  ${source}`);
  }
}
```

Two small files are added to a copy of the tree: one, in ring 2, imports the raw rows itself;
the other, in ring 0, reads the boundary declaration.

```sh
cp -r clean broken
cat > broken/adapters/row-cache.mjs <<'EOF'
// broken/adapters/row-cache.mjs — ring 2: imports the raw rows itself
import { tariffRows } from "../infrastructure/tariff-rows.mjs";

export const rowCache = () => tariffRows.rows();
EOF
cat > broken/entities/field-summary.mjs <<'EOF'
// broken/entities/field-summary.mjs — ring 0: helper that reads the boundary declaration itself
import { OUTPUT_FIELDS } from "../use-cases/boundary.mjs";

export const outputFieldCount = () => OUTPUT_FIELDS.length;
EOF
node clean-rule.mjs clean broken
```

```
possible ring edges = 16
clean rule: forbidden = 7, permitted = 9
  forbidden edges = 0->1 0->2 0->3 1->2 1->3 2->3 3->3
  same as union = true

clean/  ring edges = 4  (1->0 1->1 2->1 3->2)
  violation = 0

broken/  ring edges = 6  (0->1 1->0 1->1 2->1 2->3 3->2)
  violation = 2
    2->3  adapters/row-cache.mjs
    0->1  entities/field-summary.mjs
```

The forbidden set matches the union exactly; the contribution is not a new prohibition but
gathering two prohibitions into one predicate. The clean tree's four edges sit within the nine
permitted. The two added files pushed the edge count to six; two are violations — one entering
the outermost ring, the other leaving the innermost. The previous lesson's two rules each saw
only one of these.

#### Control Flow Diverging from the Dependency Arrow

A ring edge does not say which path a request takes: in the source, the dependency looks
inward, while at run time the use case calls the outward-facing gateway. The script builds the
composition root, passes every object through a wrapper, records the ring on top of the stack
as the caller at each call, and compares every recorded boundary against the import set. Calls
made through a direct import are not recorded: for those, the two directions are identical by
definition.

```js
// direction-split.mjs — for every boundary passing through the composition root, compares control flow direction with the dependency arrow
import { ringEdges } from "./clean-rule.mjs";
import { tariffRows } from "./clean/infrastructure/tariff-rows.mjs";
import { desk } from "./clean/infrastructure/desk.mjs";
import { quoteController } from "./clean/adapters/quote-controller.mjs";
import { tariffGateway, SOURCE_NAMES } from "./clean/adapters/tariff-gateway.mjs";
import { quoteUseCase } from "./clean/use-cases/quote-use-case.mjs";
import { GATEWAY_NAMES } from "./clean/use-cases/boundary.mjs";

const imported = new Set(ringEdges("clean").keys());
const stack = [];
const calls = new Map();

function wrap(ring, object) {
  const wrapped = {};
  for (const [name, fn] of Object.entries(object)) {
    wrapped[name] = (...args) => {
      const caller = stack.at(-1);
      if (caller !== undefined && caller !== ring) {
        const k = `${caller}->${ring}`;
        calls.set(k, (calls.get(k) ?? 0) + 1);
      }
      stack.push(ring);
      try { return fn(...args); } finally { stack.pop(); }
    };
  }
  return wrapped;
}

const gateway = wrap(2, tariffGateway(wrap(3, tariffRows)));
const controller = wrap(2, quoteController(wrap(1, quoteUseCase(gateway))));
const outerDesk = wrap(3, desk(controller));

const BODIES = [
  { weight: 3, volume: 24000, zone: "mid", declaredValue: 150000, contract: "standard" },
  { weight: 60, volume: 90000, zone: "far", declaredValue: 400000, contract: "bulk" },
];
const responses = BODIES.map((b) => outerDesk.request(b));
console.log(`requests = ${BODIES.length}  responses = ${responses.map((r) => `${r.status} ${r.body}`).join(" | ")}`);

const count = { same: 0, reversed: 0, unbound: 0 };
console.log(`\nboundaries through the composition root = ${calls.size}`);
for (const [k, n] of [...calls].sort()) {
  const [i, j] = k.split("->");
  const kind = imported.has(k) ? "same" : imported.has(`${j}->${i}`) ? "reversed" : "unbound";
  count[kind] += 1;
  const arrow = kind === "same" ? k : kind === "reversed" ? `${j}->${i}` : "none";
  console.log(`  control ${k}  dependency ${arrow}  ${kind.padEnd(9)} calls = ${n}`);
}
console.log(`same direction = ${count.same}, reversed = ${count.reversed}, unbound = ${count.unbound}`);
console.log(`names declared inward at reversed boundaries: gateway ${GATEWAY_NAMES.length}, source ${SOURCE_NAMES.length}`);
```

```sh
node direction-split.mjs
```

```
requests = 2  responses = 200 114.50 TL / 4.80 kg | 422 no weight tier

boundaries through the composition root = 4
  control 1->2  dependency 2->1  reversed  calls = 6
  control 2->1  dependency 2->1  same      calls = 2
  control 2->3  dependency 3->2  reversed  calls = 6
  control 3->2  dependency 3->2  same      calls = 2
same direction = 2, reversed = 2, unbound = 0
names declared inward at reversed boundaries: gateway 3, source 3
```

In two of the four boundaries, the two directions match: the desk both calls and imports the
controller, and the controller does the same for the use case. In the other two, they diverge:
the use case calls the gateway, but the gateway imports the use case; the gateway calls the raw
source, but the source's ring imports the gateway's ring. The called names being declared
inward — three in ring 1, three in ring 2 — is what makes the divergence possible. The cost
carries the same count: at these two boundaries, the run-time path cannot be read from the
import lines, only found by looking at the composition root.

#### Data Crossing the Boundary

The second measure is the shape of data crossing the boundary. The use case can hand the rule's
returned object out as is, or narrow it to the declared fields. The script measures both by
accessible name, undeclared name, and JSON character count.

```js
// boundary-data.mjs — measures two data shapes crossing the boundary by accessible name and character count
import { shipment } from "./clean/entities/shipment.mjs";
import { feeRule } from "./clean/entities/fee-rule.mjs";
import { OUTPUT_FIELDS, undeclaredNames } from "./clean/use-cases/boundary.mjs";
import { quoteUseCase } from "./clean/use-cases/quote-use-case.mjs";
import { quoteController } from "./clean/adapters/quote-controller.mjs";

const fakeGateway = {
  tiers: () => [{ capWeight: 1, fee: 4990 }, { capWeight: 5, fee: 8490 }],
  coefficient: (zone) => (zone === "mid" ? 1.35 : 1),
  discountRate: (contract) => (contract === "standard" ? 0.05 : 0),
};
const INPUT = { weight: 3, volume: 24000, zone: "mid", declaredValue: 150000, contract: "standard" };

const s = shipment(INPUT);
const raw = { ...feeRule.calculate(s, fakeGateway.tiers(), fakeGateway.coefficient(s.zone),
  fakeGateway.discountRate(s.contract)), shipment: s };
const output = quoteUseCase(fakeGateway).run(INPUT);

for (const [name, data] of [["rule's object", raw], ["output model", output]]) {
  const names = undeclaredNames(data, []);
  const undeclared = undeclaredNames(data, OUTPUT_FIELDS);
  console.log(`${name.padEnd(16)} accessible names = ${String(names.length).padStart(2)}`
    + `  undeclared = ${String(undeclared.length).padStart(2)}`
    + `  json characters = ${String(JSON.stringify(data).length).padStart(3)}`);
  for (let i = 0; i < undeclared.length; i += 4) {
    console.log(`  undeclared: ${undeclared.slice(i, i + 4).join(", ")}`);
  }
}

const wrong = quoteController({ run: () => raw });
const correct = quoteController(quoteUseCase(fakeGateway));
console.log(`\nrule's object passed out -> status ${wrong.quoteRequest(INPUT).status}`);
const r = correct.quoteRequest(INPUT);
console.log(`output model passed out  -> status ${r.status}  ${r.body}`);
```

```sh
node boundary-data.mjs
```

```
rule's object    accessible names = 10  undeclared =  8  json characters = 170
  undeclared: tier.capWeight, tier.fee, shipment.weight, shipment.volume
  undeclared: shipment.zone, shipment.declaredValue, shipment.contract, shipment.chargeableWeight
output model     accessible names =  4  undeclared =  0  json characters =  76

rule's object passed out -> status 500
output model passed out  -> status 200  114.50 TL / 4.80 kg
```

The rule's object has 10 externally accessible names, and eight of them are undeclared in the
output contract: two are the tier object's internal fields, six are the entity object's.
Accessible names go from 10 to 4, JSON characters from 170 to 76.

The difference is not size but an obligation to know. When the entity object crosses the
boundary, ring 2 reads ring 0's field names; when the rule renames a field, the adapter changes
with it. This bond is invisible in the import graph, because no import is involved — the bond
lives in the object's shape. The controller testing output against declared fields catches this
at run time: a 500 response against a 200.

#### The Trade-off's Numbers

The gain was measured in two quality attributes. On maintainability: the eight-file tree's four
ring edges sit within the permitted nine and the violation count is 0; the two small added files
pushed it to 2. On testability: because the edges leaving ring 1 are `1->0` and `1->1`, the use
case runs against a three-function hand-written gateway.

The cost carries the same numbers: two of the four boundaries are reversed, and each reversed
boundary needs an inside declaration plus a matching outside definition. The tighter the edge
set, the earlier a violation is caught, and the higher the number of reversal points.

#### Summary

- The rule is the product of two conditions: a ring may import only rings inside it or its own
  ring, and no edge may enter the outermost ring; seven of the sixteen edges are forbidden, and
  the forbidden set is identical to the union of the two ring rules.
- The clean tree's four edges carry a violation count of 0; the two added files produced two
  edges — one entering the outermost ring, one leaving the innermost — and the violation count
  rose to 2.
- At two of the four boundaries passing through the composition root, control flow diverged from
  the dependency arrow; the divergence was made possible by six called names being declared
  inward.
- When the rule's object crosses the boundary, accessible names are 10 and undeclared names 8;
  when the output model crosses, 4 and 0; JSON characters dropped from 170 to 76.

#### Next Step

Every measure in this lesson was taken over the **ring**. A ring is a rule unit, not a release
unit: which set of the eight files gets packaged together, which can version separately, and
how many files one consumer must bind to for a single capability — none of that shows up here.
The next lesson moves the unit from ring to **component**: it splits the same library into
deployable units and measures how many names each component exposes outward and how often two
components change together.

### Component-Based Architecture

Source: https://academia.sh/en/courses/architectural-styles/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.

### Microkernel and Plugin Architecture

Source: https://academia.sh/en/courses/architectural-styles/microkernel-and-plugin-architecture

Separating an unchanging small core from plugins of unknown number: the core file's known plugin-name count falling from four to zero, measuring the number of lines edited in the core when a fifth plugin is added, the core running with a one-file closure with no plugins attached, and counting how many requests a contract-breaking plugin stops in each arrangement.

The component boundary produced a unit of reuse, but those measures were all at release time. The
open question is which units can be plugged in and out while the program is **running**. A
boundary is a release decision; pluggability is a run-time decision, and the two differ.

This lesson examines the pattern that splits units into two classes: an unchanging small part
that runs on its own, and parts of unknown number attached on top of it. Four things are measured:
the names the small part knows about the others, the lines a new part edits, whether the core runs
with none attached, and whether a broken part stops the whole.

#### Core, Plugin, and Contract

The **microkernel** pattern declares three things. The first is the **core**: the smallest working
whole that produces a response with no plugin attached. In the Introduction to Linux course, the
kernel was the operating system's hardware-managing part; the core here is a library's smallest
working part, and the two uses differ. The second is the **plugin**: a unit that extends the
core's capability without the core knowing its name. The third is the **plugin contract**: the
names a plugin must satisfy.

This contract is not new; it is a **port**, established in the Domain-Driven Design course's Ports
and Adapters lesson. The component boundary and its splitting principles were established in the
Design Principles course's Component Cohesion Principles and Component Coupling Principles
lessons. What is described here is a **pattern**, not a principle: which counts change once a
contract binds to a run-time registry. The constraint is one sentence — the core carries no
plugin's name, only the contract's names.

#### The Library's Pluggable Arrangement

The fee calculation splits into two parts. The core finds the base from the tier table, rounds it,
and applies the minimum fee; every item added to or subtracted from the base is a plugin. Four
plugins satisfy one contract: `name`, `fields` naming shipment fields it reads, and `item`
returning cents. The last file belongs to measurement, not the library.

```sh
mkdir -p microkernel/plugins
cat > microkernel/tariff.mjs <<'EOF'
// microkernel/tariff.mjs — tier table: not a plugin, but the core's construction input
export const TIERS = [
  { weightCap: 1, fee: 4990 }, { weightCap: 5, fee: 8490 },
  { weightCap: 15, fee: 14990 }, { weightCap: 30, fee: 24990 },
];
EOF
cat > microkernel/plugins/contract-discount.mjs <<'EOF'
// microkernel/plugins/contract-discount.mjs — negative item based on the contract rate
const RATE = { none: 0, standard: 0.05, bulk: 0.12 };

export const contractDiscount = { name: "contract-discount", fields: ["contract"],
  item: (s, base) => -Math.round(base * (RATE[s.contract] ?? 0)) };
EOF
cat > microkernel/plugins/volume-difference.mjs <<'EOF'
// microkernel/plugins/volume-difference.mjs — the amount by which volumetric weight exceeds actual weight
const VOLUME_DIVISOR = 5000;
const KG_RATE = 900;

export const volumeDifference = { name: "volume-difference", fields: ["weight", "volume"],
  item: (s) => Math.round(Math.max(0, s.volume / VOLUME_DIVISOR - s.weight) * KG_RATE) };
EOF
cat > microkernel/plugins/insurance-fee.mjs <<'EOF'
// microkernel/plugins/insurance-fee.mjs — four thousandths of the declared value
const INSURANCE_RATE = 0.004;

export const insuranceFee = { name: "insurance-fee", fields: ["declaredValue"],
  item: (s) => Math.round(s.declaredValue * INSURANCE_RATE) };
EOF
cat > microkernel/plugins/zone-difference.mjs <<'EOF'
// microkernel/plugins/zone-difference.mjs — fixed additional item based on zone
const DIFFERENCE = { near: 0, mid: 1500, far: 4200 };

export const zoneDifference = { name: "zone-difference", fields: ["zone"],
  item: (s) => DIFFERENCE[s.zone] ?? DIFFERENCE.far };
EOF
cat > sample-shipments.mjs <<'EOF'
// sample-shipments.mjs — the four shipments used across all measures
export const SHIPMENTS = [
  { weight: 3, volume: 24000, zone: "mid", declaredValue: 150000, contract: "standard" },
  { weight: 0.4, volume: 1200, zone: "near", declaredValue: 0, contract: "none" },
  { weight: 12, volume: 90000, zone: "far", declaredValue: 400000, contract: "bulk" },
  { weight: 28, volume: 60000, zone: "mid", declaredValue: 25000, contract: "none" },
];
EOF
```

The comparison runs between two cores. The first imports four components by name and fixes their
order inside itself.

```js
// microkernel/embedded.mjs — a core that imports four components by name and fixes their order inside itself
import { contractDiscount } from "./plugins/contract-discount.mjs";
import { volumeDifference } from "./plugins/volume-difference.mjs";
import { insuranceFee } from "./plugins/insurance-fee.mjs";
import { zoneDifference } from "./plugins/zone-difference.mjs";

const MINIMUM_FEE = 3990;
const ROUNDING_STEP = 50;

export const embeddedCore = (tiers) => ({
  calculate: (shipment) => {
    const tier = tiers.find((t) => shipment.weight <= t.weightCap);
    if (tier === undefined) throw new RangeError("no weight tier");
    const base = tier.fee;
    const total = base
      + contractDiscount.item(shipment, base)
      + volumeDifference.item(shipment, base)
      + insuranceFee.item(shipment, base)
      + zoneDifference.item(shipment, base);
    const net = Math.round(total / ROUNDING_STEP) * ROUNDING_STEP;
    return { amount: Math.max(net, MINIMUM_FEE), applied: 4, dropped: [] };
  },
});
```

The second keeps a plugin registry. A plugin that fails the contract is rejected at attach time;
one that breaks at run time is dropped for that request and its name reported.

```js
// microkernel/pluggable.mjs — the core knows only the contract's names; no plugin name appears
const MINIMUM_FEE = 3990;
const ROUNDING_STEP = 50;

export const CONTRACT = ["name", "fields", "item"];

const satisfies = (e) => typeof e?.name === "string"
  && Array.isArray(e?.fields) && typeof e?.item === "function";

export const core = (tiers) => {
  const registry = [];
  return {
    attach: (e) => (satisfies(e) ? (registry.push(e), true) : false),
    attached: () => registry.map((e) => e.name),
    calculate: (shipment) => {
      const tier = tiers.find((t) => shipment.weight <= t.weightCap);
      if (tier === undefined) throw new RangeError("no weight tier");
      const base = tier.fee;
      const dropped = [];
      let total = base;
      for (const e of registry) {
        let d = null;
        try { if (e.fields.every((f) => shipment[f] !== undefined)) d = e.item(shipment, base); }
        catch { d = null; }
        if (Number.isFinite(d)) total += d; else dropped.push(e.name);
      }
      const net = Math.round(total / ROUNDING_STEP) * ROUNDING_STEP;
      return { amount: Math.max(net, MINIMUM_FEE), applied: registry.length - dropped.length, dropped };
    },
  };
};
```

The only place plugin names are written is the composition root.

```js
// microkernel/main.mjs — composition root: builds the core, attaches plugins in the declared order
import { core } from "./pluggable.mjs";
import { contractDiscount } from "./plugins/contract-discount.mjs";
import { volumeDifference } from "./plugins/volume-difference.mjs";
import { insuranceFee } from "./plugins/insurance-fee.mjs";
import { zoneDifference } from "./plugins/zone-difference.mjs";

export const PLUGINS = [contractDiscount, volumeDifference, insuranceFee, zoneDifference];

export const build = (tiers, plugins = PLUGINS) => {
  const c = core(tiers);
  const rejected = plugins.filter((e) => c.attach(e) === false).length;
  return { c, rejected };
};
```

#### The Number of Names the Core Knows

The script counts how many plugin names appear in three files' text, extracts their closures, runs
the core without plugins, and checks that both arrangements produce the same amount.

```js
// core-measure.mjs — the plugin name count, import closure, and run without plugins for the core files
import { readFileSync } from "node:fs";
import { dirname, join, normalize } from "node:path";
import { TIERS } from "./microkernel/tariff.mjs";
import { embeddedCore } from "./microkernel/embedded.mjs";
import { core, CONTRACT } from "./microkernel/pluggable.mjs";
import { PLUGINS, build } from "./microkernel/main.mjs";
import { SHIPMENTS } from "./sample-shipments.mjs";

const NAMES = PLUGINS.map((e) => e.name);

const closure = (root, seen = new Set()) => {
  if (seen.has(root)) return seen;
  seen.add(root);
  const found = readFileSync(root, "utf8").matchAll(/^import\s.*?from\s+"(\.[^"]+)"/gm);
  for (const [, y] of found) closure(normalize(join(dirname(root), y)), seen);
  return seen;
};

console.log(`plugin = ${NAMES.length}, contract name = ${CONTRACT.length} (${CONTRACT.join(", ")})`);
for (const d of ["embedded.mjs", "pluggable.mjs", "main.mjs"]) {
  const text = readFileSync(`microkernel/${d}`, "utf8");
  console.log(`  ${d.padEnd(14)} known plugin name = ${NAMES.filter((a) => text.includes(a)).length}`
    + `  import closure = ${closure(`microkernel/${d}`).size} file`);
}

const empty = core(TIERS).calculate(SHIPMENTS[0]);
console.log(`\nunplugged core: attached = 0, amount = ${empty.amount}, applied = ${empty.applied}`);
const pairs = SHIPMENTS.map((s) => [embeddedCore(TIERS).calculate(s), build(TIERS).c.calculate(s)]);
console.log(`amounts with four plugins = ${pairs.map(([a]) => a.amount).join(", ")}`
  + `  applied = ${pairs[0][1].applied}`);
console.log(`diverging result = ${pairs.filter(([a, b]) => a.amount !== b.amount).length} / ${pairs.length}`
  + `, rejected plugin = ${build(TIERS).rejected}`);
```

```sh
node core-measure.mjs
```

```
plugin = 4, contract name = 3 (name, fields, item)
  embedded.mjs   known plugin name = 4  import closure = 5 file
  pluggable.mjs  known plugin name = 0  import closure = 1 file
  main.mjs       known plugin name = 4  import closure = 6 file

unplugged core: attached = 0, amount = 8500, applied = 0
amounts with four plugins = 11800, 5000, 24400, 26600  applied = 4
diverging result = 0 / 4, rejected plugin = 0
```

The first three lines measure the pattern. The embedded core carries four plugin names, closure
five files: loading the rule means loading all four plugins too. The pluggable core carries zero
names, closure one file. The names did not vanish, they moved: the composition root knows all
four, closure six files. The pattern shifts the knowledge burden onto a single setup
file.

The last three lines show the core running without plugins: for a 3 kg shipment, the 8,490-cent
base rounds to 8,500 cents, applied items 0. With four plugins the shipment comes to 11,800 cents,
and both arrangements match for all four shipments.

#### The Cost of a Fifth Plugin

Extension cost is the number of lines edited in existing files when a new capability is added. The
fifth plugin adds a fuel difference as a percentage of the base. The block adds it to both
arrangements in a copy of the tree; since the patch also fixes path comments, the count skips one
line.

```sh
cp -r microkernel fifth
cat > fifth/plugins/fuel-difference.mjs <<'EOF'
// fifth/plugins/fuel-difference.mjs — fifth plugin: fuel difference as a percentage of the base
const FUEL_RATE = 0.085;

export const fuelDifference = { name: "fuel-difference", fields: ["zone"],
  item: (s, base) => Math.round(base * FUEL_RATE) };
EOF
python3 - <<'EOF'
from pathlib import Path

IMPORT = 'import { zoneDifference } from "./plugins/zone-difference.mjs";'
FUEL = 'import { fuelDifference } from "./plugins/fuel-difference.mjs";'
PATCH = {
    "embedded.mjs": [(IMPORT, f"{IMPORT}\n{FUEL}"),
                   ("      + zoneDifference.item(shipment, base);",
                    "      + zoneDifference.item(shipment, base)\n      + fuelDifference.item(shipment, base);"),
                   ("applied: 4", "applied: 5"), ("four components", "five components")],
    "main.mjs": [(IMPORT, f"{IMPORT}\n{FUEL}"), ("zoneDifference];", "zoneDifference, fuelDifference];")],
}
for path in sorted(Path("fifth").rglob("*.mjs")):
    text = path.read_text().replace("// microkernel/", "// fifth/")
    for old, new in PATCH.get(path.name, []):
        text = text.replace(old, new)
    path.write_text(text)
EOF
for d in embedded.mjs pluggable.mjs main.mjs; do
  f=$(diff <(tail -n +2 microkernel/$d) <(tail -n +2 fifth/$d))
  echo "$d  removed line = $(echo "$f" | grep -c '^<')  added line = $(echo "$f" | grep -c '^>')"
done
echo "added file = $(( $(find fifth -name '*.mjs' | wc -l) - $(find microkernel -name '*.mjs' | wc -l) ))"
cat > fifth-check.mjs <<'EOF'
// fifth-check.mjs — do both arrangements produce the same amount in the patched tree?
import { TIERS } from "./fifth/tariff.mjs";
import { embeddedCore } from "./fifth/embedded.mjs";
import { build } from "./fifth/main.mjs";
import { SHIPMENTS } from "./sample-shipments.mjs";
const both = [embeddedCore(TIERS), build(TIERS).c].map((c) => c.calculate(SHIPMENTS[0]).amount);
console.log(`with fifth plugin: embedded = ${both[0]}, pluggable = ${both[1]}`);
EOF
node fifth-check.mjs
```

```
embedded.mjs  removed line = 2  added line = 4
pluggable.mjs  removed line = 0  added line = 0
main.mjs  removed line = 1  added line = 2
added file = 1
with fifth plugin: embedded = 12500, pluggable = 12500
```

The same capability added one file in both arrangements and produced the same amount, but the
edited lines fell in different places. In the embedded core, three places changed — the import
line, the summation term, the applied count — 2 lines out, 4 in. In the pluggable core file, 0
lines changed; only the composition root changed. The measure says the core is **closed**: a new
item type needs no retesting or re-release.

#### The Plugin That Breaks the Contract

The second side of pluggability: what gets plugged in can be broken. The block replaces the
insurance plugin with one whose signature is right but whose behavior is broken.

```sh
cp -r microkernel broken
cat > broken/plugins/insurance-fee.mjs <<'EOF'
// broken/plugins/insurance-fee.mjs — the plugin that breaks the contract at run time: throws an error instead of a number
export const insuranceFee = { name: "insurance-fee", fields: ["declaredValue"],
  item: () => { throw new TypeError("could not read declared value"); } };
EOF
```

```js
// resilience.mjs — counts how many requests a contract-breaking plugin stops in each arrangement
import { TIERS } from "./microkernel/tariff.mjs";
import { embeddedCore as soundEmbedded } from "./microkernel/embedded.mjs";
import { PLUGINS, build as soundBuild } from "./microkernel/main.mjs";
import { embeddedCore as brokenEmbedded } from "./broken/embedded.mjs";
import { build as brokenBuild } from "./broken/main.mjs";
import { SHIPMENTS } from "./sample-shipments.mjs";

const ARRANGEMENTS = [
  ["embedded  / sound ", () => soundEmbedded(TIERS)],
  ["embedded  / broken", () => brokenEmbedded(TIERS)],
  ["pluggable / sound ", () => soundBuild(TIERS).c],
  ["pluggable / broken", () => brokenBuild(TIERS).c],
];

for (const [name, build] of ARRANGEMENTS) {
  const c = build();
  let answered = 0, first = "-", dropped = "-", error = "";
  for (const s of SHIPMENTS) {
    try {
      const r = c.calculate(s);
      if (++answered === 1) { first = String(r.amount); dropped = r.dropped.join(",") || "none"; }
    } catch (e) { error = error || `${e.name}: ${e.message}`; }
  }
  console.log(`${name}  answered = ${answered}/${SHIPMENTS.length}  first amount = ${first.padStart(6)}`
    + `  ${error === "" ? `dropped = ${dropped}` : error}`);
}

const itemless = { name: "fuel-difference", fields: ["zone"] };
const { c, rejected } = soundBuild(TIERS, [...PLUGINS, itemless]);
console.log(`\nplugin that breaks the contract at attach time: rejected = ${rejected}, attached = ${c.attached().length}`);
console.log(`  attached names = ${c.attached().join(", ")}`);
console.log(`  same shipment's amount = ${c.calculate(SHIPMENTS[0]).amount}`);
```

```sh
node resilience.mjs
```

```
embedded  / sound   answered = 4/4  first amount =  11800  dropped = none
embedded  / broken  answered = 0/4  first amount =      -  TypeError: could not read declared value
pluggable / sound   answered = 4/4  first amount =  11800  dropped = none
pluggable / broken  answered = 4/4  first amount =  11200  dropped = insurance-fee

plugin that breaks the contract at attach time: rejected = 1, attached = 4
  attached names = contract-discount, volume-difference, insurance-fee, zone-difference
  same shipment's amount = 11800
```

The same broken file gives two different results. In the embedded core, all four requests went
unanswered: since the component call sits inside the calculation's body, the error stopped the
whole calculation. In the pluggable core, all four were answered; the broken plugin was dropped
and reported, and the amount fell to 11,200, meaning the insurance item is missing — not a correct
answer but an **incomplete** one, and reporting it makes the gap visible.

The last three lines show the contract's other side. The plugin with no `item` field was rejected
at attach time: rejected 1, attached 4, amount at 11,800. Two lines of defense exist — one checks
the signature, the other the returned value — and neither knows the plugin's name.

#### The Trade-off's Numbers

The gain was measured in two **quality attributes**. In modifiability: plugin names the core knows
fell from 4 to 0, closure fell from 5 files to 1, and the fifth plugin edited 0 core lines. In
availability: answered requests rose from 0/4 to 4/4.

The cost has three items. The first is relocation: the four names gathered into the composition
root, whose closure became 6 files; only that file now reads the order. The second is the
incomplete answer: the core staying up does not mean a complete result. The third is the contract
— three names repeat in every plugin, and changing it changes five files together.

#### Summary

- The microkernel pattern declares three things: a core that runs without plugins, plugins of
  unknown number, and a contract declaring the names a plugin must satisfy; the contract is a
  port.
- The embedded core carries 4 plugin names, closure 5 files; the pluggable core carries 0 names,
  closure 1 file. The names moved to the composition root, closure 6 files.
- The core without plugins produced 8,500 cents for a 3 kg shipment, 11,800 with four plugins, and
  both arrangements matched for all four shipments.
- The fifth plugin added 1 file in both arrangements; the embedded core had 2 lines out and 4 in,
  the pluggable core file 0.
- With a plugin that breaks the contract at run time, answered requests were 0/4 embedded and 4/4
  pluggable; a plugin failing the contract at attach time was rejected.

#### Next Step

All of this lesson's measures were about what the **core** knows. The plugins never knew each
other, but the core knew all of them: the order, who to call, and how to sum the result all sat in
one place. The next lesson removes that place too. Units recognize neither each other nor a
calling center; they write to a shared field, read from it, and the field itself provides the
meeting point. Its name is the **blackboard**, and three things are measured: the names units know
about each other, the redundant work spent on one result — how many units read the same data, how
many turns spun empty — and whether the same input gives the same output every run.

### Blackboard Architecture

Source: https://academia.sh/en/courses/architectural-styles/blackboard-architecture

Resolvers that do not know each other meeting through a shared field of information: the number of names units know about each other coming out to zero in determining a fee zone from an address, the extra reads and polls spent to reach the same result, the number of files a new resolver edits, and whether the result is deterministic across twenty-four poll orders.

The previous lesson's core did not know the plugins' names, but it knew what the order was and
how the result would be summed.

This lesson removes that center too. Units recognize neither each other nor a pattern that calls
them; they write to a shared field, read from it, and the field itself provides the meeting point.
Three things are measured: the names they know, the extra work spent on the same result — how
many units read the same data, how many rounds spun empty — and whether the same input gives the
same output every run.

#### Blackboard, Resolver, and Control

The **blackboard** pattern has three parts. The first is the board: the shared field of named
partial results. The second is the **resolvers**: units that read from and write to the board,
not knowing each other's names. The third is the **control loop**: the loop deciding which
resolver to poll and when, without knowing what they do.

This differs from the observer pattern's notification: in the Design Patterns course's Observer
lesson, the publisher keeps a subscriber list and sends the notification directly, while on the
blackboard the writer does not know who will read it. The distinguishing constraint is this: a
resolver can only read and write the board, never call another resolver.

#### Ambiguous Zone and Four Resolvers

The zone the fee depends on cannot be determined from the incoming address alone: the postal code
is missing a digit. Four resolvers satisfy the same contract — `name` and `resolve(read)` — and
each returns a partial result along with a **confidence** degree. The province-name resolver
writes no zone claim, only the normalized province name; the postal-code resolver needs that name
when the code is incomplete.

```sh
mkdir -p blackboard/resolvers
cat > blackboard/fee.mjs <<'EOF'
// blackboard/fee.mjs — zone to fee: same shipment, coefficient by zone
const BASE = 8490;
const STEP = 50;
const COEFFICIENT = { near: 1, mid: 1.35, far: 1.8 };

export const amount = (zone) => Math.round((BASE * (COEFFICIENT[zone] ?? COEFFICIENT.far)) / STEP) * STEP;
EOF
cat > blackboard/resolvers/province-name.mjs <<'EOF'
// blackboard/resolvers/province-name.mjs — normalizes the province name; writes no zone claim
const PROVINCES = ["istanbul", "ankara", "izmir", "van"];

export const provinceName = { name: "province-name", resolve: (read) => {
  const name = read("address").province.toLowerCase();
  return PROVINCES.includes(name) ? { confidence: 0.7, write: { normalizedProvince: name } } : null;
} };
EOF
cat > blackboard/resolvers/postal-code.mjs <<'EOF'
// blackboard/resolvers/postal-code.mjs — full code directly; missing code falls to the province's farthest zone
const PREFIX = { 34: "near", "06": "mid", 35: "mid", 65: "far" };
const PROVINCE_FARTHEST = { istanbul: "mid", ankara: "far", izmir: "far", van: "far" };

export const postalCode = { name: "postal-code", resolve: (read) => {
  const code = read("address").postalCode;
  if (code.length === 5) return { confidence: 0.85, write: { zone: PREFIX[code.slice(0, 2)] ?? "far" } };
  const province = read("normalizedProvince");
  return province === undefined ? null : { confidence: 0.3, write: { zone: PROVINCE_FARTHEST[province] ?? "far" } };
} };
EOF
cat > blackboard/resolvers/coordinate.mjs <<'EOF'
// blackboard/resolvers/coordinate.mjs — maps the approximate straight-line distance to the center against thresholds
const CENTER = { latitude: 41.02, longitude: 28.98 };
const THRESHOLD = [[300, "near"], [900, "mid"]];

export const coordinate = { name: "coordinate", resolve: (read) => {
  const a = read("address");
  const dLatitude = (a.latitude - CENTER.latitude) * 111;
  const dLongitude = (a.longitude - CENTER.longitude) * 111 * Math.cos((CENTER.latitude * Math.PI) / 180);
  const km = Math.round(Math.hypot(dLatitude, dLongitude));
  return { confidence: 0.9, write: { zone: THRESHOLD.find(([s]) => km <= s)?.[1] ?? "far" } };
} };
EOF
cat > blackboard/resolvers/shipment-history.mjs <<'EOF'
// blackboard/resolvers/shipment-history.mjs — the zone of the same customer's most recent shipments
const HISTORY = { "M-118": "near", "M-204": "mid" };

export const shipmentHistory = { name: "shipment-history", resolve: (read) => {
  const z = HISTORY[read("address").customer];
  return z === undefined ? null : { confidence: 0.55, write: { zone: z } };
} };
EOF
```

#### The Board, the Control Loop, and the Direct Counterpart

The board takes one of two write rules: under **first write wins**, a second write to an occupied
key is rejected; under **most confident wins**, only a write with higher confidence passes.

```js
// blackboard/board.mjs — the shared field: write rule, reads and writes counters
export const board = (rule, initial) => {
  const field = new Map(Object.entries(initial));
  const confidence = new Map();
  const readers = new Map();
  const counters = { reads: 0, writes: 0, rejected: 0 };
  return {
    read: (key, who) => {
      counters.reads += 1;
      readers.set(key, (readers.get(key) ?? new Set()).add(who));
      return field.get(key);
    },
    write: (key, value, c) => {
      const occupied = field.has(key);
      const passes = rule === "first-write" ? occupied === false : (confidence.get(key) ?? -1) < c;
      if (passes === false) { counters.rejected += 1; return; }
      field.set(key, value);
      confidence.set(key, c);
      counters.writes += 1;
    },
    value: (key) => field.get(key),
    measure: () => ({ ...counters, readersByKey: Object.fromEntries([...readers].map(([k, s]) => [k, s.size])) }),
  };
};
```

The control loop polls in the given order: a resolver that contributes leaves the pending set, one
that cannot is polled again next round, and the loop runs until it sees a round with no
contribution.

```js
// blackboard/control.mjs — control loop: polls in the given order, cycles rounds until no contribution remains
export const controlLoop = (t, resolvers) => {
  const pending = new Set(resolvers);
  let round = 0, poll = 0, contribution = 0, emptyRound = 0;
  while (true) {
    round += 1;
    let roundContribution = 0;
    for (const r of resolvers) {
      if (pending.has(r) === false) continue;
      poll += 1;
      const result = r.resolve((k) => t.read(k, r.name));
      if (result === null) continue;
      pending.delete(r);
      for (const [k, v] of Object.entries(result.write)) t.write(k, v, result.confidence);
      contribution += 1;
      roundContribution += 1;
    }
    if (roundContribution === 0) { emptyRound += 1; break; }
  }
  return { round, poll, contribution, emptyRound };
};
```

The arrangement to compare calls the same four resolvers directly: the order and the selection
rule are written in this file; the order was parameterized for measurement.

```js
// blackboard/direct.mjs — direct call: this file knows the four names, the order, and the selection rule
import { provinceName } from "./resolvers/province-name.mjs";
import { postalCode } from "./resolvers/postal-code.mjs";
import { coordinate } from "./resolvers/coordinate.mjs";
import { shipmentHistory } from "./resolvers/shipment-history.mjs";

export const ORDER = [provinceName, postalCode, coordinate, shipmentHistory];

export const directResolve = (address, order = ORDER) => {
  const data = { address };
  let reads = 0, contribution = 0, best = { confidence: -1, zone: undefined };
  for (const r of order) {
    const result = r.resolve((k) => (reads += 1, data[k]));
    if (result === null) continue;
    contribution += 1;
    Object.assign(data, result.write);
    const z = result.write.zone;
    if (z !== undefined && result.confidence > best.confidence) best = { confidence: result.confidence, zone: z };
  }
  return { ...best, round: 1, poll: order.length, contribution, reads };
};
```

```js
// blackboard/main.mjs — composition root: resolver list and the measured address
import { postalCode } from "./resolvers/postal-code.mjs";
import { provinceName } from "./resolvers/province-name.mjs";
import { coordinate } from "./resolvers/coordinate.mjs";
import { shipmentHistory } from "./resolvers/shipment-history.mjs";

export const RESOLVERS = [postalCode, provinceName, coordinate, shipmentHistory];

export const ADDRESS = { postalCode: "0680", province: "ANKARA", latitude: 39.93, longitude: 32.86, customer: "M-118" };
```

#### Known Names and Work Done

The script counts how many **other** unit names appear in each file and runs both arrangements.

```js
// names-and-work.mjs — how many other unit names each unit knows, and the work both arrangements do
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { board } from "./blackboard/board.mjs";
import { controlLoop } from "./blackboard/control.mjs";
import { directResolve } from "./blackboard/direct.mjs";
import { RESOLVERS, ADDRESS } from "./blackboard/main.mjs";
import { amount } from "./blackboard/fee.mjs";

const NAMES = RESOLVERS.map((r) => r.name);
const files = readdirSync("blackboard", { recursive: true })
  .map((a) => join("blackboard", a)).filter((y) => y.endsWith(".mjs")).sort();

const table = files.map((path) => {
  const text = readFileSync(path, "utf8");
  const own = NAMES.find((a) => path.includes(a));
  return [path, NAMES.filter((a) => a !== own && text.includes(a))];
});
console.log(`file = ${table.length}, other unit name 0 = ${table.filter(([, b]) => b.length === 0).length}`);
for (const [path, b] of table.filter(([, b]) => b.length > 0)) {
  console.log(`  ${path.padEnd(23)} other unit name = ${b.length}  (${b.join(", ")})`);
}

const t = board("most-confident", { address: ADDRESS });
const o = controlLoop(t, RESOLVERS);
const m = t.measure();
const z = t.value("zone");
console.log(`blackboard     zone = ${z} amount = ${amount(z)}`
  + `  round = ${o.round}  poll = ${o.poll}  contribution = ${o.contribution}  empty round = ${o.emptyRound}`);
console.log(`  reads = ${m.reads}  writes = ${m.writes}  rejected write = ${m.rejected}`
  + `  readers by key = ${Object.entries(m.readersByKey).map(([a, n]) => `${a}:${n}`).join(" ")}`);

const d = directResolve(ADDRESS);
console.log(`direct call    zone = ${d.zone} amount = ${amount(d.zone)}`
  + `  round = ${d.round}  poll = ${d.poll}  contribution = ${d.contribution}  empty round = 0`);
console.log(`  reads = ${d.reads}  writes = none  rejected write = none`);
```

```sh
node names-and-work.mjs
```

```
file = 9, other unit name 0 = 7
  blackboard/direct.mjs   other unit name = 4  (postal-code, province-name, coordinate, shipment-history)
  blackboard/main.mjs     other unit name = 4  (postal-code, province-name, coordinate, shipment-history)
blackboard     zone = mid amount = 11450  round = 3  poll = 5  contribution = 4  empty round = 1
  reads = 7  writes = 2  rejected write = 2  readers by key = address:4 normalizedProvince:1
direct call    zone = mid amount = 11450  round = 1  poll = 4  contribution = 4  empty round = 0
  reads = 5  writes = none  rejected write = none
```

Seven of the nine files carry no other unit's name: the board, the control loop, the fee
conversion, and all four resolvers are 0. Two files know the names, both carrying 4, and they
differ in kind: on the blackboard the knowledge sits in a composition-root list; in the
direct-call arrangement it sits next to the call order and the selection rule.

The second half shows the cost. Both arrangements found the same zone and amount, but the
blackboard spent 3 rounds, 5 polls, and 7 reads; the direct call finished in 1 round with 4 polls
and 5 reads. The extra poll belongs to the postal-code resolver: on the first round the normalized
province was not yet on the board, so it was polled again on the second; one round spun empty just
to detect termination. Four units read the `address` key, since none knows what the others read.

#### Same Input, Twenty-Four Orders

The control loop takes the order from outside; whether the result changes when the order changes
is a measurable question. The 24 arrangements of the four resolvers are run.

```js
// determinism.mjs — same input, 24 different poll orders: is the result and contribution order-dependent?
import { board } from "./blackboard/board.mjs";
import { controlLoop } from "./blackboard/control.mjs";
import { directResolve } from "./blackboard/direct.mjs";
import { RESOLVERS, ADDRESS } from "./blackboard/main.mjs";
import { amount } from "./blackboard/fee.mjs";

const permutation = (items) => (items.length <= 1 ? [items]
  : items.flatMap((e, i) => permutation([...items.slice(0, i), ...items.slice(i + 1)]).map((p) => [e, ...p])));
const ORDERS = permutation(RESOLVERS);

const distribution = (runs, attr) => {
  const m = new Map();
  for (const r of runs) m.set(r[attr], (m.get(r[attr]) ?? 0) + 1);
  return [...m].sort();
};

const report = (label, runs) => {
  const zones = distribution(runs, "zone");
  console.log(`${label.padEnd(27)} distinct zone = ${zones.length}:`
    + ` ${zones.map(([z, n]) => `${z} ${n} orders ${amount(z)}`).join(", ")}`);
  console.log(`  contribution = ${distribution(runs, "contribution").map(([k, n]) => `${k} (${n} orders)`).join(", ")}`);
};

for (const rule of ["first-write", "most-confident"]) {
  report(`blackboard / ${rule}`, ORDERS.map((order) => {
    const t = board(rule, { address: ADDRESS });
    const o = controlLoop(t, order);
    return { zone: t.value("zone"), contribution: o.contribution };
  }));
}
report("direct call", ORDERS.map((order) => directResolve(ADDRESS, order)));
```

```sh
node determinism.mjs
```

```
blackboard / first-write    distinct zone = 3: far 2 orders 15300, mid 11 orders 11450, near 11 orders 8500
  contribution = 4 (24 orders)
blackboard / most-confident distinct zone = 1: mid 24 orders 11450
  contribution = 4 (24 orders)
direct call                 distinct zone = 1: mid 24 orders 11450
  contribution = 3 (12 orders), 4 (12 orders)
```

The first pair of lines shows the pattern's most expensive flaw. Under the first-write rule, the
same address resolved to three different zones across the 24 orderings, and the same shipment was
fee'd at 8,500, 11,450, or 15,300 cents. What determines the result is not the address but which
resolver the control loop polls first; under this rule the pattern is **not deterministic**.

The second pair shows the flaw is in the write rule, not the board: under the most-confident rule,
all 24 orderings gave the same zone and amount. Determinism comes from having a comparable
confidence degree in the contract.

The third pair gives the blackboard's counterpart. The direct call produced the same result in
every order, but in 12 of the 24 orderings the contribution came out 3 instead of 4: the
postal-code resolver was called before the normalized province name, so it was eliminated and
never asked again. On the blackboard, the contribution is 4 in every case, since a pending
resolver is polled again next round.

#### The Fifth Resolver

The fifth resolver proposes the default zone written in the customer contract. The block adds it
to a copy of the tree, lists the edited files, and loads the patched tree.

```sh
cp -r blackboard fifth
cat > fifth/resolvers/contract-default.mjs <<'EOF'
// fifth/resolvers/contract-default.mjs — fifth resolver: default zone written in the contract
const CONTRACT = { "M-118": "mid", "M-204": "near" };

export const contractDefault = { name: "contract-default", resolve: (read) => {
  const z = CONTRACT[read("address").customer];
  return z === undefined ? null : { confidence: 0.4, write: { zone: z } };
} };
EOF
python3 - <<'EOF'
from pathlib import Path

HISTORY = 'import { shipmentHistory } from "./resolvers/shipment-history.mjs";'
NEW = 'import { contractDefault } from "./resolvers/contract-default.mjs";'
PAIR = [(HISTORY, f"{HISTORY}\n{NEW}"), ("shipmentHistory];", "shipmentHistory, contractDefault];")]
for name in ["main.mjs", "direct.mjs"]:
    path = Path("fifth") / name
    text = path.read_text().replace("// blackboard/", "// fifth/")
    for old, new in PAIR:
        text = text.replace(old, new)
    path.write_text(text)
EOF
diff -rq blackboard fifth
node -e 'Promise.all([import("./fifth/main.mjs"), import("./fifth/direct.mjs")])
  .then(([k, d]) => console.log(`fifth tree: main list = ${k.RESOLVERS.length}, direct order = ${d.ORDER.length}`))'
```

```
Files blackboard/direct.mjs and fifth/direct.mjs differ
Files blackboard/main.mjs and fifth/main.mjs differ
Only in fifth/resolvers: contract-default.mjs
fifth tree: main list = 5, direct order = 5
```

One file was added and two edited: the composition root and the direct-call arrangement. The
board, the control loop, the fee conversion, and the four resolvers did not change at all. The
file count is the same in both arrangements, not the kind — one is only a list, the other the call
order plus the selection rule.

#### The Trade-off's Numbers

The gain was measured in two quality attributes: in modifiability, seven of nine files carry no
other unit's name, and the fifth resolver left the board, the control loop, and the four resolvers
untouched; in integrity, the contribution is 4 in every one of the 24 orderings.

The cost has three items. Extra work: 3 rounds, 5 polls, and 7 reads against the direct call's 1
round, 4 polls, and 5 reads. Determinism: with the wrong write rule, the same address produced
three different fees. Visibility: only the board's record shows which resolver determined the
result.

#### Summary

- The blackboard pattern has three parts: the shared field partial results are written to,
  resolvers that do not know each other's names, and a control loop that does not know what they
  do; since the writer does not know who reads, this differs from the observer pattern's
  notification.
- Seven of nine files carry 0 other-unit names; the two that know the names are the composition
  root and the direct-call arrangement, and both carry 4.
- For the same result the blackboard spent 3 rounds, 5 polls, and 7 reads; the direct call spent 1
  round, 4 polls, and 5 reads; 4 units read the `address` key, and 1 round spun empty.
- Under the first-write rule, the 24 orderings produced 3 zones and 3 amounts (8,500, 11,450,
  15,300); under the most-confident rule, all gave a single zone and a single amount.
- The contribution is 4 in all 24 orderings on the blackboard, and 3 in 12 orderings for the
  direct call; the fifth resolver added 1 file and edited 2.

#### Next Step

This topic addressed **how units are arranged** and which way the dependency arrow points: layered
orders, inward-facing rings, distributable components, pluggable plugins, and resolvers that never
know each other. In the blackboard pattern, units wrote to a shared field, read from it, and the
field itself provided the meeting point. The next topic asks not about arrangement but about the
**conversation style**: in an interaction between two units, who initiates, who waits, and how
much must be known about the other side.

## Interaction Styles

Choosing the arrangement in which units talk to each other: separating roles into client and server, decentralized peer interaction, distributing the responsibility between the model and the view across controller, presenter, and view model variants, a pipe-and-filter chain for stream-based processing, event-based loose coupling, and queues and streams as the backbone of asynchronous integration.

### Client–Server

Source: https://academia.sh/en/courses/architectural-styles/client-server

Measuring in code the separation of roles into initiator and waiter: the delivery side's count of names it must know about the pricing side dropping from four to one, which client breaks when the pricing side renames its own internal names, and the polling count as the cost of the waiting side not knowing the initiating side.

Structural styles addressed how units are arranged and which way the dependency arrow points.
In the blackboard arrangement, units did not know each other at all: they wrote to a shared
knowledge area, read from it, and the area itself brokered the encounter. This topic asks not
about arrangement but about the manner of conversation. In an interaction between two units: who
initiates, who waits, and how much must be known about the other side.

The first style gives the sharpest answer to these three questions: one side always initiates,
the other always waits, and the obligation to know piles up in a single direction. The
Client–Server Model lesson in the Computer Networks curriculum established this separation at
the network level; the criterion that determines the role is not hardware but who initiates the
connection. Address, listening calls, and port numbers belong there and are not revisited here.
The question here sits one level up: how does the same role separation appear between two
modules inside a single codebase, and what does it measure.

#### What Gets Measured

Two contexts of the library face each other in this lesson. The delivery operations context must
know a shipment's fee, but the calculation lives inside the pricing context. Two arrangements of
the interaction are compared and three counts are taken: the number of names the calling side
must know about the other side, the number of clients broken when the other side reorganizes its
own internals, and the number of connections the waiting side sets up toward the initiating side.

The quality attribute it connects to is maintainability, and the quality question is: when the
pricing side changes its own internal names, how many files must be edited on the delivery side.

#### Inside the Pricing Side

The fee calculation is spread across three modules. These three files are identical in both
arrangements; the only thing that changes is how the delivery side reaches them.

```sh
mkdir -p fee direct client server
```

```js
// fee/zone.mjs — derives the fee zone from the address
const ZONE = { "34": "near", "06": "mid", "35": "mid", "65": "far" };
export const determineZone = (province) => ZONE[province] ?? "far";
```

```js
// fee/tariff.mjs — weight tier, zone coefficient, and minimum fee
const COEFFICIENT = { near: 1, mid: 1.4, far: 2.1 };
const MINIMUM = { near: 30, mid: 45, far: 70 };
export const tier = (kg) => (kg <= 1 ? 1 : kg <= 5 ? 2 : kg <= 20 ? 3 : 4);
export const applyTariff = (zone, kg) =>
  Math.max(MINIMUM[zone], 25 * tier(kg) * COEFFICIENT[zone]);
```

```js
// fee/discount.mjs — contracted customer discount and volume discount
const CONTRACT = { "CUST-1": 0.1, "CUST-2": 0.2 };
export const applyDiscount = (amount, customer, quantity) =>
  amount * (1 - (CONTRACT[customer] ?? 0)) * (quantity >= 10 ? 0.95 : 1);
```

#### The Arrangement Where Internal Names Are Called Directly

In the first arrangement, the delivery desk sequences the calculation steps itself. It imports
three modules, calls four names, and holds the intermediate values itself.

```js
// direct/operation.mjs — the delivery desk calls pricing's internal names itself
import { determineZone } from "../fee/zone.mjs";
import { tier, applyTariff } from "../fee/tariff.mjs";
import { applyDiscount } from "../fee/discount.mjs";

export function calculatePrice(shipment) {
  const zone = determineZone(shipment.province);
  const raw = applyTariff(zone, shipment.weight);
  const net = applyDiscount(raw, shipment.customer, shipment.quantity);
  return { zone, tier: tier(shipment.weight), raw, net: Math.round(net * 100) / 100 };
}
```

There is no role in this arrangement. Both sides look equal, because the calling side knows the
callee's internal ordering: which step comes before which, which intermediate value goes into
which function. The order of the steps sits on one side, the steps themselves on the other.

#### Role Separation

In the second arrangement, the pricing side opens a single entry point. None of the internal
names leave; what leaves is a request shape. The `operation` field says what is being requested,
and the rest is the request's data.

```js
// server/pricing.mjs — single entry point; internal names never leave
import { determineZone } from "../fee/zone.mjs";
import { tier, applyTariff } from "../fee/tariff.mjs";
import { applyDiscount } from "../fee/discount.mjs";

let processed = 0;

function price(s) {
  const zone = determineZone(s.province);
  const raw = applyTariff(zone, s.weight);
  const net = applyDiscount(raw, s.customer, s.quantity);
  return { zone, tier: tier(s.weight), raw, net: Math.round(net * 100) / 100 };
}

export function handleRequest(request) {
  processed += 1;
  if (request.operation === "price") return { state: "ok", result: price(request.shipment) };
  return { state: "unknown-operation", operation: request.operation };
}

export const processedRequests = () => processed;
```

```js
// client/operation.mjs — recognizes only the request shape and a single name
import { handleRequest } from "../server/pricing.mjs";

export function calculatePrice(shipment) {
  const response = handleRequest({ operation: "price", shipment });
  if (response.state !== "ok") throw new Error(`request not fulfilled: ${response.state}`);
  return response.result;
}
```

The order of the steps switched sides. It now sits on the waiting side, and the initiating side
only states what it wants. When an unknown operation arrives, the response is not a crash but a
state value; this too is part of the role — the waiting side must respond to every incoming
request.

#### Measurement

The measurement reads the import lines of the two client files to extract the known name count,
verifies that both arrangements produce the same result, then sets up a situation where the
pricing side reorganizes its own internals: four internal names are renamed and both clients are
run against this new tree.

```js
// count-names.mjs — known name count in both arrangements, result equality, and the client broken by internal renaming
import { cpSync, readFileSync, writeFileSync } from "node:fs";
import { calculatePrice as directPrice } from "./direct/operation.mjs";
import { calculatePrice as clientPrice } from "./client/operation.mjs";
import { processedRequests } from "./server/pricing.mjs";

const IMPORT = /import\s*\{([^}]*)\}\s*from\s*"(\.[^"]+)"/g;
const knownNames = (path) => {
  const bag = [...readFileSync(path, "utf8").matchAll(IMPORT)];
  const names = bag.flatMap((m) => m[1].split(",").map((s) => s.trim().split(" ")[0]));
  return { module: bag.length, names };
};

for (const path of ["direct/operation.mjs", "client/operation.mjs"]) {
  const { module, names } = knownNames(path);
  console.log(`${path.padEnd(24)} known modules=${module} known names=${names.length} [${names.join(" ")}]`);
}

const SHIPMENTS = [
  { province: "34", weight: 0.8, customer: "CUST-1", quantity: 3 },
  { province: "65", weight: 12, customer: "CUST-2", quantity: 12 },
  { province: "06", weight: 30, customer: "CUST-9", quantity: 1 },
];
const a = SHIPMENTS.map(directPrice);
const b = SHIPMENTS.map(clientPrice);
console.log(`result: ${JSON.stringify(a[1])}`);
console.log(`arrangements equal = ${JSON.stringify(a) === JSON.stringify(b)}, requests turned into calls = ${processedRequests()}`);

for (const d of ["fee", "direct", "client", "server"]) cpSync(d, `new/${d}`, { recursive: true });
const NEW_NAME = { determineZone: "findZone", applyTariff: "calculateTariff", applyDiscount: "processDiscount", tier: "weightTier" };
for (const path of ["new/fee/zone.mjs", "new/fee/tariff.mjs", "new/fee/discount.mjs", "new/server/pricing.mjs"]) {
  let text = readFileSync(path, "utf8");
  for (const [oldName, newName] of Object.entries(NEW_NAME)) text = text.replaceAll(oldName, newName);
  writeFileSync(path, text);
}

for (const path of ["./new/direct/operation.mjs", "./new/client/operation.mjs"]) {
  try {
    const { calculatePrice } = await import(path);
    console.log(`${path.padEnd(32)} after internal names changed net=${calculatePrice(SHIPMENTS[1]).net}`);
  } catch (error) {
    console.log(`${path.padEnd(32)} broke: ${error.message.split("\n")[0]}`);
  }
}
```

```sh
node count-names.mjs
```

```
direct/operation.mjs     known modules=3 known names=4 [determineZone tier applyTariff applyDiscount]
client/operation.mjs     known modules=1 known names=1 [handleRequest]
result: {"zone":"far","tier":3,"raw":157.5,"net":119.7}
arrangements equal = true, requests turned into calls = 3
./new/direct/operation.mjs       broke: The requested module '../fee/discount.mjs' does not provide an export named 'applyDiscount'
./new/client/operation.mjs       after internal names changed net=119.7
```

#### Reading the Numbers

The known module count dropped from 3 to 1, the known name count from 4 to 1. The second number
is the measure of the obligation to know: after role separation, the only thing the delivery side
knows about the pricing side is one function name and one request shape. The fee for the three
shipments came out identical across both arrangements, so the measurement compares two
arrangements that do the same job.

The last two lines answer the maintainability question. When the four internal names changed,
the directly calling client broke at run time and the failure message named the missing name by
its own name; the role-separated client kept producing the same result and its file was not
touched by a single character. The number of files edited is 4 internal files plus 1 client file
in the first arrangement, 4 internal files plus 0 client files in the second. The difference
grows with the number of clients: in a library with ten clients, the same renaming requires
editing ten more files in the first arrangement, none in the second.

The gain is not free. The pricing side gained one file (the entry point), and an unfulfilled
request is now carried as a state value; the caller must check this state on every response. The
`unknown-operation` response appears at run time, not at compile time — a reference to a name
that does not exist would have been caught earlier, as an import error, in the directly calling
arrangement.

#### The Cost of Asymmetry

The second face of role separation is this: the waiting side does not know the initiating side.
The measure of this is the number of imports going from the pricing side to the delivery side.
The result determines how the delivery desk learns when the tariff version changes — since the
server cannot deliver the news, the client must ask. The script below sets up three separate
tariff versions across a twelve-slice calendar and counts the case where the client asks once per
slice.

```js
// polling.mjs — because the waiting side does not know the initiating side, news is learned only by asking
import { readFileSync } from "node:fs";

const SERVER_SIDE = ["server/pricing.mjs", "fee/zone.mjs", "fee/tariff.mjs", "fee/discount.mjs"];
const backImports = SERVER_SIDE
  .flatMap((path) => [...readFileSync(path, "utf8").matchAll(/from "(\.[^"]+)"/g)])
  .filter((m) => m[1].includes("client")).length;
console.log(`imports from server side to client = ${backImports}`);

const CALENDAR = ["V1", "V1", "V1", "V2", "V2", "V2", "V2", "V2", "V3", "V3", "V3", "V3"];
let asked = 0;
const handleRequest = (request) => {
  asked += 1;
  return { state: "ok", version: CALENDAR[request.slice] };
};

let known = null, learned = 0, emptyQuery = 0;
for (let slice = 0; slice < CALENDAR.length; slice += 1) {
  const { version } = handleRequest({ operation: "tariffVersion", slice });
  if (version === known) emptyQuery += 1;
  else { known = version; learned += 1; }
}
console.log(`versions in calendar = ${new Set(CALENDAR).size}, asked = ${asked}, learned = ${learned}, no-change questions = ${emptyQuery}`);
```

```sh
node polling.mjs
```

```
imports from server side to client = 0
versions in calendar = 3, asked = 12, learned = 3, no-change questions = 9
```

The back-import count is zero, and this follows from the definition of the role. The cost is
given by the last line: learning three version changes required twelve questions, and nine
questions brought no news at all. The ratio moves with how often the client asks — if the client
asks less often, the empty questions decrease but it learns of the change later. This ratio is a
trade-off and one of the two ends must be chosen, because there is no third option inside the
style. The calendar here is a model, not a measurement; what is counted is the gap between the
number of questions and the news learned, not any duration.

#### Summary

- The criterion separating client from server is the direction of initiation in code as well: one
  side builds the request, the other must respond to every incoming request.
- Role separation switches the side that holds the order of steps; the module count known by the
  delivery side dropped from 3 to 1, the name count from 4 to 1, and both arrangements produced
  the same fee.
- When the pricing side renamed its four internal names, the directly calling client broke at
  run time; the role-separated client kept working without its file being touched.
- The cost is one entry-point file, a state value to check on every response, and the error
  shifting from import time to run time.
- The number of imports going from the waiting side to the initiating side is zero; the result of
  this is that learning three version changes required twelve questions to be asked, and nine of
  them returned empty.

#### Next Step

Asymmetry in this arrangement is deliberate: one side always initiates, the other always waits,
and the waiting side is singular. This singularity has a counterpart in how the library
operates — all fee questions going to a single place means that place is known by everyone. In an
arrangement where multiple transfer hubs carry out the same work, the question reverses: if every
hub is both asker and answerer, the obligation to know spreads out instead of piling up in one
direction. The next lesson measures this arrangement: the number of neighbors a unit must know,
the number of messages required for a tariff update to reach every unit, and the number of units
that keep working when one unit is withdrawn.

### Peer-to-Peer

Source: https://academia.sh/en/courses/architectural-styles/peer-to-peer

Measuring the decentralized arrangement where the initiator and waiter roles merge into a single unit: the neighbor count six transfer hubs must know across three neighborhood arrangements, the round and message count required for a tariff update to reach everyone, the learner count when a unit is withdrawn, and the count of neighbor lists edited when a new unit is added.

In the previous arrangement, asymmetry was deliberate: one side always initiated, the other
always waited, and the waiting side was singular. This singularity has a counterpart in the
delivery operations context — all fee questions going to a single place means that place is known
by everyone. In an arrangement where multiple transfer hubs carry out the same work, the question
reverses. If every hub is both asker and answerer, the obligation to know spreads out instead of
piling up in one direction.

**Peer-to-peer** is the software-architecture-level signature of this style: role separation
falls away, and the two roles merge into the same module. The Client–Server Model lesson in the
Computer Networks curriculum said that this arrangement gains capacity as demand grows, but that
the client–server model was chosen for the web because it requires a stable address and
centralized control. Address, connection, and the network layer belong there. The question here
is three counts: the number of neighbors a unit must know, the number of messages required for a
tariff update to reach every unit, the number of units that keep working when one unit is
withdrawn.

The quality attribute it connects to is reliability, and the quality question is: when one
transfer hub goes out of service, how many hubs fail to learn the new tariff.

#### The Module That Carries Two Roles

There are six transfer hubs, and the same file runs for all of them. Two things sit inside the
module at once: `handleRequest`, which serves incoming requests, and `broadcast`, which sends
requests to neighbors. The previous lesson's two files — client and server — are merged into a
single file here.

```sh
mkdir -p es
```

```js
// es/es.mjs — the single module running for each transfer hub: both receiving and initiating
export function peer(name) {
  const neighbors = [];
  let version = "V1", received = 0, sent = 0;
  return {
    name,
    get version() { return version; },
    introduce: (neighbor) => neighbors.push(neighbor),
    counters: () => ({ received, sent, neighbor: neighbors.length }),
    handleRequest(request) {
      received += 1;
      if (request.operation !== "tariff-announce") return { state: "unknown-operation" };
      if (request.version === version) return { state: "known" };
      version = request.version;
      return { state: "new" };
    },
    broadcast() {
      for (const neighbor of neighbors) {
        sent += 1;
        neighbor.handleRequest({ operation: "tariff-announce", version });
      }
    },
  };
}
```

A peer's only knowledge is its neighbors. How many neighbors it knows is supplied from the
outside; the measurable face of the style is right there.

#### Three Neighborhood Arrangements

The same six units are connected in three separate ways. In the star arrangement, one hub knows
everyone and everyone knows only the hub; this is the previous lesson's arrangement written out
as a neighborhood. In the ring arrangement, each unit knows two neighbors. In the full mesh, each
unit knows the other five.

The update enters from the same place in all three arrangements: the new tariff version is
announced to the ANK hub. Then it propagates round by round — in each round, every unit that
knows the version announces it to its neighbors.

```js
// es/ag.mjs — the same units in three neighborhood arrangements, and one update's propagation run
import { peer } from "./es.mjs";

export const NAMES = ["IST", "ANK", "IZM", "BUR", "ADA", "TRA"];
export const ENTRY = "ANK";

export const star = (names) => new Map(names.map((a, i) => [a, i === 0 ? names.slice(1) : [names[0]]]));
export const ring = (names) =>
  new Map(names.map((a, i) => [a, [names[(i + 1) % names.length], names[(i - 1 + names.length) % names.length]]]));
export const fullMesh = (names) => new Map(names.map((a) => [a, names.filter((b) => b !== a)]));
export const ARRANGEMENTS = [["star", star], ["ring", ring], ["full mesh", fullMesh]];

export function propagate(map) {
  const peers = new Map([...map.keys()].map((a) => [a, peer(a)]));
  for (const [a, neighbors] of map) for (const n of neighbors) peers.get(a).introduce(peers.get(n));
  peers.get(ENTRY).handleRequest({ operation: "tariff-announce", version: "V2" });
  const all = [...peers.values()];
  let round = 0;
  while (all.some((e) => e.version !== "V2") && round < 20) {
    round += 1;
    for (const e of all.filter((e) => e.version === "V2")) e.broadcast();
  }
  return {
    round,
    message: all.reduce((t, e) => t + e.counters().sent, 0),
    neighbor: all.reduce((t, e) => t + e.counters().neighbor, 0),
    learner: all.filter((e) => e.version === "V2").length,
    unit: all.length,
  };
}
```

```js
// propagation.mjs — known neighbor count, propagation round, and message count in three arrangements
import { NAMES, ARRANGEMENTS, propagate } from "./es/ag.mjs";

console.log("arrangement  units  known neighbor  round  message  learner");
for (const [label, build] of ARRANGEMENTS) {
  const s = propagate(build(NAMES));
  console.log(
    `${label.padEnd(10)}${String(s.unit).padStart(4)}${String(s.neighbor).padStart(13)}` +
      `${String(s.round).padStart(6)}${String(s.message).padStart(7)}${`${s.learner}/${s.unit}`.padStart(9)}`,
  );
}
```

```sh
node propagation.mjs
```

```
arrangement  units  known neighbor  round  message  learner
star         6           10     2      7      6/6
ring         6           12     3     18      6/6
full mesh    6           30     1      5      6/6
```

Propagation here is a model, not a network measurement: what is counted is the message and round
count, no duration is measured.

#### Reading the Numbers

All three arrangements reached six out of six units, so they do the same job. The differences
collect in three columns.

The known-neighbor count is the total of the obligation to know. In the star it is 10: the hub
knows five, and five units know one hub each. In the ring it is 12, in the full mesh 30. The full
mesh is the most expensive arrangement to know, and this grows with the square of the unit count.

The message and round counts move in opposite directions. The full mesh finished with the fewest
messages (5) and in a single round, because the entry unit knew everyone. The ring spent the most
messages (18) and took three rounds, because the news has to hop from neighbor to neighbor; in
every round, every unit that knows the news announces it again, and most of those announcements
carry an already-known version. The star landed between the two: 7 messages, 2 rounds — the news
had to reach the hub in the first round and spread out from the hub in the second.

These three columns form a trade-off table: reducing the obligation to know increases the message
count.

#### When a Unit Is Withdrawn

The measurement for the reliability question is this experiment: every unit except the entry unit
is removed from the network in turn, propagation is run from the start, and the count of how many
of the remaining units learned the new version is taken. The same script also calculates how many
neighbor lists must be edited when a seventh hub is added to the network.

```js
// resilience.mjs — learner count when a unit is withdrawn, the cost of adding a unit, and the two roles in one file
import { readFileSync } from "node:fs";
import { NAMES, ENTRY, ARRANGEMENTS, propagate } from "./es/ag.mjs";

const withdraw = (map, name) =>
  new Map([...map].filter(([a]) => a !== name).map(([a, n]) => [a, n.filter((x) => x !== name)]));

console.log("arrangement  worst learner  critical unit  lists edited for new unit");
for (const [label, build] of ARRANGEMENTS) {
  const full = build(NAMES);
  let critical = 0, worst = [Infinity, 0];
  for (const name of NAMES.filter((a) => a !== ENTRY)) {
    const s = propagate(withdraw(full, name));
    if (s.learner < s.unit) critical += 1;
    if (s.learner < worst[0]) worst = [s.learner, s.unit];
  }
  const bigger = build([...NAMES, "OLD"]);
  const changed = NAMES.filter((a) => full.get(a).join(",") !== bigger.get(a).join(",")).length;
  console.log(
    `${label.padEnd(9)}${`${worst[0]}/${worst[1]}`.padStart(16)}${String(critical).padStart(14)}` +
      `${String(changed).padStart(34)}`,
  );
}

const source = readFileSync("es/es.mjs", "utf8");
const receiver = (source.match(/handleRequest\(request\)/g) ?? []).length;
const initiator = (source.match(/\.handleRequest\(\{/g) ?? []).length;
console.log(`es/es.mjs: receiving definitions = ${receiver}, initiating calls = ${initiator}`);
```

```sh
node resilience.mjs
```

```
arrangement  worst learner  critical unit  lists edited for new unit
star                  1/5             1                                 1
ring                  5/5             0                                 2
full mesh             5/5             0                                 6
es/es.mjs: receiving definitions = 1, initiating calls = 1
```

In the star, the critical-unit count is 1, and that unit is the hub: when it is withdrawn, only
one of the remaining five units — the one the news enters through — knows the version, and four
fail to learn it. In the ring and the full mesh, the critical-unit count is 0; whichever unit is
withdrawn, all five of the remaining five learn it. This is the count of decentralization. The
word is not a description of structure but a measurement result: the number of units that stop
others' work when withdrawn is zero.

The last column is the other half of the cost. When the seventh hub is added, one list — the
hub's list — is edited in the star. In the ring, two lists are edited; in the full mesh, six. The
full mesh's gain of finishing in a single round is paid for by updating every existing unit's
knowledge with each new unit.

The last line gives the style's code signature. There is one receiving definition and one
initiating call in a single file. The previous lesson's two separate files collapsed into one
file here, and this is the reason for the measurement: because the direction of initiation does
not pile up in a single direction, removing one unit from the network does not stop the rest from
talking to each other.

#### Summary

- The code signature of the peer-to-peer style is the removal of role separation: the receiving
  definition and the initiating call sit in the same module, and a single file runs for six
  units.
- The known-neighbor count came out to 10 in the star, 12 in the ring, and 30 in the full mesh;
  the obligation to know grows with the square of the unit count in the full mesh.
- The message and round counts moved in opposite directions: the full mesh spent 5 messages and
  1 round, the star 7 messages and 2 rounds, the ring 18 messages and 3 rounds; all three reached
  six out of six units.
- The critical-unit count is 1 in the star, 0 in the ring and the full mesh; when the hub was
  withdrawn, four of the remaining five units failed to learn the new tariff.
- The cost of decentralization is the price of adding a new unit: for the seventh hub, 1 neighbor
  list was edited in the star, 2 in the ring, and 6 in the full mesh.

#### Next Step

The two styles covered so far both addressed interaction between units of the same kind: both
sides of the conversation were units that calculated a fee or announced a tariff, and the only
difference was who initiated. The library also has a boundary of a different kind. The clerk at
the operations desk does not see how the fee was calculated, only what appears on the screen: the
zone name, the tier, the discount rationale, and the amount due must be arranged in a specific
form. The two sides of this boundary are not equal — one carries the rule and the data, the other
displays it. The next lesson compares three separate distributions of this responsibility: how
many of the field names the displaying file recognizes, how many separate places formatting is
done in, and how many files a display change touches.

### Model–View Families

Source: https://academia.sh/en/courses/architectural-styles/model-view-families

Producing the same offer screen with controller, presenter, and view model variants, and turning the responsibility distribution into numbers: how many field names the displaying file recognizes, how many files the import closure spans, how many calls and how many bytes cross the boundary, and which files the order and format decisions sit in.

The two styles covered so far addressed interaction between units of the same kind: both sides of
the conversation were units that calculated a fee or announced a tariff, and the only difference
was who initiated. The library also has a boundary of a different kind. The clerk at the
operations desk does not see how the fee was calculated, only what appears on the screen: the
zone name, the tier, the discount, and the amount due must be arranged in a specific form. The two
sides of this boundary are not equal — one carries the rule and the data, the other displays it.

This lesson compares three separate distributions of the responsibility, and counts how many
field names the displaying file recognizes, how many calls and how many bytes cross the boundary,
and which files the order and format decisions sit in. All three variants must produce the same
screen; only then does the measurement compare two arrangements of the same job.

#### Naming and the Boundary

The family names are abbreviated with three letters: **model–view–controller**,
**model–view–presenter**, and the **view model** variant. The word **presenter** here is the side
that presents the values to be displayed; it is not related to the waiting-side sense from the
Client–Server lesson — it is the same word used in two separate catalogs.

The view model was established in the Server-Side Templating lesson in the Server-Side
Fundamentals course: it is the contract between the template and the data source, and the
calculation and data access happen not in the template but in the code that builds it. Template
evaluation, escaping, and layout templates belong there and are not revisited here. The state
management lessons in the Application Architecture: Routing, State and Data course were also on
the interface side of the same boundary. What is new here is only the counting of the
responsibility distribution.

The quality attribute it connects to is maintainability, and the quality question is: when the
screen's layout or the amount format changes, how many files are edited.

#### The Common Model

All three variants use the same calculation. The model returns five fields, and none of them
carry formatting; numbers stay numbers, codes stay codes.

```sh
mkdir -p model mvc mvp vm
```

```js
// model/price.mjs — pricing context: calculation and field names
export const PROVINCE_NAME = { "34": "Istanbul", "06": "Ankara", "65": "Van" };
const COEFFICIENT = { "34": 1, "06": 1.4, "65": 2.1 };

export function calculatePrice(shipment) {
  const tier = shipment.weight <= 1 ? 1 : shipment.weight <= 5 ? 2 : shipment.weight <= 20 ? 3 : 4;
  const raw = 25 * tier * COEFFICIENT[shipment.province];
  const discount = raw * shipment.contractRate;
  return { province: shipment.province, tier, raw, discount, net: raw - discount };
}
```

#### The Controller Variant

In the first variant, the controller takes the input, calls the model, and selects the view. The
view receives the model's result directly: it reads the field names itself and does its own
formatting.

```js
// mvc/view.mjs — the view reads the model's field names itself and formats them itself
import { PROVINCE_NAME } from "../model/price.mjs";

export const display = (result) =>
  [
    ["Zone", PROVINCE_NAME[result.province]],
    ["Tier", String(result.tier)],
    ["Base fee", `${result.raw.toFixed(2)} TL`],
    ["Discount", `${result.discount.toFixed(2)} TL`],
    ["Amount due", `${result.net.toFixed(2)} TL`],
  ]
    .map(([label, value]) => `${label.padEnd(11)}: ${value}`)
    .join("\n");
```

#### The Presenter Variant

In the second variant, the view is passive: it holds only what is written to it, knows no field
name, and performs no calculation. The presenter reads from the model, formats, and writes to the
view line by line.

```js
// mvp/view.mjs — passive view: only holds what is written to it, knows no field name
export function view() {
  const lines = new Map();
  return {
    write: (label, value) => lines.set(label, value),
    text: () => [...lines].map(([l, v]) => `${l.padEnd(11)}: ${v}`).join("\n"),
  };
}
```

```js
// mvp/presenter.mjs — reads from the model, formats, and writes to the passive view line by line
import { calculatePrice, PROVINCE_NAME } from "../model/price.mjs";
import { view } from "./view.mjs";

export function offerScreen(shipment, build = view) {
  const s = calculatePrice(shipment);
  const v = build();
  v.write("Zone", PROVINCE_NAME[s.province]);
  v.write("Tier", String(s.tier));
  v.write("Base fee", `${s.raw.toFixed(2)} TL`);
  v.write("Discount", `${s.discount.toFixed(2)} TL`);
  v.write("Amount due", `${s.net.toFixed(2)} TL`);
  return v.text();
}
```

#### The View Model Variant

In the third variant, a data structure sits in between. The view model reads from the model and
produces fields ready for display; the view only reads those fields and decides how they are
arranged.

```js
// vm/view-model.mjs — separate data structure producing view-ready fields from the model
import { calculatePrice, PROVINCE_NAME } from "../model/price.mjs";

export function viewModel(shipment) {
  const s = calculatePrice(shipment);
  return {
    zoneName: PROVINCE_NAME[s.province],
    tierText: String(s.tier),
    baseFee: `${s.raw.toFixed(2)} TL`,
    discountAmount: `${s.discount.toFixed(2)} TL`,
    amountDue: `${s.net.toFixed(2)} TL`,
  };
}
```

```js
// vm/view.mjs — reads only the view model's fields, performs no calculation
const ORDER = [
  ["Zone", "zoneName"],
  ["Tier", "tierText"],
  ["Base fee", "baseFee"],
  ["Discount", "discountAmount"],
  ["Amount due", "amountDue"],
];

export const display = (vm) => ORDER.map(([l, a]) => `${l.padEnd(11)}: ${vm[a]}`).join("\n");
```

The remaining two files are one line each: the controller, and the screen that builds the view
model. Both take the view as a parameter, so that what crosses the boundary can be counted during
measurement.

```sh
cat > mvc/controller.mjs <<'END'
// mvc/controller.mjs — takes the input, calls the model, selects the view
import { calculatePrice } from "../model/price.mjs";
import { display } from "./view.mjs";

export const offerScreen = (shipment, draw = display) => draw(calculatePrice(shipment));
END
cat > vm/screen.mjs <<'END'
// vm/screen.mjs — builds the view model and hands it to the view
import { viewModel } from "./view-model.mjs";
import { display } from "./view.mjs";

export const offerScreen = (shipment, draw = display) => draw(viewModel(shipment));
END
```

#### Measurement

The script first runs all three variants against the same shipment and verifies that their
outputs are identical. It then counts how many model field names and how many presentation names
appear in each family's view file, extracts the view's import closure, and totals the byte count
by recording the calls that cross the boundary. The last table looks for two decisions: the file
that determines the order of the rows, and the file that determines the amount format.

```js
// responsibility.mjs — the three families producing the same screen, data crossing the boundary, and the field names the view knows
import { readFileSync } from "node:fs";
import { dirname, join, normalize } from "node:path";
import { offerScreen as mvc } from "./mvc/controller.mjs";
import { offerScreen as mvp } from "./mvp/presenter.mjs";
import { offerScreen as vmScreen } from "./vm/screen.mjs";
import { display as displayMvc } from "./mvc/view.mjs";
import { display as displayVm } from "./vm/view.mjs";

const SHIPMENT = { province: "06", weight: 12, contractRate: 0.15 };
const FIELD = ["province", "tier", "raw", "discount", "net"];
const PRESENTATION = ["zoneName", "tierText", "baseFee", "discountAmount", "amountDue"];

const closure = (root) => {
  const seen = new Set([root]), stack = [root];
  while (stack.length > 0) {
    const d = stack.pop();
    for (const [, y] of readFileSync(d, "utf8").matchAll(/from "(\.[^"]+)"/g)) {
      const h = normalize(join(dirname(d), y));
      if (seen.has(h) === false) { seen.add(h); stack.push(h); }
    }
  }
  return seen.size;
};

const countNames = (path, names) => {
  const text = readFileSync(path, "utf8");
  return names.filter((a) => new RegExp(`[.\\["']${a}\\b`).test(text)).length;
};

const recordCalls = (original) => {
  const log = [];
  return [(...a) => { log.push(a); return original(...a); }, log];
};

function runMvp() {
  const log = [];
  const build = () => {
    const lines = new Map();
    return {
      write: (l, v) => { log.push([l, v]); lines.set(l, v); },
      text: () => { log.push([]); return [...lines].map(([l, v]) => `${l.padEnd(11)}: ${v}`).join("\n"); },
    };
  };
  return [mvp(SHIPMENT, build), log];
}

const [drawMvc, logMvc] = recordCalls(displayMvc);
const [drawVm, logVm] = recordCalls(displayVm);
const [mvpOutput, logMvp] = runMvp();
const output = [mvc(SHIPMENT, drawMvc), mvpOutput, vmScreen(SHIPMENT, drawVm)];

console.log(output[0]);
console.log(`all three families' output the same = ${new Set(output).size === 1}`);

const FAMILY = [
  ["MVC", "mvc/view.mjs", ["mvc/view.mjs", "mvc/controller.mjs"], logMvc],
  ["MVP", "mvp/view.mjs", ["mvp/view.mjs", "mvp/presenter.mjs"], logMvp],
  ["VM ", "vm/view.mjs", ["vm/view.mjs", "vm/view-model.mjs", "vm/screen.mjs"], logVm],
];

console.log("\nfamily  field name  presentation name  closure  boundary calls  bytes crossed");
for (const [name, viewPath, , log] of FAMILY) {
  const bytes = log.reduce((t, a) => t + JSON.stringify(a).length, 0);
  console.log(
    `${name}   ${String(countNames(viewPath, FIELD)).padStart(6)}${String(countNames(viewPath, PRESENTATION)).padStart(15)}` +
      `${String(closure(viewPath)).padStart(9)}${String(log.length).padStart(15)}${String(bytes).padStart(12)}`,
  );
}

console.log("\nfamily  order-determining file       format-determining file      files touched");
for (const [name, , files] of FAMILY) {
  const find = (pattern) => files.find((d) => readFileSync(d, "utf8").includes(pattern)) ?? "-";
  const order = find('"Amount due"'), format = find("toFixed");
  console.log(`${name}   ${order.padEnd(24)}${format.padEnd(26)}${new Set([order, format]).size}`);
}
```

```sh
node responsibility.mjs
```

```
Zone       : Ankara
Tier       : 3
Base fee   : 105.00 TL
Discount   : 15.75 TL
Amount due : 89.25 TL
all three families' output the same = true

family  field name  presentation name  closure  boundary calls  bytes crossed
MVC        5              0        2              1          67
MVP        0              0        1              6         103
VM         0              5        1              1         111

family  order-determining file       format-determining file      files touched
MVC   mvc/view.mjs            mvc/view.mjs              1
MVP   mvp/presenter.mjs       mvp/presenter.mjs         1
VM    vm/view.mjs             vm/view-model.mjs         2
```

#### Reading the Numbers

All three variants produced the same five rows, so the measurement is comparing the same job.

The first column gives the obligation to know. In the controller variant, the view recognizes
five model field names; in the other two, zero. The second column shows where that went: in the
view model variant, the view recognizes five presentation names, so the knowledge did not
disappear — it was translated from model names to presentation names. In the presenter variant,
the view recognizes no name at all; it holds the written label and value exactly as given.

The third column is the concrete result of this. The import closure of the controller variant's
view file is 2: loading the view means loading the model too. The other two views have a closure
of 1; both can be loaded and tested without the model.

The fourth and fifth columns measure what crosses the boundary and expose the trade-off. In the
controller variant, a single call crossed the boundary and carried 67 bytes — raw numbers are
short. In the presenter variant, six calls crossed and carried 103 bytes; each line is a separate
call, and formatted strings are longer than a raw number. In the view model variant, a single
call carried 111 bytes: five formatted fields together. The variant with the least data crossing
the boundary is the variant where the view knows the most about the model. A cheap boundary and
an uninformed view do not coexist in the same arrangement.

#### Where the Decisions Sit

The last table answers the maintainability question. The screen's row order and the amount format
are two separate decisions, and which file they sit in changes by family.

In the controller and presenter variants, both decisions sit in the same file, so a request that
changes both touches a single file. In the view model variant, order sits in the view and format
sits in the view model; the same request touches two files. This is the cost of the separation,
and its payoff can be read in the same table: a request that changes only the order touches the
view file in the view model variant, and that file does not know the model at all and can be
tested on its own. In the controller variant, the same request requires editing the file that
recognizes all five model field names.

The choice of criterion depends on the kind of request. If what changes often is the order, the
separation pays off; if what changes often is which fields are displayed, editing two files
together is the cost.

#### Summary

- All three variants produced the same five-row screen; the difference is not in function but in
  the responsibility distribution between the model and the view.
- The model field names the view recognizes are 5 in the controller variant, 0 in the other two;
  in the view model variant this knowledge was translated into 5 presentation names, and in the
  presenter variant no name at all remained in the view.
- The view file's import closure is 2 in the controller variant, 1 in the other two; the second
  and third views can be loaded without the model.
- The data crossing the boundary grew in the opposite direction: 1 call and 67 bytes in the
  controller variant, 6 calls and 103 bytes in the presenter variant, 1 call and 111 bytes in the
  view model variant.
- The order and format decisions sit in a single file in the first two variants and in two
  separate files in the view model variant; a request that changes both touches 2 files instead
  of 1.

#### Next Step

In all three variants, the data crossed the boundary in a single leap: the model's result was
handed over in one call, and formatting happened in one place. The pricing context's actual
workflow is not like that. When the daily shipment list is processed, each record passes through
several steps in sequence: field names are normalized, the zone is derived, the tariff is
applied, the discount is subtracted, the amount is rounded. Today these five steps sit inside a
single body. When a new step — a fuel surcharge — is requested, that body must be edited, and
none of the steps can be tested on its own. The next lesson splits the steps into separate units
and measures the chain: the number of files edited when a new step is added, the number of names
each step recognizes, the number of fields crossing the boundary between steps, and the number of
records held in memory at once when records are streamed one by one.

### Pipes and Filters

Source: https://academia.sh/en/courses/architectural-styles/pipes-and-filters

Processing the same daily shipment list in a single body and in a filter chain: measuring the number of fields each step reads, the number of fields crossing filter boundaries, the file edited and the number of fields that file reads when a new step is added, and the number of records simultaneously live when records are streamed one at a time.

In the previous three variants, data crossed the boundary in one leap: the model result came
back in a single call, formatting happened in one place. The pricing context's real workflow
differs. The daily shipment list passes each record through five steps: normalizing field names,
deriving the zone, applying the tariff, subtracting the discount, rounding the amount. Today
these steps sit inside a single body; adding a step — a fuel surcharge — means editing that
body, and none of the steps can run on its own.

The **pipeline** style breaks this work into steps. Each step is a **filter**: it takes a record
as input, gives a record as output, and does not know its neighbors. The connection between
steps is the **pipe**; the chain's order lives not in the filters but wherever the chain is
built. The Pipeline lesson of the Shell Programming course built an instance: there the filter
was a process, the pipe a memory buffer; here the filter is a module, the pipe a function call.
Shell syntax belongs there.

The **quality attribute** here is maintainability, and the scenario question is: when a new step
is added, how many files are edited and how many field names does that file recognize. Two more
measures come alongside: fields crossing filter boundaries, and live records, in the performance
efficiency family.

#### Five Steps in a Single Body

The first arrangement is today's situation: a single function applies the five steps to each row
in sequence. The transition between steps is not a boundary but the next statement in the same
body.

```sh
mkdir -p single pipe
```

```js
// single/body.mjs — five steps inside a single body: field names, zone, tariff, discount, rounding
const ZONE = { "34": "near", "06": "mid", "35": "mid", "65": "far" };
const COEFFICIENT = { near: 1, mid: 1.4, far: 2.1 };
const MINIMUM = { near: 30, mid: 45, far: 70 };
const CONTRACT = { "MUS-1": 0.1, "MUS-2": 0.2 };

export function calculateFee(dailyList) {
  return dailyList.map((row) => {
    const record = {
      province: row.PROVINCE.trim(),
      weight: Number(row.WEIGHT),
      customer: row.CUSTOMER.trim(),
      count: Number(row.COUNT),
    };
    record.zone = ZONE[record.province] ?? "far";
    record.tier = record.weight <= 1 ? 1 : record.weight <= 5 ? 2 : record.weight <= 20 ? 3 : 4;
    record.raw = Math.max(MINIMUM[record.zone], 25 * record.tier * COEFFICIENT[record.zone]);
    record.net = record.raw * (1 - (CONTRACT[record.customer] ?? 0)) * (record.count >= 10 ? 0.95 : 1);
    record.net = Math.round(record.net * 100) / 100;
    return record;
  });
}
```

#### Filters

In the second arrangement, each step is its own file, and all carry the same signature: take a
record, give a record. That the exported name is the same (`filter`) is the style's condition —
wherever the chain is built does not distinguish between steps.

```js
// pipe/normalize.mjs — converts raw row field names into record fields
export const filter = (row) => ({
  province: row.PROVINCE.trim(),
  weight: Number(row.WEIGHT),
  customer: row.CUSTOMER.trim(),
  count: Number(row.COUNT),
});
```

```js
// pipe/zone.mjs — derives the fee zone from the province
const ZONE = { "34": "near", "06": "mid", "35": "mid", "65": "far" };
export const filter = (r) => ({ ...r, zone: ZONE[r.province] ?? "far" });
```

```js
// pipe/tariff.mjs — weight tier, zone coefficient and minimum fee
const COEFFICIENT = { near: 1, mid: 1.4, far: 2.1 };
const MINIMUM = { near: 30, mid: 45, far: 70 };
const tier = (kg) => (kg <= 1 ? 1 : kg <= 5 ? 2 : kg <= 20 ? 3 : 4);
export const filter = (r) => {
  const t = tier(r.weight);
  return { ...r, tier: t, raw: Math.max(MINIMUM[r.zone], 25 * t * COEFFICIENT[r.zone]) };
};
```

```js
// pipe/discount.mjs — contracted customer discount and volume discount
const CONTRACT = { "MUS-1": 0.1, "MUS-2": 0.2 };
export const filter = (r) => ({
  ...r,
  net: r.raw * (1 - (CONTRACT[r.customer] ?? 0)) * (r.count >= 10 ? 0.95 : 1),
});
```

```js
// pipe/round.mjs — rounds the amount to the smallest currency unit
export const filter = (r) => ({ ...r, net: Math.round(r.net * 100) / 100 });
```

Each filter carries forward what arrives with `...r` and adds its own field on top; it never
names a field it has no interest in.

#### The Chain Itself

The order sits in a single file as a list of names; the same file carries the generator that
streams records one at a time. The `watch` parameter is for measurement — called at every
filter's entry.

```js
// pipe/chain.mjs — the chain's order lives only here, no field name appears
export const CHAIN = ["normalize", "zone", "tariff", "discount", "round"];

export async function* stream(source, watch = () => {}) {
  const filters = [];
  for (const name of CHAIN) filters.push({ name, filter: (await import(`./${name}.mjs`)).filter });
  for await (const row of source) {
    let r = row;
    for (const { name, filter } of filters) {
      watch(name, r);
      r = filter(r);
    }
    yield r;
  }
}
```

#### Measurement

The script runs a six-row daily list through both arrangements, verifies the outputs match, and
produces three numbers per step. Input and output field counts are counted at run time; the read
count is names appearing in the file's source as `.fieldName`.

```js
// count-fields.mjs — both arrangements produce the same list, the fields each step reads and the fields crossing the boundary
import { readFileSync } from "node:fs";
import { calculateFee } from "./single/body.mjs";
import { CHAIN, stream } from "./pipe/chain.mjs";

const DAILY_LIST = [
  { PROVINCE: "34", WEIGHT: "0.8", CUSTOMER: "MUS-1", COUNT: "3" },
  { PROVINCE: "65", WEIGHT: "12", CUSTOMER: "MUS-2", COUNT: "12" },
  { PROVINCE: " 06", WEIGHT: "30", CUSTOMER: "MUS-9 ", COUNT: "1" },
  { PROVINCE: "35", WEIGHT: "4", CUSTOMER: "MUS-1", COUNT: "20" },
  { PROVINCE: "34", WEIGHT: "21", CUSTOMER: "MUS-2", COUNT: "2" },
  { PROVINCE: "65", WEIGHT: "0.4", CUSTOMER: "MUS-9", COUNT: "1" },
];
const FIELDS = ["PROVINCE", "WEIGHT", "CUSTOMER", "COUNT", "province", "weight", "customer", "count", "zone", "tier", "raw", "net"];
const fieldsRead = (path) => {
  const text = readFileSync(path, "utf8");
  return FIELDS.filter((f) => new RegExp(`\\.${f}\\b`).test(text)).length;
};

const inputFields = new Map();
const watch = (name, r) => { if (inputFields.has(name) === false) inputFields.set(name, Object.keys(r).length); };

const body = calculateFee(DAILY_LIST);
const chain = [];
for await (const r of stream(DAILY_LIST, watch)) chain.push(r);

console.log(`first record: ${JSON.stringify(chain[0])}`);
console.log(`both arrangements equal = ${JSON.stringify(body) === JSON.stringify(chain)}, records = ${chain.length}`);

console.log("\nstep        input field    fields read  output field");
let passed = 0, totalRead = 0;
for (const [i, name] of CHAIN.entries()) {
  const input = inputFields.get(name);
  const output = i + 1 < CHAIN.length ? inputFields.get(CHAIN[i + 1]) : Object.keys(chain[0]).length;
  const read = fieldsRead(`pipe/${name}.mjs`);
  passed += input;
  totalRead += read;
  console.log(
    `${name.padEnd(12)}${String(input).padStart(10)}${String(read).padStart(16)}${String(output).padStart(12)}`,
  );
}
console.log(
  `${"single body".padEnd(12)}${String(Object.keys(DAILY_LIST[0]).length).padStart(10)}` +
    `${String(fieldsRead("single/body.mjs")).padStart(16)}${String(Object.keys(body[0]).length).padStart(12)}`,
);
console.log(`\nfields crossing a filter boundary = ${passed}, of these read = ${totalRead}`);
```

```sh
node count-fields.mjs
```

```
first record: {"province":"34","weight":0.8,"customer":"MUS-1","count":3,"zone":"near","tier":1,"raw":30,"net":27}
both arrangements equal = true, records = 6

step        input field    fields read  output field
normalize            4               4           4
zone                 4               1           5
tariff               5               2           7
discount             7               3           8
round                8               1           8
single body          4              12           8

fields crossing a filter boundary = 28, of these read = 11
```

#### Reading the Numbers

Six records produced identical output in both arrangements, so the measurement compares two
arrangements of the same work.

The fields-read column shows the distribution of the knowledge obligation. The single body
recognizes all twelve names — four raw headers, four normalized, four derived. In the chain, the
same information spreads across five files, none recognizing more than four; finding which file to
edit when a field's name changes is a matter of reading this column.

The input and output columns give the style's cost. 28 fields crossed filter boundaries, only 11
read by name; the remaining 17 were carried without ever being read — each filter passes along
what does not concern it, since it cannot know what the next step needs. The single body carries
nothing: intermediate values sit in the same scope, no boundary crossed. The trade-off:
spreading names out is paid for in carried fields.

#### A New Step

The scenario question is now tested. The fuel surcharge step enters after the discount and
before the rounding. The script copies both arrangements, applies the change, and counts edited
files. Its last section runs each filter standalone with the record captured at its own
boundary, comparing the output against the record the next step received.

```js
// extend.mjs — edited file, changed lines and that file's fields read when the fuel surcharge step is added
import { cpSync, readdirSync, readFileSync, writeFileSync } from "node:fs";

const FIELDS = ["PROVINCE", "WEIGHT", "CUSTOMER", "COUNT", "province", "weight", "customer", "count", "zone", "tier", "raw", "net"];
const fieldsRead = (text) => FIELDS.filter((f) => new RegExp(`\\.${f}\\b`).test(text)).length;
const DEFINITION = "const FUEL = { near: 0.02, mid: 0.04, far: 0.07 };";
const edit = (path, transform) => writeFileSync(path, transform(readFileSync(path, "utf8")));

for (const dir of ["single", "pipe"]) cpSync(dir, `new/${dir}`, { recursive: true });
edit("new/single/body.mjs", (m) =>
  m.replace("const CONTRACT", `${DEFINITION}\nconst CONTRACT`)
    .replace("    record.net = Math.round", "    record.net = record.net * (1 + FUEL[record.zone]);\n    record.net = Math.round"));
writeFileSync("new/pipe/fuel.mjs",
  `// pipe/fuel.mjs — fuel surcharge by zone\n${DEFINITION}\nexport const filter = (r) => ({ ...r, net: r.net * (1 + FUEL[r.zone]) });\n`);
edit("new/pipe/chain.mjs", (m) => m.replace('"discount", "round"', '"discount", "fuel", "round"'));

const changedLines = (previous, updated) => {
  const tally = (m) => m.split("\n").reduce((h, s) => h.set(s, (h.get(s) ?? 0) + 1), new Map());
  const a = tally(previous), b = tally(updated);
  const missing = (x, y) => [...x].reduce((t, [s, n]) => t + Math.max(0, n - (y.get(s) ?? 0)), 0);
  return missing(b, a) + missing(a, b);
};

console.log("arrangement  edited  added  changed lines  fields read in edited");
for (const dir of ["single", "pipe"]) {
  const existingFiles = new Set(readdirSync(dir));
  const edited = [];
  let added = 0, lines = 0, read = 0;
  for (const file of readdirSync(`new/${dir}`)) {
    const updatedText = readFileSync(`new/${dir}/${file}`, "utf8");
    if (existingFiles.has(file) === false) { added += 1; continue; }
    const previousText = readFileSync(`${dir}/${file}`, "utf8");
    if (previousText === updatedText) continue;
    edited.push(file);
    lines += changedLines(previousText, updatedText);
    read += fieldsRead(updatedText);
  }
  console.log(
    `${dir.padEnd(7)}${String(edited.length).padStart(10)}${String(added).padStart(9)}` +
      `${String(lines).padStart(15)}${String(read).padStart(25)}  [${edited.join(" ")}]`,
  );
}

const DAILY_LIST = [{ PROVINCE: "34", WEIGHT: "0.8", CUSTOMER: "MUS-1", COUNT: "3" }, { PROVINCE: "65", WEIGHT: "12", CUSTOMER: "MUS-2", COUNT: "12" }];
const { calculateFee: previous } = await import("./single/body.mjs");
const { calculateFee } = await import("./new/single/body.mjs");
const { CHAIN, stream } = await import("./new/pipe/chain.mjs");
const boundary = new Map();
const chain = [];
for await (const r of stream(DAILY_LIST, (name, r) => { if (boundary.has(name) === false) boundary.set(name, r); })) chain.push(r);
console.log(`\nsteps after new step = ${CHAIN.length}, both arrangements equal = ${JSON.stringify(calculateFee(DAILY_LIST)) === JSON.stringify(chain)}`);
console.log(`second record net: before fuel step ${previous(DAILY_LIST)[1].net}, after ${chain[1].net}`);

let tested = 0;
for (const [i, name] of CHAIN.entries()) {
  const { filter } = await import(`./new/pipe/${name}.mjs`);
  const expected = i + 1 < CHAIN.length ? boundary.get(CHAIN[i + 1]) : chain[0];
  if (JSON.stringify(filter(boundary.get(name))) === JSON.stringify(expected)) tested += 1;
}
console.log(`step run standalone with its own boundary record: pipeline ${tested}/${CHAIN.length}, single body 1/1`);
```

```sh
node extend.mjs
```

```
arrangement  edited  added  changed lines  fields read in edited
single          1        0              2                       12  [body.mjs]
pipe            1        1              2                        0  [chain.mjs]

steps after new step = 6, both arrangements equal = true
second record net: before fuel step 119.7, after 128.08
step run standalone with its own boundary record: pipeline 6/6, single body 1/1
```

The edited-file count is 1 in both arrangements; no gain there. The gain is in the last column:
the single body's edited file recognizes twelve field names, so the fuel surcharge means opening
the body where tariff and discount sit. The chain's edited file, `chain.mjs`, recognizes no
field name at all — the change inserts a string into an array; the new step sits in a separate
file, recognizing two names.

The last line gives the second gain. All six steps, called standalone with the record captured
at their own boundary, produced the record the next step received. No such arrangement is
possible in the single body: the only callable unit is the entire body, and intermediate values
never step outside it. Testing the rounding rule requires running the tariff and discount too.

#### Streaming Records

The filter boundary being a single record has a side effect: the whole chain can run without
waiting for the list. The script below runs both arrangements at three sizes and counts the
highest number of records taken from the source whose output is not yet written — the record
count, not a memory byte.

```js
// streaming.mjs — records simultaneously live: when the list is taken in bulk and when it is streamed one by one
import { calculateFee } from "./single/body.mjs";
import { stream } from "./pipe/chain.mjs";

const PROVINCE = ["34", "06", "35", "65"], CUSTOMER = ["MUS-1", "MUS-2", "MUS-9"];
const row = (i) => ({
  PROVINCE: PROVINCE[i % PROVINCE.length], WEIGHT: String(1 + (i % 25)),
  CUSTOMER: CUSTOMER[i % CUSTOMER.length], COUNT: String(1 + (i % 12)),
});

function counter() {
  let live = 0, peak = 0;
  return {
    peak: () => peak,
    take: (r) => { live += 1; peak = Math.max(peak, live); return r; },
    release: () => { live -= 1; },
  };
}

function batch(n) {
  const c = counter();
  const rows = Array.from({ length: n }, (_, i) => c.take(row(i)));
  let total = 0;
  for (const r of calculateFee(rows)) { total += r.net; c.release(); }
  return { peak: c.peak(), total: Math.round(total * 100) / 100 };
}

async function streamed(n) {
  const c = counter();
  function* source() { for (let i = 0; i < n; i += 1) yield c.take(row(i)); }
  let total = 0;
  for await (const r of stream(source())) { total += r.net; c.release(); }
  return { peak: c.peak(), total: Math.round(total * 100) / 100 };
}

console.log("records  batch peak  streamed peak  totals equal");
for (const n of [6, 60, 600]) {
  const a = batch(n), b = await streamed(n);
  console.log(`${String(n).padStart(5)}${String(a.peak).padStart(13)}${String(b.peak).padStart(15)}${String(a.total === b.total).padStart(16)}`);
}
```

```sh
node streaming.mjs
```

```
records  batch peak  streamed peak  totals equal
    6            6              1            true
   60           60              1            true
  600          600              1            true
```

In the batch arrangement, the live count grew with the list size: 6, 60, 600. In the streamed
chain it stayed at 1 for all three sizes, and both arrangements produced the same total. Because
the number is independent of input size, the chain runs even if the list has no end; this is
what **stream** means for the style.

The difference comes not from the style itself but from how the boundary is defined: the single
body could also be called record by record, but it still keeps all five steps in one file, and
those steps still cannot run one at a time. In the pipeline, streamability is a consequence of
the filter signature, not an extra effort.

#### Summary

- In the pipeline style, each step is a filter: it takes a record, gives a record, and does not
  know its neighbor; order lives not in the filters but in the file that builds the chain.
- The six-record list produced the same result in both arrangements; the single body recognizes
  twelve field names, and none of the five filters recognizes more than four.
- 28 fields crossed filter boundaries, of which 11 were read; the remaining 17 were carried
  without ever being read — the cost of spreading the names out.
- Adding a new step edited 1 file in both arrangements, but the edited file recognizes 12 field
  names in the single body versus 0 in the chain.
- All six steps ran standalone with their own boundary record; the single body has 1 callable
  unit; when records were streamed, the live count stayed at 1 even for the 600-record list.

#### Next Step

The pipeline is a fixed, linear chain: every record passes through every step, and each step's
output is the next one's input. The library holds work that does not fit this pattern. When a
shipment's fee is finalized, several things must happen: a record must be opened and a route
assigned in the delivery operations context, the contracted customer's volume counter must
increase, and which tariff version priced it logged to the archive. These jobs do not feed each
other, do not transform the record, and have no order among them; their count also changes over
time. If the module that finalizes the fee calls them itself, it must import all of them, and
every new job edits that file. The next lesson puts a named event and an event bus in between,
then measures: the modules and names the publisher knows about the other side, its import
closure, lines edited when a fourth consumer is added, and files that must be read to answer
"who handles this event."

### Publish–Subscribe

Source: https://academia.sh/en/courses/architectural-styles/publish-subscribe

Comparing connecting the three jobs that run when a fee is finalized by direct call and through an event bus: measuring the number of modules and names the publisher knows, the import closure, the number of lines edited in the publisher when a fourth consumer is added, and the number of files that must be read to find handlers.

The pipeline was a fixed, linear chain: every record passed through every step, and each step's
output was the next one's input. The library holds work that does not fit this pattern. When a
shipment's fee is finalized, a record must be opened and a route assigned in the delivery
operations context, the contracted customer's volume counter must increase, and which tariff
version priced it logged to the archive. These jobs do not feed each other, do not transform the
record, and have no order among them; their count also changes over time. If the module that
finalizes the fee calls them itself, it must import all three.

The **publish–subscribe** style was built and measured in the Messaging topic of the Caching,
Queues and Asynchronous Processing course; topic, subscription, producer, and consumer were
defined there, and delivery semantics and retry belong there — not revisited here. The Observer
pattern in the Design Patterns course builds the same idea at the object level: the object
announcing the change carries the subscriber list itself. This style's distinctive face shows up
in a single file — the subscriber list lives in neither the publisher nor the consumer. A named
**topic** and an **event bus** enter between them; the two sides know the bus, not each other.

The **quality attribute** here is maintainability, and the scenario question is: when a fourth
job reacting to the finalized fee is added, how many lines change in the file that finalizes the
fee. The reverse of the question is also measured: when the publisher does not know the
handlers, how many files must be read to answer "who handles this event."

#### Three Jobs

The three jobs are **identical files** in both arrangements; the only thing that changes is how
they are called. Each keeps its own state and exposes a counter, so both arrangements' effect
can be counted as equal.

```sh
mkdir -p jobs direct bus
```

```js
// jobs/route.mjs — delivery operations: opens a delivery record and assigns a route
const TREE = { near: ["34"], mid: ["34", "06"], far: ["34", "41", "65"] };
const opened = [];
export const assignRoute = (event) => { opened.push({ code: event.code, route: TREE[event.zone] }); };
export const openedRecords = () => opened.length;
```

```js
// jobs/volume.mjs — pricing: the contracted customer's volume counter
const counter = new Map();
export const incrementVolume = (event) => { counter.set(event.customer, (counter.get(event.customer) ?? 0) + 1); };
export const volume = (customer) => counter.get(customer) ?? 0;
```

```js
// jobs/archive.mjs — writes the finalized amount to the archive with its tariff version
const lines = [];
export const archive = (event) => { lines.push(`${event.code} ${event.tariffVersion} ${event.net}`); };
export const archiveLines = () => lines.length;
```

#### Direct Call

In the first arrangement, the module that finalizes the fee calls the three jobs in sequence.
Three import lines and three calls are the entirety of the publisher's knowledge obligation.

```js
// direct/finalize.mjs — the module that finalizes the fee calls the three jobs itself
import { assignRoute } from "../jobs/route.mjs";
import { incrementVolume } from "../jobs/volume.mjs";
import { archive } from "../jobs/archive.mjs";

export function finalize(event) {
  assignRoute(event);
  incrementVolume(event);
  archive(event);
  return event.code;
}
```

#### Event Bus

In the second arrangement, a module enters between them. The bus exposes two operations:
`subscribe` attaches a handler to a topic, `publish` gives an event on that topic to every
attached handler. Each handler call is isolated and counted. What happens when a consumer
throws, and what changes when the event crosses a system boundary, is measured in this course's
Event-Driven Architecture lesson; the bus here is single-process, and publication is
synchronous.

```js
// bus/bus.mjs — publication by topic name: the two sides know the bus, not each other
const subscriptions = new Map();
let published = 0, delivered = 0, failed = 0;

export function subscribe(topic, handler) {
  if (subscriptions.has(topic) === false) subscriptions.set(topic, []);
  subscriptions.get(topic).push(handler);
}

export function publish(topic, event) {
  published += 1;
  for (const handler of subscriptions.get(topic) ?? []) {
    try { handler(event); delivered += 1; } catch { failed += 1; }
  }
  return (subscriptions.get(topic) ?? []).length;
}

export const counts = () => ({ published, delivered, failed });
```

The publisher knows only one of these two operations. The other thing it knows is a string: the
topic's name.

```js
// bus/finalize.mjs — knows only the topic name and the event shape
import { publish } from "./bus.mjs";

export function finalize(event) {
  publish("fee-finalized", event);
  return event.code;
}
```

Subscriptions are set up in a third file. This file does the composition root's job: it knows
both the bus and the jobs, but no one knows it — the publisher does not import it.

```js
// bus/registry.mjs — subscriptions are set up here; the publisher does not know this file
import { subscribe } from "./bus.mjs";
import { assignRoute } from "../jobs/route.mjs";
import { incrementVolume } from "../jobs/volume.mjs";
import { archive } from "../jobs/archive.mjs";

subscribe("fee-finalized", assignRoute);
subscribe("fee-finalized", incrementVolume);
subscribe("fee-finalized", archive);
```

#### Measurement

The script runs three events through both arrangements and compares the increase in the jobs'
counters. It then reads the import lines of both publisher files, extracts the known module and
name counts, computes the import closure, and in its last section tries to answer "who handles
this event" in both arrangements: the publisher file in the direct arrangement, every module in
the source tree in the bus arrangement.

```js
// coupling.mjs — both arrangements produce the same effect, the names the publisher knows and the files read to find handlers
import { readdirSync, readFileSync } from "node:fs";
import { dirname, join, normalize } from "node:path";
import { finalize as direct } from "./direct/finalize.mjs";
import { finalize as bus } from "./bus/finalize.mjs";
import { counts } from "./bus/bus.mjs";
import { openedRecords } from "./jobs/route.mjs";
import { volume } from "./jobs/volume.mjs";
import { archiveLines } from "./jobs/archive.mjs";
import "./bus/registry.mjs";

const EVENTS = [
  { code: "G-1", zone: "mid", customer: "MUS-1", net: 89.25, tariffVersion: "T2" },
  { code: "G-2", zone: "far", customer: "MUS-2", net: 119.7, tariffVersion: "T2" },
  { code: "G-3", zone: "near", customer: "MUS-1", net: 27, tariffVersion: "T2" },
];
const state = () => ({ route: openedRecords(), archive: archiveLines(), volume: volume("MUS-1") });
const diff = (a, b) => `route records +${b.route - a.route}, archive lines +${b.archive - a.archive}, MUS-1 volume +${b.volume - a.volume}`;

const s0 = state();
for (const e of EVENTS) direct(e);
const s1 = state();
for (const e of EVENTS) bus(e);
const s2 = state();
console.log(`direct arrangement: ${diff(s0, s1)}`);
console.log(`bus arrangement: ${diff(s1, s2)}`);
console.log(`bus counts: ${JSON.stringify(counts())}`);

const IMPORT = /import\s*\{([^}]*)\}\s*from\s*"(\.[^"]+)"/g;
const closure = (root) => {
  const seen = new Set([root]), stack = [root];
  while (stack.length > 0) {
    const path = stack.pop();
    for (const [, , rel] of readFileSync(path, "utf8").matchAll(IMPORT)) {
      const resolved = normalize(join(dirname(path), rel));
      if (seen.has(resolved) === false) { seen.add(resolved); stack.push(resolved); }
    }
  }
  return seen.size;
};

console.log("\npublisher                  known module  known name  import closure");
for (const path of ["direct/finalize.mjs", "bus/finalize.mjs"]) {
  const imports = [...readFileSync(path, "utf8").matchAll(IMPORT)];
  const names = imports.flatMap((m) => m[1].split(",").map((s) => s.trim()));
  console.log(
    `${path.padEnd(27)}${String(imports.length).padStart(13)}${String(names.length).padStart(12)}` +
      `${String(closure(path)).padStart(16)}  [${names.join(" ")}]`,
  );
}

const files = readdirSync(".", { recursive: true }).filter((y) => y.endsWith(".mjs") && y.includes("/"));
const directHandlers = (readFileSync("direct/finalize.mjs", "utf8").match(/^ {2}\w+\(event\);$/gm) ?? []).length;
const subscribedFiles = files.filter((y) => readFileSync(y, "utf8").includes('subscribe("fee-finalized"'));
const busHandlers = subscribedFiles.reduce(
  (t, y) => t + (readFileSync(y, "utf8").match(/subscribe\("fee-finalized", \w+\)/g) ?? []).length, 0);
console.log(`\nthe "who handles the fee-finalized event" question`);
console.log(`  direct: 1 file read, ${directHandlers} handlers visible`);
console.log(`  bus: 0 visible in the publisher, ${files.length} files scanned, ${busHandlers} handlers found in ${subscribedFiles.length} file(s)`);
```

```sh
node coupling.mjs
```

```
direct arrangement: route records +3, archive lines +3, MUS-1 volume +2
bus arrangement: route records +3, archive lines +3, MUS-1 volume +2
bus counts: {"published":3,"delivered":9,"failed":0}

publisher                  known module  known name  import closure
direct/finalize.mjs                    3           3               4  [assignRoute incrementVolume archive]
bus/finalize.mjs                       1           1               2  [publish]

the "who handles the fee-finalized event" question
  direct: 1 file read, 3 handlers visible
  bus: 0 visible in the publisher, 7 files scanned, 3 handlers found in 1 file(s)
```

#### Reading the Numbers

The first two lines are the measurement's validity condition: three events produced three route
records, three archive lines, and two volume increases for `MUS-1` in both arrangements. Both
arrangements do the same work.

The third line gives publication's payoff. Three publish calls turned into nine handler calls:
the publisher made 3 calls, but 9 jobs were actually done. The multiplier is a number the
publisher does not know, and since `failed` is zero, all nine completed.

The table counts the knowledge obligation. In the direct arrangement the publisher knows three
modules and three names, and the closure is 4 files: loading the publisher loads all three
jobs. In the bus arrangement one module and one name are known, and the closure is 2 files. The
three jobs stepped outside the closure; the only new thing remaining inside it is the bus
itself. The topic name `"fee-finalized"` is a string, not an import, so it is not checked at
compile time — the first half of the cost.

The last three lines are the second half. In the direct arrangement, one file answers the
question: three calls sit there, read in sequence. In the bus arrangement, the publisher file
shows no handler; finding the answer required scanning the tree's seven modules and searching
for subscription lines. It was found — three handlers in one file — but the search cost grows
with the file count. This is the measure of loose coupling: as the names the publisher knows
drop from 3 to 1, the files read to find handlers rise from 1 to 7.

#### Fourth Consumer

The scenario question is now tested with a fourth job: the finalized fee will be notified to the
carrier.

```js
// jobs/carrier.mjs — fourth job: notifies the carrier of the finalized fee
const notifications = [];
export const notifyCarrier = (event) => { notifications.push(`${event.code} ${event.net}`); };
export const notificationCount = () => notifications.length;
```

The script copies both arrangements, attaches the fourth job to both, counts how many lines
changed in which file, and shows lines changed in the publisher file in a separate column. It
then runs both arrangements in the new tree to verify the fourth job actually runs.

```js
// extend.mjs — changed lines, changed lines in the publisher and delivery count when a fourth consumer is added
import { cpSync, readFileSync, writeFileSync } from "node:fs";

const edit = (path, transform) => writeFileSync(path, transform(readFileSync(path, "utf8")));
const changedLines = (previous, updated) => {
  const tally = (m) => m.split("\n").reduce((h, s) => h.set(s, (h.get(s) ?? 0) + 1), new Map());
  const a = tally(previous), b = tally(updated);
  const missing = (x, y) => [...x].reduce((t, [s, n]) => t + Math.max(0, n - (y.get(s) ?? 0)), 0);
  return missing(b, a) + missing(a, b);
};
const ARCHIVE = 'import { archive } from "../jobs/archive.mjs";';
const CARRIER = 'import { notifyCarrier } from "../jobs/carrier.mjs";';

for (const dir of ["jobs", "direct", "bus"]) cpSync(dir, `new/${dir}`, { recursive: true });
edit("new/direct/finalize.mjs", (m) =>
  m.replace(ARCHIVE, `${ARCHIVE}\n${CARRIER}`).replace("  archive(event);", "  archive(event);\n  notifyCarrier(event);"));
edit("new/bus/registry.mjs", (m) =>
  m.replace(ARCHIVE, `${ARCHIVE}\n${CARRIER}`)
    .replace('subscribe("fee-finalized", archive);', 'subscribe("fee-finalized", archive);\nsubscribe("fee-finalized", notifyCarrier);'));

const PUBLISHER = { direct: "direct/finalize.mjs", bus: "bus/finalize.mjs" };
const FILES = {
  direct: ["direct/finalize.mjs"],
  bus: ["bus/finalize.mjs", "bus/bus.mjs", "bus/registry.mjs"],
};
console.log("arrangement  edited file               changed lines  changed in publisher");
for (const [arrangement, paths] of Object.entries(FILES)) {
  const lineDiff = (y) => changedLines(readFileSync(y, "utf8"), readFileSync(`new/${y}`, "utf8"));
  const edited = paths.filter((y) => lineDiff(y) > 0);
  const total = edited.reduce((t, y) => t + lineDiff(y), 0);
  const inPublisher = edited.includes(PUBLISHER[arrangement]) ? lineDiff(PUBLISHER[arrangement]) : 0;
  console.log(`${arrangement.padEnd(10)}${edited.join(" ").padEnd(26)}${String(total).padStart(13)}${String(inPublisher).padStart(25)}`);
}

const EVENTS = [
  { code: "G-4", zone: "mid", customer: "MUS-1", net: 89.25, tariffVersion: "T2" },
  { code: "G-5", zone: "far", customer: "MUS-2", net: 119.7, tariffVersion: "T2" },
];
const { notificationCount } = await import("./new/jobs/carrier.mjs");
const { finalize: direct } = await import("./new/direct/finalize.mjs");
const { finalize: bus } = await import("./new/bus/finalize.mjs");
const { counts } = await import("./new/bus/bus.mjs");
await import("./new/bus/registry.mjs");

for (const e of EVENTS) direct(e);
const directNotifications = notificationCount();
for (const e of EVENTS) bus(e);
console.log(`\nfourth job ran: direct ${directNotifications}/${EVENTS.length}, bus ${notificationCount() - directNotifications}/${EVENTS.length}`);
console.log(`bus counts: ${JSON.stringify(counts())}, delivery per event = ${counts().delivered / counts().published}`);
```

```sh
node extend.mjs
```

```
arrangement  edited file               changed lines  changed in publisher
direct    direct/finalize.mjs                   2                        2
bus       bus/registry.mjs                      2                        0

fourth job ran: direct 2/2, bus 2/2
bus counts: {"published":2,"delivered":8,"failed":0}, delivery per event = 4
```

The number of changed lines is 2 in both arrangements: one import line and one binding line. The
difference is in the last column: in the direct arrangement these two lines sit inside the file
that finalizes the fee; in the bus arrangement, that file was not touched. Delivery per event
rose from 3 to 4 without the publisher file changing.

This shows where the gain lands: the amount of change stayed the same, but its **location**
changed. The publisher sits inside the pricing context; `bus/registry.mjs` is a binding file
carrying no field rule at all. With ten consumers, every new job is still two lines, but the
file where the fee calculation sits is never opened.

#### Summary

- In the publish–subscribe style, the subscriber list lives in neither the publisher nor the
  consumer; a topic name and an event bus enter between them, and the two sides know the bus,
  not each other.
- Three events produced the same effect in both arrangements; three publish calls turned into
  nine handler calls, a multiplier the publisher does not know.
- The modules the publisher knows dropped from 3 to 1, names from 3 to 1, and the import closure
  from 4 files to 2; the topic name is a string, not an import, so it is not checked at compile
  time.
- The cost is traceability: "who handles this event" was answered by reading 1 file directly,
  and by scanning 7 modules through the bus.
- The fourth consumer changed 2 lines in both arrangements, but those lines fell in the
  publisher file directly and in the binding file through the bus; delivery per event rose from
  3 to 4 without the publisher file changing.

#### Next Step

This bus has an unmeasured property: the `publish` call does not return until every attached
handler finishes. Four handlers run in sequence on the same call stack, so the module that
finalizes the fee takes as long as the slowest handler. If archive writing slows down, fee
finalization slows down too, even though the archive's write time does not matter for pricing.
The handler can be taken out of waiting by putting a store in between — dropping the event
somewhere and returning. The next lesson builds this store and measures it: how many consumer
steps have run by the time the publisher drops the event, how much work piles up waiting when
the consumer is slower than the producer, whether order is preserved with two consumers on the
same store, and how many times a delivery record opens when the same message is processed
twice.

### Message Queues and Streams

Source: https://academia.sh/en/courses/architectural-styles/message-queues-and-streams

Measuring the effect of putting a store between the publisher and the consumer: the consumer work unit run the moment a message is dropped, the number of pending messages when the consumer falls behind the producer, whether order is preserved with one and two consumers, the delivery record opened when a message is delivered a second time, and the number of history records a unit added afterward can read from a queue versus a stream.

The previous lesson's bus had an unmeasured property: the `publish` call did not return until
every attached handler finished. Four handlers ran in sequence on the same call stack, so the
module that finalizes the fee took as long as the slowest handler. If archive writing slowed
down, fee finalization slowed down too, even though the archive's write time does not matter
for pricing. This lesson puts a **store** in between: the publisher drops the message and
returns, the consumer takes it later.

The **message queue** and the **stream** were built and measured in the Messaging topic of the
Caching, Queues and Asynchronous Processing course: delivery semantics, the ordering/partitioning
trade-off, dead-letter queues, and retry belong there and are not revisited here. What falls to
this lesson is the style itself — what the store guarantees, what it does not, and the
constraint this brings to the architecture. Four things are measured: consumer work run at the
drop, pending messages, whether order is preserved, and the consequence of the same message
processed twice.

The **quality attribute** it connects to is reliability, and the scenario question is: when the
consumer falls behind the producer, or a message is delivered a second time, how much work piles
up and how many delivery records get opened twice. A second measure falls into the performance
efficiency family: how many consumer work units have run by the time the publisher drops the
message.

#### Two Ends and a Store

The consumer is in the delivery operations context: it opens a delivery record for every message
that arrives. Each message is assumed to cost three work units. **This is a model, not a
measurement**; what is counted is the work unit count, no duration has been measured. The
`keyed` option skips opening a record the second time it sees the same shipment code; it is
measured at the end of the lesson.

```sh
mkdir -p async
```

```js
// async/consumer.mjs — consumer: opens a delivery record for each message and spends 3 work units
export const WORK_UNITS = 3;

export function consumer(keyed = false) {
  const records = [], seen = new Set();
  let units = 0;
  return {
    process(message) {
      units += WORK_UNITS;
      if (keyed && seen.has(message.code)) return;
      seen.add(message.code);
      records.push(message.code);
    },
    units: () => units,
    records: () => records,
  };
}
```

The store exposes two operations. `enqueue` appends the message to the end and calls no
consumer; `dequeue` gives the oldest message. The gap between these signatures is the whole of
the style: enqueuer and dequeuer do not have to run at the same time.

```js
// async/queue.mjs — message queue: the enqueuer does not wait, the dequeuer takes in order
export function queue() {
  const pending = [];
  let enqueued = 0, dequeued = 0, peak = 0;
  return {
    enqueue(message) { pending.push(message); enqueued += 1; peak = Math.max(peak, pending.length); },
    dequeue() { if (pending.length === 0) return null; dequeued += 1; return pending.shift(); },
    length: () => pending.length,
    counts: () => ({ enqueued, dequeued, peak }),
  };
}
```

#### The Publisher Does Not Wait

The first script passes twenty messages through both arrangements. In the synchronous
arrangement the publisher calls the consumer directly; in the queue arrangement it drops the
message. The consumer work run the moment the drop finishes is counted, the queue is then
drained, and both arrangements are verified to produce the same record. In the second section,
the producer processes five messages per round and the consumer three, and production stops
after the fourth round.

```js
// blocking.mjs — work run when the publisher drops the message, and messages pending in the queue
import { consumer, WORK_UNITS } from "./async/consumer.mjs";
import { queue } from "./async/queue.mjs";

const MESSAGES = Array.from({ length: 20 }, (_, i) => ({ code: `G-${String(i + 1).padStart(2, "0")}` }));

const sync = consumer();
for (const message of MESSAGES) sync.process(message);

const q = queue(), later = consumer();
for (const message of MESSAGES) q.enqueue(message);
const atDrop = { units: later.units(), pending: q.length() };
let dequeued;
while ((dequeued = q.dequeue()) !== null) later.process(dequeued);

const WIDTHS = [15, 25, 16, 18];
const ROWS = [
  ["synchronous", MESSAGES.length, sync.units(), 0, sync.units()],
  ["queue", MESSAGES.length, atDrop.units, atDrop.pending, later.units()],
];
console.log("arrangement    publisher calls  work run at drop  pending messages  work run at end");
for (const [label, ...s] of ROWS) console.log(label.padEnd(13) + s.map((v, i) => String(v).padStart(WIDTHS[i])).join(""));
console.log(`queue counts: ${JSON.stringify(q.counts())}, records equal = ${JSON.stringify(sync.records()) === JSON.stringify(later.records())}`);

const PRODUCTION = [5, 5, 5, 5, 0, 0, 0, 0];
const CONSUMPTION = 3;
const queue2 = queue(), consumer2 = consumer();
let sequence = 0;
console.log("\nround  produced  consumed  pending");
for (const [i, produced] of PRODUCTION.entries()) {
  for (let n = 0; n < produced; n += 1) { sequence += 1; queue2.enqueue({ code: `H-${sequence}` }); }
  let consumed = 0;
  for (let n = 0; n < CONSUMPTION; n += 1) {
    const message = queue2.dequeue();
    if (message === null) break;
    consumer2.process(message);
    consumed += 1;
  }
  console.log(`${String(i + 1).padStart(3)}${String(produced).padStart(10)}${String(consumed).padStart(11)}${String(queue2.length()).padStart(10)}`);
}
console.log(`peak pending = ${queue2.counts().peak}, work units = ${consumer2.units()}, work units per message = ${WORK_UNITS}`);
```

```sh
node blocking.mjs
```

```
arrangement    publisher calls  work run at drop  pending messages  work run at end
synchronous               20                       60               0                60
queue                     20                        0              20                60
queue counts: {"enqueued":20,"dequeued":20,"peak":20}, records equal = true

round  produced  consumed  pending
  1         5          3         2
  2         5          3         4
  3         5          3         6
  4         5          3         8
  5         0          3         5
  6         0          3         2
  7         0          2         0
  8         0          0         0
peak pending = 11, work units = 60, work units per message = 3
```

#### Reading the Numbers

The first table's last column is 60 in both arrangements: no work vanished, the same work got
done, and both produced the same record. What changes is **when** the work happens. In the
synchronous arrangement, sixty work units had already spent the publisher's own time by its
twentieth call; in the queue arrangement, zero work units had run at the same moment, and twenty
messages waited in the store. The pending-message count is the cost's name: the publisher
escaped waiting, but unprocessed work now sits in the system and has to be held somewhere.

The second table shows how this backlog behaves. While production runs faster than consumption,
the pending count grows by two every round; after the fourth round's production, the queue
reaches its peak, eleven. Once production stops, the queue drains and empties three rounds
later. This is the style's core constraint: the queue **stores** the speed gap, it does not
eliminate it. If production stays permanently faster than consumption, the pending count grows
without bound; the queue only buys the publisher freedom from waiting while the gap is
temporary.

#### Order and Retry

The second script asks two questions. The first is order: is the enqueue order the same as the
processing order? It is measured separately for a single consumer and for two consumers working
off the same queue's end. In the two-consumer arrangement, the workers' costs differ, and
whichever worker frees up first takes the next message; finish order follows from that. **This
is a model, not a measurement** — what is counted is the work unit and the finish order, no
duration has been measured. The second question is retry: what happens if an unacknowledged
message goes back onto the queue?

```js
// order-and-retry.mjs — order with one and two consumers, delivery records opened when a message is redelivered
import { consumer } from "./async/consumer.mjs";
import { queue } from "./async/queue.mjs";

const MESSAGES = Array.from({ length: 8 }, (_, i) => ({ code: `G-${i + 1}` }));
const ORDER = MESSAGES.map((m) => m.code);

const q = queue(), single = consumer();
for (const message of MESSAGES) q.enqueue(message);
let dequeued;
while ((dequeued = q.dequeue()) !== null) single.process(dequeued);

const inversions = (sequence) => {
  const position = new Map(ORDER.map((code, i) => [code, i]));
  let count = 0;
  for (let a = 0; a < sequence.length; a += 1)
    for (let b = a + 1; b < sequence.length; b += 1) if (position.get(sequence[a]) > position.get(sequence[b])) count += 1;
  return count;
};

const COST = [1, 4];
const freeAt = COST.map(() => 0);
const finish = [];
const queue2 = queue();
for (const message of MESSAGES) queue2.enqueue(message);
while ((dequeued = queue2.dequeue()) !== null) {
  const j = freeAt[0] <= freeAt[1] ? 0 : 1;
  freeAt[j] += COST[j];
  finish.push([dequeued.code, freeAt[j]]);
}
const finishOrder = [...finish].sort((a, b) => a[1] - b[1]).map(([code]) => code);

console.log(`enqueue order   : ${ORDER.join(" ")}`);
console.log(`single consumer : ${single.records().join(" ")}  inversions = ${inversions(single.records())}`);
console.log(`two consumers   : ${finishOrder.join(" ")}  inversions = ${inversions(finishOrder)}`);

for (const [label, keyed] of [["unkeyed", false], ["keyed", true]]) {
  const queue3 = queue(), consumer3 = consumer(keyed);
  for (const message of MESSAGES) queue3.enqueue(message);
  let count = 0;
  while ((dequeued = queue3.dequeue()) !== null) {
    consumer3.process(dequeued);
    count += 1;
    if (count === 3) queue3.enqueue(dequeued);
  }
  const counts = queue3.counts();
  console.log(`${label.padEnd(11)} delivered = ${counts.dequeued}, distinct messages = ${new Set(MESSAGES.map((m) => m.code)).size}, opened delivery records = ${consumer3.records().length}, work units = ${consumer3.units()}`);
}
```

```sh
node order-and-retry.mjs
```

```
enqueue order   : G-1 G-2 G-3 G-4 G-5 G-6 G-7 G-8
single consumer : G-1 G-2 G-3 G-4 G-5 G-6 G-7 G-8  inversions = 0
two consumers   : G-1 G-3 G-4 G-2 G-5 G-6 G-8 G-7  inversions = 3
unkeyed     delivered = 9, distinct messages = 8, opened delivery records = 9, work units = 27
keyed       delivered = 9, distinct messages = 8, opened delivery records = 8, work units = 27
```

For a single consumer, the inversion count is zero: order is preserved, a consequence of the
store's `dequeue` signature. When a second consumer is added, the same queue hands out messages
in the same order, but the finish order broke in three places. Order preservation is tied to
having a single consumer; adding a second is not a performance decision, it is a decision to
**give up the order guarantee**. The architectural consequence: work whose order matters and
work that can be processed in parallel cannot go through the same queue.

The last two lines are retry's bill. Eight distinct messages were delivered nine times. In the
unkeyed consumer, opened delivery records numbered nine: two for the same shipment. In the keyed
consumer it stayed at eight — the second delivery had no effect — but work units are 27 in both,
since the message was taken and processed again regardless. This gives the style's constraint:
the store puts the message in order, it does not guarantee **uniqueness**; that has to come from
the consumer's own key. How a two-consumer design deviates against the same message arriving
twice is measured separately in this course's Event-Driven Architecture lesson.

#### Consuming versus Deleting

The queue's `dequeue` operation removes the message from the store: consuming is deleting. The
stream separates these operations — the record stays in place, and each consumer keeps its own
**offset**. Offset, retention, and rereading were measured in the Streams versus Queues lesson of
the Messaging topic. The question here is architectural: can a unit added afterward build its
own history alone?

```js
// async/log.mjs — stream: consuming does not delete, each consumer keeps its own offset
export function log() {
  const entries = [];
  return { append: (message) => entries.push(message), read: (offset) => entries.slice(offset), length: () => entries.length };
}
```

```js
// history.mjs — queue empties once consumed, stream is read by offset: history for a unit added afterward
import { queue } from "./async/queue.mjs";
import { log } from "./async/log.mjs";
import { consumer } from "./async/consumer.mjs";

const MESSAGES = Array.from({ length: 20 }, (_, i) => ({ code: `G-${i + 1}` }));

const q = queue(), queueConsumer = consumer();
for (const message of MESSAGES) q.enqueue(message);
let dequeued;
while ((dequeued = q.dequeue()) !== null) queueConsumer.process(dequeued);

const stream = log(), streamConsumer = consumer();
for (const message of MESSAGES) stream.append(message);
let offset = 0;
for (const message of stream.read(offset)) { streamConsumer.process(message); offset += 1; }
const initialOffset = offset;

const newcomer = consumer();
for (const message of stream.read(0)) newcomer.process(message);

console.log(`queue: enqueued ${q.counts().enqueued}, pending after being consumed ${q.length()}`);
console.log(`  history a newcomer can read from the queue on its own = ${q.length()} records`);
console.log(`  the history is only obtained by exposing the consumer's records: ${queueConsumer.records().length} records, bindings created to another consumer = 1`);
console.log(`stream: log length ${stream.length()}, first consumer's offset ${initialOffset}`);
console.log(`  history a newcomer reading from offset 0 can read = ${newcomer.records().length} records, bindings created to another consumer = 0`);
console.log(`  first consumer's offset ${initialOffset} -> ${offset}, log length ${stream.length()}`);
```

```sh
node history.mjs
```

```
queue: enqueued 20, pending after being consumed 0
  history a newcomer can read from the queue on its own = 0 records
  the history is only obtained by exposing the consumer's records: 20 records, bindings created to another consumer = 1
stream: log length 20, first consumer's offset 20
  history a newcomer reading from offset 0 can read = 20 records, bindings created to another consumer = 0
  first consumer's offset 20 -> 20, log length 20
```

Once the queue has emptied, the history a newly added unit can read on its own is zero records.
The only way to get the same information is for the existing consumer to expose its record —
building a new binding between two consumers: the new unit now depends not on the publisher but
on another consumer. In the stream the same number is twenty, and the binding created is zero;
the new unit knows only the log. The second read left the first consumer's offset unchanged, and
the log's length did not grow — consumer count does not grow the knowledge obligation.

This is the choice criterion: if the work happens once and is done, a queue is enough; if units
added afterward need to build their own state from history, a stream is needed — its cost is
keeping the record around.

#### Summary

- The store separates the publisher from the consumer: a publisher dropping twenty messages
  spent 60 work units of its own time synchronously, spent 0 through the queue, and left 20
  messages waiting in the store.
- Total work is 60 in both arrangements and both produced the same record; the queue changes
  when work happens, not how much.
- The queue stores the speed gap: with 5 produced and 3 consumed per round, the pending peak was
  11, and the queue emptied three rounds after production stopped.
- Order was preserved with a single consumer (0 inversions); adding a second consumer broke the
  finish order in three places — a decision to give up the order guarantee.
- When eight messages were delivered nine times, the unkeyed consumer opened 9 delivery records
  and the keyed consumer 8; work units were 27 in both — the store guarantees order, not
  uniqueness.
- In a queue, consuming is deleting: a unit added afterward can read 0 history records, versus
  20 in a stream, which creates no binding to another consumer.

#### Next Step

This topic covered how units talk to each other. The same three things were measured across six
styles: a unit's obligation to know the other side, the direction of connection, and the data
crossing the boundary. Client and server, peers, model and view, pipe and filter, publisher and
subscriber, the two ends of a queue — all compared on the same three numbers. But these
measurements passed by one question: how many separate parts are these units deployed as? This
lesson's queue, and the previous lesson's bus, both lived inside a single running program; the
publish–subscribe arrangement needed no second process, one object was enough. Interaction style
and part count are two independent decisions. The next topic asks the second one: how many
separate deployment units does the software get published as, and what does that number change.

## Deployment Styles

The difference that comes from how many separate pieces the same software is published as: the simplicity and scaling limit of a single unit, enterprise-wide service sharing, the distributed complexity paid for independent release, systems built on event streams, the composition of functions and managed services, and choosing among all these arrangements by comparing them through quality attributes.

### Monolithic Architecture

Source: https://academia.sh/en/courses/architectural-styles/monolithic-architecture

Publishing two contexts of the same library as a single deployment unit: counting the boundaries an offer request crosses, its serialization points, and the bytes crossing the boundary; measuring how many files a one-file change republishes; and showing that a defect terminating the process brings down both contexts at once.

The previous topic covered how units talk to each other. Every interaction style was measured
by the same three things: whether a unit is obligated to know its counterpart, the direction of
coupling, and the data crossing the boundary. But all of those measurements passed over one
question. Client and server, publisher and subscriber, pipe and filter — every one of these can
be built inside a single running program. The publish–subscribe arrangement does not need a
second process; an event bus object is enough.

This topic asks that question directly: **into how many separate pieces is the software
published?** The answer is independent of the interaction style and has its own measurements. A
**deployment unit** is a set of files that must be published together; when a release goes out,
the entire set is replaced at once. **Monolithic architecture** is the style that keeps every
context in a single deployment unit: the module count varies, but the deployment unit count is
1.

#### Two Contexts, One Process

The library has two bounded contexts. In the pricing context, a shipment is a priceable unit;
in the delivery operation context, it is a physical parcel with a route and a carrier. Both are
laid out plainly below.

```js
// shared/fee.mjs — pricing context: the shipment is a priceable unit
export const TARIFF = { tier: [[1, 3000], [5, 4800], [20, 9600]], zone: { "34": 100, "06": 115, "35": 125 }, minimum: 2500 };

export function baseFee(weight, zone) {
  const tier = TARIFF.tier.find(([k]) => weight <= k) ?? [0, 9600];
  return Math.max(TARIFF.minimum, Math.round((tier[1] * TARIFF.zone[zone]) / 100));
}

export function net(weight, zone, contractRate) {
  const t = baseFee(weight, zone);
  return t - Math.round(t * Math.min(contractRate, 0.4));
}
```

```js
// shared/operation.mjs — delivery operation context: the shipment is a physical parcel with a route and a carrier
export const TREE = { "34": ["34"], "06": ["34", "06"], "35": ["34", "41", "35"] };

export function route(zone) { return TREE[zone] ?? ["34"]; }

export function carrier(zone) { return route(zone).length > 2 ? "MT" : "AN"; }

export function deliveryDay(zone) { return route(zone).length; }
```

To count boundary crossings, each unit's outer face is wrapped. This wrapper stays the same
throughout the course: whichever style is being measured, it is the one collecting the
crossings.

```js
// shared/wrap.mjs — wraps a module's outer face; every call is recorded by unit name
export function wrap(name, module, log) {
  const face = {};
  for (const [k, v] of Object.entries(module)) {
    face[k] = typeof v === "function" ? (...a) => { log.push(`${name}.${k}`); return v(...a); } : v;
  }
  return face;
}
```

An offer request touches both contexts at once: the net fee comes from pricing, the delivery
day and the carrier come from operation. In the monolithic arrangement, this combination is a
function call.

```js
// monolith/application.mjs — both contexts in the same process, same memory, one deployment unit
import * as feeModule from "../shared/fee.mjs";
import * as operationModule from "../shared/operation.mjs";
import { wrap } from "../shared/wrap.mjs";

export function application(log) {
  const fee = wrap("fee", feeModule, log);
  const operation = wrap("operation", operationModule, log);
  return {
    offer(s) {
      log.push("application.offer");
      return {
        id: s.id,
        net: fee.net(s.weight, s.zone, s.rate),
        day: operation.deliveryDay(s.zone),
        carrier: operation.carrier(s.zone),
      };
    },
  };
}
```

#### Measuring the Request and the Release

The measurement produces two kinds of numbers. The first comes from the run: the units a
request touches, the boundaries it crosses, the serialization points, and the bytes crossing
the boundary. The second comes from the files: the deployment unit's import closure, that is,
the set of files that must be published together.

```js
// monolith/measure.mjs — units a request touches, boundaries it crosses, the publish set, and the neighbor requirement
import { readFileSync } from "node:fs";
import { dirname, join, normalize } from "node:path";
import { application } from "./application.mjs";

const log = [];
const output = application(log).offer({ id: "G1", weight: 4, zone: "35", rate: 0.15 });
const unit = new Set(log.map((c) => c.split(".")[0]));
console.log(`offer output = ${JSON.stringify(output)}`);
console.log(`boundary crossing = ${log.length} (${log.join(" -> ")})`);
console.log(`units touched = ${unit.size}, serialization point = 0, bytes crossing the boundary = 0`);

const IMPORT = /from\s+"(\.[^"]+)"/g;
function closure(entry) {
  const stack = [entry], visited = new Set();
  while (stack.length > 0) {
    const d = normalize(stack.pop());
    if (visited.has(d)) continue;
    visited.add(d);
    for (const [, y] of readFileSync(d, "utf8").matchAll(IMPORT)) stack.push(join(dirname(d), y));
  }
  return [...visited].sort();
}

const files = closure("monolith/application.mjs");
console.log(`deployment unit = 1, unit closure = ${files.length} files (${files.join(", ")})`);
console.log(`if fee.mjs changes: files changed = 1, files republished = ${files.length}`);
for (const g of ["shared/fee.mjs", "shared/operation.mjs"]) {
  console.log(`${g} neighbors needed to run alone = ${closure(g).length - 1}`);
}
```

```sh
node monolith/measure.mjs
```

```
offer output = {"id":"G1","net":5100,"day":3,"carrier":"MT"}
boundary crossing = 4 (application.offer -> fee.net -> operation.deliveryDay -> operation.carrier)
units touched = 3, serialization point = 0, bytes crossing the boundary = 0
deployment unit = 1, unit closure = 4 files (monolith/application.mjs, shared/fee.mjs, shared/operation.mjs, shared/wrap.mjs)
if fee.mjs changes: files changed = 1, files republished = 4
shared/fee.mjs neighbors needed to run alone = 0
shared/operation.mjs neighbors needed to run alone = 0
```

#### Reading the Numbers

There are four boundary crossings, and all four are function calls. The serialization point is
0 and the bytes crossing the boundary are 0: the shipment object passed into pricing by its
in-memory reference, not by a copy. This is the style's most concrete gain. No format is
negotiated for the data crossing the boundary, no version compatibility is debated, no
transformation point is defined — because there is no transformation.

The second pair of numbers shows the style's limit. If one line changes in the pricing rule,
the file changed is 1, but the files republished are 4. A release that fixes the minimum fee in
pricing also republishes the operation context's code. This is not a coupling at the code
level; the two contexts do not import each other. It is a coupling at the release level, and
its source is the style itself.

The last two lines expose an important distinction. Both contexts are **independently
testable**: neither needs the other's file, and the neighbors needed are 0. So monolithic
architecture does not block independent *testability*; what it blocks is independent *release*.
Conflating these two is the most common mistake in style discussions.

#### Shared Fate

The monolithic style's second limit is in failure behavior. Because both contexts run in the
same process, a defect that terminates the process stops both at once. A per-request catch is
not enough: a catch only holds an error raised on its own call stack, not one raised from a
task deferred to later.

```js
// monolith/failure.mjs — a defect that terminates the process even with a per-request catch
import * as fee from "../shared/fee.mjs";
import * as operation from "../shared/operation.mjs";

const REQUEST = [
  { unit: "fee", id: "G1" }, { unit: "operation", id: "G2" },
  { unit: "fee", id: "G3", defective: true }, { unit: "operation", id: "G4" },
  { unit: "fee", id: "G5" }, { unit: "operation", id: "G6" },
];

let answered = 0;
process.on("exit", () => {
  console.log(`answered requests = ${answered} / ${REQUEST.length}`);
  console.log(`units left standing = ${answered === REQUEST.length ? 2 : 0} / 2`);
});

for (const request of REQUEST) {
  try {
    if (request.unit === "fee") {
      if (request.defective) setTimeout(() => { throw new RangeError(`no tariff tier: ${request.id}`); });
      console.log(`${request.id} fee    = ${fee.net(4, "35", 0.15)}`);
    } else {
      console.log(`${request.id} route  = ${operation.route("35").join("-")}`);
    }
    answered += 1;
  } catch (e) {
    console.log(`${request.id} caught: ${e.message}`);
  }
  await new Promise((c) => setTimeout(c, 5));
}
```

The stack trace is written to the standard error stream; the command below separates it out and
leaves only the counted output and the exit code.

```sh
node monolith/failure.mjs 2>/dev/null; echo "exit code = $?"
```

```
G1 fee    = 5100
G2 route  = 34-41-35
G3 fee    = 5100
answered requests = 3 / 6
units left standing = 0 / 2
exit code = 1
```

Three of the six requests were answered. The defect was in the pricing context, but both of the
operation requests, G4 and G6, went unanswered; the units left standing are 0. Failure isolation
is not an unmeasurable quality in this style — it is measured, and the result is zero.

#### The Bill the Style Leaves Behind

The numbers describe a quality attribute trade-off, and both of its sides appear in this same
lesson. On the gain side: bytes crossing the boundary are 0, serialization points are 0, and the
number of processes to look at when tracing a request end to end is 1. On the loss side: a
one-file change republishes 4 files, and across six requests the units left standing are 0.

The direction of this trade-off changes with team structure and release cadence. If the same
team publishes both contexts on the same schedule, "files republished: 4" is not a cost; the
release was going to happen together anyway. Once the contexts move to separate schedules, the
same number turns directly into waiting time: the pricing release has to wait for the operation
side to be stable.

The scaling limit comes from the same place. Because there is a single process, replication can
only happen as a whole: when the number of pricing requests rises, the operation code is
replicated right along with it. As long as the replicated unit count stays 1, information about
which context is under load cannot enter the replication decision.

#### Summary

- A deployment unit is a set of files that must be published together; monolithic architecture
  is the style that keeps every context in a single deployment unit.
- The offer request touched 3 units and crossed 4 boundaries; because all four were function
  calls, the serialization points measured 0 and the bytes crossing the boundary measured 0.
- A change in one file republished 4 files; this is a release coupling, not a code coupling, and
  its source is the style itself.
- Both contexts could run on their own (neighbors needed: 0); the style blocks independent
  release, not independent testability.
- With a defect that terminated the process, 3 of six requests were answered and the units left
  standing were 0; failure isolation was measured, and it came out at zero.
- The direction of the trade-off is not fixed: if the contexts publish on the same schedule, the
  release coupling is not a cost; once they move to separate schedules, it becomes waiting time.

#### Next Step

The cost of release coupling shows up once the two contexts move to separate schedules. The
first attempt at a solution is not to separate the contexts but to place a shared integration
surface between them: each context ships to its own deployment unit, but both speak through a
common record format and a common call path. This arrangement targets enterprise-wide reuse,
and its measurement comes from there: the field count of the shared format, the number of
transformation points at each boundary crossing, and the number of services forced to publish
together when a field in the shared format changes. The next lesson measures those three
numbers.

### Service-Oriented Architecture

Source: https://academia.sh/en/courses/architectural-styles/service-oriented-architecture

Two contexts speaking through a single enterprise-wide record format and a single call path: measuring the canonical record's field count, the fields each service carries but does not read, the transformation points, and the bytes crossing the boundary; comparing these against a narrow contract; and counting the files edited when a field in the shared format changes.

In the monolithic arrangement, a one-file change republished four files because both contexts
sat in the same deployment unit. The first attempt at a solution is not to pull the contexts
apart but to place a shared integration surface between them. Each context ships to its own
deployment unit, but both speak through a common record format and a common call path.

**Service-oriented architecture** sets up this arrangement for the entire enterprise: every
capability is published as a coarse-grained **service**, services hide their own
implementation, and the exchange between them runs through a single record format valid across
the enterprise. The service here shares its name with the in-application layer measured in the
Service Layer lesson of the Design Patterns course, but it is a different thing: there, a
service was an application's internal boundary; here, it is a deployment unit published on its
own.

#### The Canonical Record and the Common Call Path

At the center of the style stands the **canonical data model**: a single shipment record that
both contexts accept. Because the record must meet both contexts' needs, its fields are the
union of the two.

```js
// shared/canonical.mjs — one enterprise-wide shipment record: every service speaks the same format
export const CANONICAL_FIELD = [
  "id", "weight", "volume", "zone", "value",
  "contractNo", "contractRate", "carrierCode", "deliveryState", "lastTimestamp",
];

export function toCanonical(s) {
  const c = {};
  for (const a of CANONICAL_FIELD) c[a] = s[a] ?? null;
  return c;
}
```

Calls do not go directly from service to service; they pass through a shared surface. This
surface finds the service by name, carries the body in canonical form, and counts the
crossings. Serialization is real here: the body is converted to text and read back on the other
side.

```js
// bus/bus.mjs — the shared integration surface: carries the call in canonical form and counts it
import { toCanonical } from "../shared/canonical.mjs";

export function bus(log) {
  const services = new Map();
  return {
    register(name, service) { services.set(name, service); },
    call(name, operation, shipment) {
      const body = JSON.stringify(toCanonical(shipment));
      log.crossing.push(`bus -> ${name}.${operation}`);
      log.transform += 1;
      log.bytes += body.length;
      const result = services.get(name)[operation](JSON.parse(body));
      const response = JSON.stringify(result);
      log.crossing.push(`${name}.${operation} -> bus`);
      log.transform += 1;
      log.bytes += response.length;
      return JSON.parse(response);
    },
  };
}
```

Each service takes the canonical record, converts it to its own internal format, does its work,
and returns the result. The `READ_FIELD` list states which fields the service actually uses
from the canonical record; the measurement uses this list.

```js
// service/fee-service.mjs — coarse-grained service: takes the canonical record, converts to its own format
const TARIFF = { tier: [[1, 3000], [5, 4800], [20, 9600]], zone: { "34": 100, "06": 115, "35": 125 }, minimum: 2500 };
export const READ_FIELD = ["id", "weight", "zone", "contractRate"];

export const feeService = {
  price(canonical) {
    const s = { weight: canonical.weight, zone: canonical.zone, rate: canonical.contractRate ?? 0 };
    const tier = TARIFF.tier.find(([k]) => s.weight <= k) ?? [0, 9600];
    const base = Math.max(TARIFF.minimum, Math.round((tier[1] * TARIFF.zone[s.zone]) / 100));
    return { id: canonical.id, net: base - Math.round(base * Math.min(s.rate, 0.4)) };
  },
};
```

```js
// service/operation-service.mjs — coarse-grained service: produces a route from the same canonical record
const TREE = { "34": ["34"], "06": ["34", "06"], "35": ["34", "41", "35"] };
export const READ_FIELD = ["id", "zone", "deliveryState"];

export const operationService = {
  plan(canonical) {
    const route = TREE[canonical.zone] ?? ["34"];
    return {
      id: canonical.id, route, day: route.length,
      carrier: route.length > 2 ? "MT" : "AN",
      state: canonical.deliveryState ?? "planned",
    };
  },
};
```

#### Measuring the Shared Format

The measurement asks four questions: how many boundaries does a request cross and how many
transformation points does it carry, how many fields of the canonical record does each service
actually read, how many bytes does the same request shrink to with a service-specific narrow
contract, and how many files are edited when a field in the shared format changes.

```js
// soa/measure.mjs — the canonical format's field usage, transform points, bytes, and the effect of a shared-field change
import { readFileSync } from "node:fs";
import { CANONICAL_FIELD, toCanonical } from "../shared/canonical.mjs";
import { bus } from "../bus/bus.mjs";
import { feeService, READ_FIELD as FEE_FIELD } from "../service/fee-service.mjs";
import { operationService, READ_FIELD as OPERATION_FIELD } from "../service/operation-service.mjs";

const SHIPMENT = { id: "G1", weight: 4, volume: 18, zone: "35", value: 12000, contractNo: "S7", contractRate: 0.15 };
const log = { crossing: [], transform: 0, bytes: 0 };
const surface = bus(log);
surface.register("fee", feeService);
surface.register("operation", operationService);

const price = surface.call("fee", "price", SHIPMENT);
const plan = surface.call("operation", "plan", SHIPMENT);
console.log(`offer = ${JSON.stringify({ id: price.id, net: price.net, day: plan.day, carrier: plan.carrier })}`);
console.log(`boundary crossing = ${log.crossing.length}, transform point = ${log.transform}, bytes crossing the boundary = ${log.bytes}`);

console.log(`canonical field = ${CANONICAL_FIELD.length}`);
for (const [name, read] of [["fee", FEE_FIELD], ["operation", OPERATION_FIELD]]) {
  console.log(`  ${name.padEnd(9)} field read = ${read.length}, field carried but unread = ${CANONICAL_FIELD.length - read.length}`);
}

const narrow = (fields) => JSON.stringify(Object.fromEntries(fields.map((a) => [a, SHIPMENT[a] ?? null])));
const canonicalBytes = 2 * JSON.stringify(toCanonical(SHIPMENT)).length;
const narrowBytes = narrow(FEE_FIELD).length + narrow(OPERATION_FIELD).length;
console.log(`two request bodies: canonical = ${canonicalBytes} bytes, service-specific narrow contract = ${narrowBytes} bytes`);
for (const n of [2, 4, 8]) {
  console.log(`  services = ${n}: canonical converter = ${n}, point-to-point converter = ${n * (n - 1)}`);
}

const FILE = ["shared/canonical.mjs", "bus/bus.mjs", "service/fee-service.mjs", "service/operation-service.mjs"];
for (const field of ["zone", "contractRate"]) {
  const touching = FILE.filter((d) => readFileSync(d, "utf8").includes(field));
  console.log(`if "${field}" changes: files edited = ${touching.length} (${touching.join(", ")})`);
}
```

```sh
node soa/measure.mjs
```

```
offer = {"id":"G1","net":5100,"day":3,"carrier":"MT"}
boundary crossing = 4, transform point = 4, bytes crossing the boundary = 417
canonical field = 10
  fee       field read = 4, field carried but unread = 6
  operation field read = 3, field carried but unread = 7
two request bodies: canonical = 318 bytes, service-specific narrow contract = 98 bytes
  services = 2: canonical converter = 2, point-to-point converter = 2
  services = 4: canonical converter = 4, point-to-point converter = 12
  services = 8: canonical converter = 8, point-to-point converter = 56
if "zone" changes: files edited = 3 (shared/canonical.mjs, service/fee-service.mjs, service/operation-service.mjs)
if "contractRate" changes: files edited = 2 (shared/canonical.mjs, service/fee-service.mjs)
```

#### Reading the Numbers

In the monolithic lesson, the offer request crossed 4 boundaries, the transform point was 0,
and the bytes crossing the boundary were 0. In the service-oriented arrangement producing the
same output, the boundary crossing is still 4, but the transform points rose to 4 and the bytes
crossing the boundary rose to 417. This is the style's first bill: every boundary is now a
serialization point, and the shape of the body is a contract.

The second set of numbers shows the canonical model's characteristic cost. The record carries
10 fields; the fee service reads 4 of them and carries 6 without reading them. The operation
service reads 3 and carries 7 without reading them. These unread fields are not just bytes:
each one puts a concept the service does not need to know into its contract. If the meaning of
the `value` field changes, the operation service's contract has changed even though it never
reads that field.

The narrow contract comparison turns the cost into a number: the same two requests take 98
bytes with service-specific formats, 318 bytes with the canonical format. Where the canonical
model earns its keep is the converter count. With two services, the two arrangements are equal;
at eight services, the canonical arrangement needs 8 converters, the point-to-point arrangement
needs 56. This is the canonical model's justification, and it strengthens as the service count
grows.

The last two lines show the style's real limit. If the `zone` field changes in the shared
format, 3 files are edited, and two of them are separate deployment units: two services are
forced to publish together. When `contractRate` changes, 2 files are edited and only one
service is affected. Release coupling is not 100% the way it was in the monolithic
arrangement, but it is not zero either: every field the shared contract covers is a coupling
point.

#### Where the Style Belongs

The numbers describe a quality attribute trade-off. The quality gained is compatibility and
reuse: once the ten-field record format is learned, a service newly joining the enterprise
writes 1 converter to talk to the existing two services, instead of having to learn two
separate formats. The quality given up is independent changeability: every field of the shared
format ties even services that do not read that field to a release.

This is why the style pays off at enterprise scale. For two services, the canonical model
brings no gain — the converter count is 2 in both arrangements, while the request body triples
in exchange. As the service count grows and the services spread across separate teams, the
balance reverses: the number of formats to learn stays at 1, while the converter count grows
linearly.

#### Summary

- Service-oriented architecture publishes every capability as a coarse-grained deployment unit
  and runs the exchange through a single enterprise-wide canonical record format.
- The same offer request carried 0 transform points and 0 bytes in the monolithic arrangement,
  and 4 transform points and 417 bytes in this arrangement.
- Of the ten-field canonical record, the fee service read 4 fields and the operation service
  read 3; the fields carried but unread were 6 and 7.
- Two requests took 98 bytes with narrow contracts and 318 bytes with the canonical format; the
  canonical model's payoff is the converter count: 8 against 56 for eight services.
- When the `zone` field changed in the shared format, 3 files were edited and two services were
  forced to publish together; release coupling dropped from 100% but did not reach zero.
- The style pays off at enterprise scale: the number of formats to learn stays at 1 while the
  converter count grows linearly with the service count.

#### Next Step

The shared contract turned out to be the source of the coupling: all ten fields tie even a
service that does not read a field to a release. The next step is to remove the common format.
Each service takes its own contract, its own data format, and its own release schedule; the
call between them passes directly, not through a shared surface. This arrangement genuinely
delivers independent release, but in exchange it produces a new number: how many requests keep
being answered once a service stops. The next lesson runs two services as separate local
processes, measures the boundary crossing and the body size, then stops one process and counts
the endpoints left standing.

### Microservice Architecture

Source: https://academia.sh/en/courses/architectural-styles/microservice-architecture

What is gained and what is paid when the shared contract is removed: running two contexts as separate local processes, bringing the files republished per deployment unit down to 1, counting the boilerplate lines paid for independence, and measuring the number of endpoints that keep responding, and the partial response, when a process is stopped.

In the service-oriented arrangement, the source of the release coupling was the shared
contract: the ten-field canonical record tied even a service that did not read those fields to
a release. The next step is to remove the common format. Each context takes its own contract,
its own data format, and its own release schedule; the call goes directly to the other service,
not through a shared surface.

**Microservice architecture** is this arrangement: every service is a deployment unit that can
be published on its own, with its own process, its own data, and its own contract. Where it
departs from service-oriented architecture is not size but the absence of shared assets — there
is no enterprise-wide canonical model, no common call surface. The measurement in this lesson
runs the two contexts as genuinely separate local processes.

#### Three Processes

The pricing context is its own server. Its contract belongs to it alone: it reads only the
fields it needs from the query string and returns its own response format.

```js
// micro/fee.mjs — pricing context: its own process, its own contract, its own release schedule
import { createServer } from "node:http";

const TARIFF = { tier: [[1, 3000], [5, 4800], [20, 9600]], zone: { "34": 100, "06": 115, "35": 125 }, minimum: 2500 };

function price(s) {
  const weight = Number(s.get("weight")), rate = Number(s.get("rate") ?? 0);
  const tier = TARIFF.tier.find(([k]) => weight <= k) ?? [0, 9600];
  const base = Math.max(TARIFF.minimum, Math.round((tier[1] * TARIFF.zone[s.get("zone")]) / 100));
  return { id: s.get("id"), net: base - Math.round(base * Math.min(rate, 0.4)) };
}

const port = Number(process.argv[2]);
if (Number.isInteger(port) === false) console.log("usage: node micro/fee.mjs <port>");
else createServer((request, response) => {
  const body = JSON.stringify(price(new URL(request.url, "http://local").searchParams));
  response.writeHead(200, { "content-type": "application/json" });
  response.end(body);
}).listen(port);
```

```js
// micro/operation.mjs — delivery operation context: separate process, separate contract
import { createServer } from "node:http";

const TREE = { "34": ["34"], "06": ["34", "06"], "35": ["34", "41", "35"] };

function plan(s) {
  const route = TREE[s.get("zone")] ?? ["34"];
  return { id: s.get("id"), route, day: route.length, carrier: route.length > 2 ? "MT" : "AN" };
}

const port = Number(process.argv[2]);
if (Number.isInteger(port) === false) console.log("usage: node micro/operation.mjs <port>");
else createServer((request, response) => {
  const body = JSON.stringify(plan(new URL(request.url, "http://local").searchParams));
  response.writeHead(200, { "content-type": "application/json" });
  response.end(body);
}).listen(port);
```

The offer request is no longer a function call; it is the aggregation of two network calls.
Because calls can fail, the aggregator has to make a decision: reject the request outright, or
return what it got and report what is missing. The implementation below chooses the second and
writes the missing-part count into the response contract.

```js
// micro/offer.mjs — aggregator: calls both services over the network, flags a partial response
import { createServer } from "node:http";

async function get(address, query) {
  const y = await fetch(`${address}?${query}`);
  if (y.ok === false) throw new Error(`${address} did not return 200`);
  return y.json();
}

async function offer(query, id) {
  const [f, p] = await Promise.allSettled([
    get(process.env.FEE_ADDRESS, query), get(process.env.OPERATION_ADDRESS, query),
  ]);
  return {
    id, net: f.status === "fulfilled" ? f.value.net : null,
    day: p.status === "fulfilled" ? p.value.day : null,
    carrier: p.status === "fulfilled" ? p.value.carrier : null,
    missing: [f, p].filter((r) => r.status === "rejected").length,
  };
}

const port = Number(process.argv[2]);
if (Number.isInteger(port) === false) console.log("usage: node micro/offer.mjs <port>");
else createServer(async (request, response) => {
  const s = new URL(request.url, "http://local").searchParams;
  const body = JSON.stringify(await offer(s.toString(), s.get("id")));
  response.writeHead(200, { "content-type": "application/json" });
  response.end(body);
}).listen(port);
```

#### Measuring the Two States

The driver script starts three processes, measures all three endpoints, then stops the pricing
process and measures the same three endpoints again. The port numbers depend on the
environment; change them if they are already in use on the machine.

```bash
# measure.sh — three separate processes are started; measured both while all are up and once fee is stopped
node micro/fee.mjs 8791 & FEE=$!
node micro/operation.mjs 8792 & OPERATION=$!
FEE_ADDRESS=http://127.0.0.1:8791/price OPERATION_ADDRESS=http://127.0.0.1:8792/plan \
  node micro/offer.mjs 8793 & OFFER=$!
sleep 1
S="id=G1&weight=4&zone=35&rate=0.15"

measure() {
  for u in 8791/price 8792/plan 8793/offer; do
    printf "  %-12s %s\n" "$u" "$(curl -s -o /dev/null -w '%{http_code} code, %{size_download} bytes' "http://127.0.0.1:$u?$S")"
  done
  printf "  offer body = %s\n" "$(curl -s "http://127.0.0.1:8793/offer?$S")"
}

echo "state 1: three processes up"
measure
kill $FEE
sleep 0.5
echo "state 2: fee process stopped"
measure
kill $OPERATION $OFFER 2>/dev/null

echo "deployment unit = 3"
for d in micro/fee.mjs micro/operation.mjs micro/offer.mjs; do
  printf "  %-20s imports = %s, server boilerplate = %s lines\n" "$d" \
    "$(grep -c '^import' $d)" "$(grep -cE 'createServer|writeHead|response.end|process.argv|Number.isInteger' $d)"
done
```

```
state 1: three processes up
  8791/price   200 code, 22 bytes
  8792/plan    200 code, 59 bytes
  8793/offer   200 code, 57 bytes
  offer body = {"id":"G1","net":5100,"day":3,"carrier":"MT","missing":0}
state 2: fee process stopped
  8791/price   000 code, 0 bytes
  8792/plan    200 code, 59 bytes
  8793/offer   200 code, 57 bytes
  offer body = {"id":"G1","net":null,"day":3,"carrier":"MT","missing":1}
deployment unit = 3
  micro/fee.mjs        imports = 1, server boilerplate = 6 lines
  micro/operation.mjs  imports = 1, server boilerplate = 6 lines
  micro/offer.mjs      imports = 1, server boilerplate = 6 lines
```

#### Reading the Numbers

Failure isolation came out at zero in the first lesson: a single defect in one process left
three of six requests unanswered, and the units left standing were 0. Here, when the same
context is stopped, 2 of the three endpoints keep returning 200. The operation endpoint produced
its 59-byte response on its own; it never looked at the pricing side. This is the measured
difference, and it is the style's real justification.

The aggregator endpoint's behavior produces a second number. The offer endpoint returned 200,
but its body changed: the `net` field is `null`, the `missing` field is 1. This **partial
response** is not an accident; it is a design decision the style forces. It was not needed in
the monolithic arrangement, because the call could not fail; here, a field was added to the
response contract, and the client has to know what that field means.

The gain on the release side was measured: every deployment unit's closure is 1 file, so if one
line changes in the pricing rule, the files republished are 1. That number was 4 in the
monolithic arrangement and 3 for the `zone` field in the service-oriented arrangement. The cost
paid shows up in the same output: 6 lines of server boilerplate are repeated in each of the
three files, 18 lines in total. Moving this repetition into a shared file brings the release
coupling back — once that file changes, all three units publish together.

#### The Number Behind Distributed Complexity

The style's bill collects in a single place: the outcome of an aggregated request is no longer
one of two states but one of four, depending on the sub-service count. The number of up/down
states grows with $2^n$; for two sub-services there are 4 states, and the measurement actually
ran two of them. In the monolithic arrangement $n = 1$, so the state count was 2: working or not
working.

The growth of the state count is a direct maintainability cost. Every additional state needs a
decision — reject, return a partial response, or use a stale value — and that decision turns
into a contract field, a test scenario, and a client behavior. The measurement tested 3
endpoints and 2 states; if the sub-service count rose to four, the same test suite would have to
face 16 states.

The second cost is the multiplication of contracts. In the service-oriented arrangement, the
number of formats to learn was 1; here, every service publishes its own format. In the
measurement, all three endpoints answered with a different body format: three separate
contracts of 22, 59, and 57 bytes. Removing the canonical model lowered the byte count and broke
the release coupling, but it made the number of formats to learn and track for version
compatibility equal to the service count.

#### Summary

- In microservice architecture, every service is a deployment unit that can be published on its
  own, with its own process, its own data, and its own contract; there is no shared canonical
  model and no common call surface.
- When the pricing process was stopped, 2 of the three endpoints kept returning 200; in the
  monolithic arrangement, the same defect had brought the units left standing down to 0.
- The aggregator endpoint gave a partial response: the `net` field is `null`, the `missing`
  field is 1 — the style loaded a new field onto the response contract and a new decision onto
  the client.
- Because every deployment unit's closure is 1 file, the files republished dropped to 1; in
  exchange, 6 lines of server boilerplate were repeated in each of three files, 18 lines in
  total.
- The outcome state of an aggregated request grows with $2^n$: 4 states for two sub-services, 2
  states in the monolithic arrangement.
- The number of body formats to learn was made equal to the service count; in the measurement,
  the three endpoints returned three separate contracts of 22, 59, and 57 bytes.

#### Next Step

Across the three styles so far, the direction of the call never changed: the aggregator knows
pricing and operation by name, calls them, and waits for the response. This obligation to know and
this waiting are the common source of every cost measured — the partial response decision and the
contract count both came from here. The next style reverses the direction of the call: pricing
publishes an event, does not know who is listening, and does not wait for a response. The next
lesson measures what changes once the event crosses the system boundary: the number of consumers
the publisher knows about, the number of lines edited in the publisher to add a new consumer, and
the deviation between two consumer designs' results when the same event arrives twice.

### Event-Driven Architecture

Source: https://academia.sh/en/courses/architectural-styles/event-driven-architecture

Reversing the direction of the call: counting the consumers the publisher knows and calls by name, measuring the line edited in the publisher file when a new consumer is added, computing how the bytes crossing the boundary and the transform points grow with the consumer count, comparing the number of requests answered when a consumer drops, and measuring the deviation between two consumer designs when the same event arrives twice.

In the three styles so far, the direction of the call never changed. Aggregation was a function
call in the monolithic arrangement, a call across a shared surface in the service-oriented
arrangement, and the combination of two network calls in the microservice arrangement. In each,
the caller knew the other unit by name, called it, and waited for a response. Every cost measured
came out of these two obligations.

**Event-driven architecture** reverses the direction of the call: a unit declares that something
has happened; it does not know who is listening and receives no return value. The pricing context
no longer calls the operation — it publishes a `shipment-priced` event. The domain event concept
comes from the Domain-Driven Design course, whose Event Sourcing lesson measured an event sequence
as a *persistence form*. The question here differs: what changes once an event carries a *system
arrangement* and crosses the system boundary.

#### Three Differences in an Event That Crosses the Boundary

Inside the application, a domain event is an in-memory object. Publisher and handler share a call
stack: the publisher sees any error the handler throws and knows, once everything finishes, that
the work is complete. All three disappear at once when the event crosses the system boundary.

First, the event **serializes**: it becomes text, read again on the other side. From this point,
its field list is a published contract, and every field is an obligation whose version
compatibility must be tracked. Second, the publisher **receives no return value** — it knows the
announcement was made, not that the work was done. Third, the event's **arrival count cannot be
assumed to be 1**. Delivery semantics belong to the Caching, Queues and Asynchronous Processing
course; the architectural constraint here is that a consumer has to be designed for the same event
arriving twice.

The bus makes all three differences explicit. Publish–subscribe was established in the same
course; the implementation here is its smallest measurable form.

```js
// event/bus.mjs — event bus: serializes the publication, isolates each consumer, counts the crossings
export function eventBus(log) {
  const listener = new Map();
  return {
    listen(type, consumer) {
      if (listener.has(type) === false) listener.set(type, []);
      listener.get(type).push(consumer);
    },
    publish(type, body) {
      const text = JSON.stringify(body);
      log.transform += 1;
      log.event.push([type, text]);
      for (const c of listener.get(type) ?? []) {
        log.transform += 1;
        log.bytes += text.length;
        log.crossing.push(`${type} -> ${c.name}`);
        try { c.handle(JSON.parse(text)); } catch (e) { log.dropped.push(`${c.name}: ${e.message}`); }
      }
    },
  };
}
```

`publish` deliberately returns nothing and does not pass a consumer's error to the publisher.
Both decisions are the style itself, not a convenience.

The publisher does only its own work and hands off the event; no consumer name appears in this
file.

```js
// event/fee.mjs — pricing context: prices and publishes the event, does not know the listener
const TARIFF = { tier: [[1, 3000], [5, 4800], [20, 9600]], zone: { "34": 100, "06": 115, "35": 125 }, minimum: 2500 };

export function net(weight, zone, rate) {
  const tier = TARIFF.tier.find(([k]) => weight <= k) ?? [0, 9600];
  const base = Math.max(TARIFF.minimum, Math.round((tier[1] * TARIFF.zone[zone]) / 100));
  return base - Math.round(base * Math.min(rate, 0.4));
}

export function pricing(bus) {
  let sequence = 0;
  return {
    price(s) {
      sequence += 1;
      return bus.publish("shipment-priced", {
        eventNo: `E${sequence}`, id: s.id, weight: s.weight, zone: s.zone,
        contractNo: s.contractNo, net: net(s.weight, s.zone, s.rate),
      });
    },
  };
}
```

The `eventNo` field added to the contract is not the shipment's identifier but the event's own
identity — the counterpart of the third difference — and it is used in the measurement.

There are two consumers. The delivery operation writes the route under the shipment identifier;
if the same event arrives twice, the second write lands on the same key with the same value.

```js
// event/operation.mjs — delivery operation consumer: writes the route to the shipment identifier
const TREE = { "34": ["34"], "06": ["34", "06"], "35": ["34", "41", "35"] };

export function operationConsumer() {
  const plan = new Map();
  return {
    name: "operation",
    handle(event) {
      const route = TREE[event.zone] ?? ["34"];
      plan.set(event.id, { route, day: route.length, carrier: route.length > 2 ? "MT" : "AN" });
    },
    state() {
      return { plan: plan.size, day: [...plan.values()].reduce((t, p) => t + p.day, 0) };
    },
  };
}
```

The second consumer accumulates weight per contract for the volume discount. Accumulation is
inherently additive; below, two designs of the same work stand side by side.

```js
// event/contract.mjs — volume discount consumer: the accumulating and the keyed design for the same work
function volume(total) {
  return { contract: total.size, volume: [...total.values()].reduce((t, a) => t + a, 0) };
}

export function accumulator() {
  const total = new Map();
  return {
    name: "contract-accumulator",
    handle(event) {
      if (event.contractNo === null) throw new TypeError(`volume discount requires a contract: ${event.id}`);
      total.set(event.contractNo, (total.get(event.contractNo) ?? 0) + event.weight);
    },
    state() { return volume(total); },
  };
}

export function keyed() {
  const total = new Map();
  const processed = new Set();
  return {
    name: "contract-keyed",
    handle(event) {
      if (event.contractNo === null) throw new TypeError(`volume discount requires a contract: ${event.id}`);
      if (processed.has(event.eventNo)) return;
      processed.add(event.eventNo);
      total.set(event.contractNo, (total.get(event.contractNo) ?? 0) + event.weight);
    },
    state() { return volume(total); },
  };
}
```

For comparison, the same work is also written as a direct call. Both arrangements produce the
same result; where they diverge is what gets measured.

```js
// direct/fee.mjs — the same work with a direct call: pricing knows the consumers by name
import { net } from "../event/fee.mjs";
import { operationConsumer } from "../event/operation.mjs";
import { accumulator } from "../event/contract.mjs";

export function directPricing(log) {
  const operation = operationConsumer();
  const contract = accumulator();
  return {
    consumer: [operation, contract],
    price(s) {
      const event = {
        id: s.id, weight: s.weight, zone: s.zone,
        contractNo: s.contractNo, net: net(s.weight, s.zone, s.rate),
      };
      log.crossing.push("fee -> operation");
      operation.handle(event);
      log.crossing.push("fee -> contract");
      contract.handle(event);
      return { id: s.id, net: event.net, plan: operation.state().plan };
    },
  };
}
```

#### Five Measurements

The driver script answers five questions in order: how many consumers the publisher knows and
calls by name; how many boundaries the same three shipments cross in the two arrangements, how
many transform points, and how many bytes; how many lines are edited in the publisher file when a
third consumer is added; how many requests are answered when a consumer drops; and how far the two
consumer designs deviate when the same event arrives twice.

```js
// event/measure.mjs — the awareness obligation, boundary measures, failure isolation, and replay deviation
import { readFileSync } from "node:fs";
import { eventBus } from "./bus.mjs";
import { pricing } from "./fee.mjs";
import { operationConsumer } from "./operation.mjs";
import { accumulator, keyed } from "./contract.mjs";
import { directPricing } from "../direct/fee.mjs";

const SHIPMENT = [
  { id: "G1", weight: 4, zone: "35", contractNo: "S7", rate: 0.15 },
  { id: "G2", weight: 1, zone: "34", contractNo: "S7", rate: 0 },
  { id: "G3", weight: 12, zone: "06", contractNo: "S9", rate: 0.25 },
];
const emptyLog = () => ({ crossing: [], transform: 0, bytes: 0, event: [], dropped: [] });
const setup = (log, consumers) => {
  const bus = eventBus(log);
  for (const c of consumers) bus.listen("shipment-priced", c);
  return [bus, pricing(bus)];
};

const IMPORT = /from\s+"[^"]*(operation|contract)\.mjs"/g;
for (const d of ["event/fee.mjs", "direct/fee.mjs"]) {
  const m = readFileSync(d, "utf8");
  console.log(`${d.padEnd(19)} known consumers = ${[...m.matchAll(IMPORT)].length}, called consumers = ${(m.match(/\.handle\(/g) ?? []).length}`);
}

const directLog = emptyLog();
const direct = directPricing(directLog);
const directResults = SHIPMENT.map((s) => direct.price(s));
console.log(`direct      : boundary crossing = ${directLog.crossing.length}, transform point = ${directLog.transform}, bytes = ${directLog.bytes}, return = ${directResults.every((d) => d === undefined) ? "none" : "object"}`);

const eventLog = emptyLog();
const [, eventFee] = setup(eventLog, [operationConsumer(), accumulator()]);
const eventResults = SHIPMENT.map((s) => eventFee.price(s));
console.log(`event driven: boundary crossing = ${eventLog.crossing.length}, transform point = ${eventLog.transform}, bytes = ${eventLog.bytes}, return = ${eventResults.every((d) => d === undefined) ? "none" : "object"}`);
console.log(`  event body = ${eventLog.event[0][1]}`);

const before = readFileSync("event/fee.mjs", "utf8");
const thirdLog = emptyLog();
const zoneCounter = {
  name: "zone-counter", count: new Map(),
  handle(e) { this.count.set(e.zone, (this.count.get(e.zone) ?? 0) + 1); },
};
const [, thirdFee] = setup(thirdLog, [operationConsumer(), accumulator(), zoneCounter]);
for (const s of SHIPMENT) thirdFee.price(s);
const edited = before === readFileSync("event/fee.mjs", "utf8") ? 0 : 1;
console.log(`third consumer: lines edited in publisher = ${edited}, lines added to composition root = 1, zones seen = ${zoneCounter.count.size}`);
console.log(`  with three consumers: boundary crossing = ${thirdLog.crossing.length}, transform point = ${thirdLog.transform}, bytes = ${thirdLog.bytes}`);

const NO_CONTRACT = [SHIPMENT[0], { id: "G2", weight: 1, zone: "34", contractNo: null, rate: 0 }, SHIPMENT[2]];
const directFailLog = emptyLog();
const directFail = directPricing(directFailLog);
let directAnswered = 0;
for (const s of NO_CONTRACT) {
  try { directFail.price(s); directAnswered += 1; } catch (e) { directFailLog.dropped.push(`publisher: ${e.message}`); }
}
console.log(`direct      : requests answered = ${directAnswered}/3, dropped = ${directFailLog.dropped.length} (${directFailLog.dropped.join("; ")})`);
console.log(`  operation = ${JSON.stringify(directFail.consumer[0].state())}, contract = ${JSON.stringify(directFail.consumer[1].state())}`);

const eventFailLog = emptyLog();
const eOp = operationConsumer();
const eContract = accumulator();
const [, eventFail] = setup(eventFailLog, [eOp, eContract]);
let eventAnswered = 0;
for (const s of NO_CONTRACT) {
  try { eventFail.price(s); eventAnswered += 1; } catch (e) { eventFailLog.dropped.push(`publisher: ${e.message}`); }
}
console.log(`event driven: requests answered = ${eventAnswered}/3, dropped = ${eventFailLog.dropped.length} (${eventFailLog.dropped.join("; ")})`);
console.log(`  operation = ${JSON.stringify(eOp.state())}, contract = ${JSON.stringify(eContract.state())}`);

const replayLog = emptyLog();
const rOp = operationConsumer();
const rAccumulator = accumulator();
const rKeyed = keyed();
const [replayBus, replayFee] = setup(replayLog, [rOp, rAccumulator, rKeyed]);
for (const s of SHIPMENT) replayFee.price(s);
const TRACKED = [[rOp, "operation", "day"], [rAccumulator, "contract-accumulator", "volume"], [rKeyed, "contract-keyed", "volume"]];
const before2 = TRACKED.map(([t, , k]) => t.state()[k]);
const [type, text] = replayLog.event[2];
replayBus.publish(type, JSON.parse(text));
console.log(`third event arrived a second time: ${text}`);
TRACKED.forEach(([t, name, k], i) => {
  const after = t.state()[k];
  console.log(`  ${name.padEnd(20)} ${k} before = ${before2[i]}, after = ${after}, deviation = ${after - before2[i]}`);
});

const source = readFileSync("event/contract.mjs", "utf8");
const lineCount = (name) => source.split(`export function ${name}()`)[1].split("\n}")[0].split("\n").filter((s) => s.trim() !== "").length;
console.log(`cost of the replay-resilient design = ${lineCount("keyed") - lineCount("accumulator")} lines`);
```

```sh
node event/measure.mjs
```

```
event/fee.mjs       known consumers = 0, called consumers = 0
direct/fee.mjs      known consumers = 2, called consumers = 2
direct      : boundary crossing = 6, transform point = 0, bytes = 0, return = object
event driven: boundary crossing = 6, transform point = 9, bytes = 470, return = none
  event body = {"eventNo":"E1","id":"G1","weight":4,"zone":"35","contractNo":"S7","net":5100}
third consumer: lines edited in publisher = 0, lines added to composition root = 1, zones seen = 3
  with three consumers: boundary crossing = 9, transform point = 12, bytes = 705
direct      : requests answered = 2/3, dropped = 1 (publisher: volume discount requires a contract: G2)
  operation = {"plan":3,"day":6}, contract = {"contract":2,"volume":16}
event driven: requests answered = 3/3, dropped = 1 (contract-accumulator: volume discount requires a contract: G2)
  operation = {"plan":3,"day":6}, contract = {"contract":2,"volume":16}
third event arrived a second time: {"eventNo":"E3","id":"G3","weight":12,"zone":"06","contractNo":"S9","net":8280}
  operation            day before = 6, after = 6, deviation = 0
  contract-accumulator volume before = 17, after = 29, deviation = 12
  contract-keyed       volume before = 17, after = 17, deviation = 0
cost of the replay-resilient design = 3 lines
```

#### Reading the Numbers

The first two lines turn the awareness obligation into a number. The direct-call pricing file
imports 2 consumer modules and calls 2 by name; in the event-driven arrangement, both are 0. This
shows directly in the cost of extension: adding a third consumer edited 0 lines in the publisher
file and added 1 line to the composition root. The same addition in the direct arrangement needs
an import line, a field line, and a call line in the pricing file.

Boundary crossing is 6 in both arrangements — easy to miss, since the event-driven style does not
reduce the number of boundaries, only what happens at them. Transform point and bytes were both 0
in the direct arrangement; in the event-driven arrangement, transform point is 9 and bytes is 470.
Nine's structure is readable too: 1 serialization per publication and 1 deserialization per
consumer, $3 \times (1 + 2)$ for three shipments. With a third consumer this became
$3 \times (1 + 3) = 12$, and bytes rose from 470 to 705 — exactly $235 \times 3$. The publication
multiplier is linear and grows with the consumer count; the publisher's code stays constant.

The failure lines show the style's sharpest gain. The contract-free shipment threw an error in
the volume discount consumer. In the direct arrangement the error rose up the call stack and
dropped the request: requests answered was 2/3 — even though the operation consumer had *already*
written that shipment's route, so the plan count stayed at 3. Part of the system had done its
work while the request still looked like a failure. In the event-driven arrangement, requests
answered is 3/3; dropped consumers is still 1, but the dropped one's name was recorded and the
pricing response was unaffected. Consumer state came out identical in both arrangements
(`plan 3`, `volume 16`); only whose problem the error is differs.

This distinction has a cost. The `dropped` list showed up only because the bus ran in the same
process; once a consumer moves to its own deployment unit, that list sits somewhere the publisher
cannot reach.

#### What Replay Loads Onto the Consumer

The last measurement is the bill for the third difference. Feeding the same event body to the bus
a second time left the operation consumer's day total at 6 — unchanged, because the route is
written under the shipment identifier and the second write overwrote the first with the same
value. The accumulating consumer's volume rose from 17 to 29, a deviation of 12, inflating the
contracted customer's volume discount; the defect is not in the bus but in the consumer's design.

The keyed design held at 17 under the same replay: the `eventNo` field in the contract lets the
consumer track which identifiers it has already processed. Its cost was measured at 3 lines — the
real obligation the style loads onto the consumer, since the publisher cannot guarantee the
arrival count and every accumulating consumer has to write its own replay resilience. These three
lines reappear in every accumulator as the consumer count grows.

#### Where the Style Belongs

The numbers describe a quality attribute trade-off. Maintainability and reliability are gained:
lines edited in the publisher for a new consumer is 0, and requests answered when a consumer
drops is 3/3. End-to-end certainty is given up: the publisher publishes a 470-byte contract, gets
no return value, and cannot know at response time that the work was done.

This is why the style fits exchanges whose work need not finish at response time. Fee calculation
has to finish while a shipment is recorded; route planning and the volume discount do not. Turning
fee calculation into an event too would move the measurement's 2/3 line onto the pricing
response — which is why the choice is made per exchange, not for the system as a whole.

#### Summary

- In event-driven architecture, a unit declares that something has happened; the publisher file
  measured 0 known consumers and 0 called by name, against 2 and 2 in the direct-call arrangement.
- When an event crosses the system boundary, three things change at once: the body serializes
  into a published contract, the publisher gets no return value, and the arrival count cannot be
  assumed to be 1.
- Boundary crossing stayed at 6 in both arrangements; what changes is what happens there —
  transform point rose from 0 to 9 and bytes from 0 to 470, becoming 12 and 705 with a third
  consumer.
- The third consumer was added with 0 lines edited in the publisher file; the cost is the 1 line
  in the composition root and the linearly growing publication multiplier.
- When a consumer threw an error, requests answered dropped to 2/3 in the direct arrangement and
  left work half done; the event-driven arrangement stayed at 3/3, recording the dropped
  consumer by name.
- When the same event arrived twice, the identifier-keyed operation consumer's deviation was 0
  and the accumulating consumer's deviation was 12; the keyed design costs 3 lines.

#### Next Step

The three arrangements in this lesson share an implicit assumption: a process exists for pricing,
operation, and the bus, standing before any request arrives. In the microservice lesson, three
processes were started and stopped by hand; here, the bus also has to run somewhere. The next
style removes this assumption: the code reduces to a function called when an event or a request
arrives, and keeping a process standing moves outside the software. The next lesson counts what
that arrangement measures: the server lines the application itself carries, the number of
functions a request touches, where state that cannot be shared between functions goes, and how
shifting composition into configuration affects the number of files edited.

### Serverless Architecture

Source: https://academia.sh/en/courses/architectural-styles/serverless-architecture

The runtime responsibility leaving the application: counting the run lines left in the function files, measuring the number of files edited when composition moves into the manifest, measuring the result deviation module state produces on a cold run and the cost of moving state outside, and counting cold start by setup count while leaving its duration to a model.

The four styles so far shared an implicit assumption: a process standing before any request
arrives. One process in the monolithic arrangement, three started and stopped by hand in the
microservice arrangement; in the event-driven arrangement, the bus also had to run somewhere.
Keeping a process standing, listening, and routing a request to the right unit was code the
application carried.

**Serverless architecture** takes this code out of the application. The deployment unit is not a
process but a function called when an event or a request arrives; opening, holding, replicating,
and closing the process is the runtime's job. The name does not describe the absence of a
server — a server exists, but it is not the application's responsibility. This lesson counts
both sides of the shift: the run lines that leave the application, and the assumptions that turn
into constraints in exchange.

#### A Principle That Turns Into a Constraint

The Server-Side Fundamentals course's Twelve-Factor App Principles lesson counted keeping
processes stateless and share-nothing as a principle; the same course's The Application Runtime
lesson measured the process as the scaling unit. In the serverless style, **stateless process**
stops being a choice: the runtime can open and close a function's instance whenever it wants, so
two calls cannot be guaranteed to see the same memory region. Violating the principle here is not
a style flaw but a measurable result deviation.

The second constraint concerns composition. A function does not bind itself to a route, import
its neighbor, or know the call order. Composition moves outside the functions, into a
**manifest** the runtime reads.

#### Three Functions and One Manifest

To count how many times setup work runs, the function modules leave a line on a shared trace,
which exists for the measurement and has nothing to do with the function's actual work.

```js
// serverless/counter.mjs — setup trace: the first load of each function module leaves a mark here
export const setup = [];
```

The pricing context's price function carries a single export, with no server, listening,
route-resolution, or port code.

```js
// serverless/fee.mjs — pricing function: no server, listening, or routing code; only a handler is exported
import { setup } from "./counter.mjs";

const TARIFF = { tier: [[1, 3000], [5, 4800], [20, 9600]], zone: { "34": 100, "06": 115, "35": 125 }, minimum: 2500 };
setup.push("fee");                         // runs once when the module loads: setup work

export function handle(event) {
  const tier = TARIFF.tier.find(([k]) => event.weight <= k) ?? [0, 9600];
  const base = Math.max(TARIFF.minimum, Math.round((tier[1] * TARIFF.zone[event.zone]) / 100));
  return { id: event.id, net: base - Math.round(base * Math.min(event.rate, 0.4)) };
}
```

The delivery operation's plan function has the same shape; the only evidence that it is a
separate deployment unit is that it is a separate file.

```js
// serverless/operation.mjs — plan function: a separate deployment unit, the same call shape
import { setup } from "./counter.mjs";

const TREE = { "34": ["34"], "06": ["34", "06"], "35": ["34", "41", "35"] };
setup.push("plan");

export function handle(event) {
  const route = TREE[event.zone] ?? ["34"];
  return { route, day: route.length, carrier: route.length > 2 ? "MT" : "AN" };
}
```

The third function accumulates weight per contract for the volume discount. Accumulation
inherently remembers something between calls; below, two designs of the same work stand side by
side — one keeps the total at module level, the other writes it to a repository the runtime
provides.

```js
// serverless/volume.mjs — volume discount function: the module-state and repository-state design for the same work
import { setup } from "./counter.mjs";

const total = new Map();                   // MODULE STATE: empties out when the module reloads
setup.push("volume");

export function handle(event) {
  total.set(event.contractNo, (total.get(event.contractNo) ?? 0) + event.weight);
  return { contract: total.size, volume: [...total.values()].reduce((t, a) => t + a, 0) };
}

export function handleStored(event, repository) {
  const key = `volume:${event.contractNo}`;
  repository.write(key, (repository.read(key) ?? 0) + event.weight);
  const list = repository.keys();
  return { contract: list.length, volume: list.reduce((t, k) => t + repository.read(k), 0) };
}
```

The manifest consists of two mappings: which name corresponds to which file's export, and which
route runs which sequence of functions. This file holds no field rule at all — only composition.

```js
// serverless/route.mjs — manifest: which name maps to which file's export, which route runs which sequence
export const manifest = {
  functions: {
    fee: "./fee.mjs#handle",
    plan: "./operation.mjs#handle",
    volume: "./volume.mjs#handle",
    "volume-stored": "./volume.mjs#handleStored",
  },
  routes: {
    "/offer": ["fee", "plan"],
    "/record": ["fee", "volume"],
    "/record-stored": ["fee", "volume-stored"],
  },
};
```

#### The Runtime's Local Stop

In this lesson, the runtime is modeled with a local stop. Its work has three steps: finding the
function's location in the manifest, loading the module, and serializing and parsing the body to
hand it to the function. **Container** is the instance a function runs inside, opened and closed
by the runtime. The `cold` option mimics the container decision: false sets the module up once
for the container's lifetime, true sets it up again on every call. The query suffix forces the
reload; in a real runtime, the counterpart is opening a new container.

```js
// serverless/runtime.mjs — the function runtime's local stop: reads the manifest, calls the functions
let sequence = 0;                          // load number that increases through the process's lifetime

export function runtime(manifest, log, { cold = false } = {}) {
  const container = ++sequence;            // this instance is a container: the module is set up once on a warm run
  const memory = new Map();
  const raw = new Map();
  const repository = {
    read(k) { log.repository += 1; return raw.get(k); },
    write(k, v) { log.repository += 1; raw.set(k, v); },
    keys() { log.repository += 1; return [...raw.keys()]; },
  };
  return {
    async call(routeName, event) {
      const result = {};
      for (const name of manifest.routes[routeName]) {
        const [file, exportName] = manifest.functions[name].split("#");
        if (cold || memory.has(name) === false) {
          memory.set(name, await import(`${file}?k=${cold ? ++sequence : container}`));
        }
        const text = JSON.stringify(event);
        log.transform += 2;
        log.bytes += text.length;
        log.crossing.push(`${routeName} -> ${name}`);
        result[name] = memory.get(name)[exportName](JSON.parse(text), repository);
      }
      return result;
    },
  };
}
```

There is no aggregator function. The runtime builds the response object itself: each name in the
route's sequence contributes its result under its own name.

#### Five Measurements

The driver script answers five questions: how many run lines remain in the function files; how
many functions a request touches and how many bytes cross the boundary; what result the same
three shipments produce warm and cold; the line and access cost of moving state to a repository;
and how many function files adding a function to the composition edits.

```js
// serverless/measure.mjs — server lines, functions touched, setup count, state deviation, manifest cost
import { readFileSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { setup } from "./counter.mjs";
import { runtime } from "./runtime.mjs";
import { manifest } from "./route.mjs";

const SHIPMENT = [
  { id: "G1", weight: 4, zone: "35", contractNo: "S7", rate: 0.15 },
  { id: "G2", weight: 1, zone: "34", contractNo: "S7", rate: 0 },
  { id: "G3", weight: 12, zone: "06", contractNo: "S9", rate: 0.25 },
];
const FUNCTION = ["serverless/fee.mjs", "serverless/operation.mjs", "serverless/volume.mjs"];
const SERVER = /createServer|\.listen\(|writeHead|response\.end|process\.argv/;
const CALL = /await import|manifest\.|JSON\.(stringify|parse)/;
const digest = (d) => createHash("sha256").update(readFileSync(d)).digest("hex").slice(0, 12);

for (const d of [...FUNCTION, "serverless/runtime.mjs"]) {
  const s = readFileSync(d, "utf8").split("\n").filter((x) => x.trim() !== "");
  const count = (r) => s.filter((x) => r.test(x)).length;
  console.log(`${d.padEnd(26)} lines = ${String(s.length).padStart(2)}, server = ${count(SERVER)}, call = ${count(CALL)}`);
}

async function run(map, routeName, shipments, cold) {
  const k = { crossing: [], transform: 0, bytes: 0, repository: 0 };
  const before = setup.length;
  const c = runtime(map, k, { cold });
  let result;
  for (const s of shipments) result = await c.call(routeName, s);
  return { k, setup: setup.length - before, result };
}

const format = (name, r) => `${name.padEnd(20)} boundary crossing = ${r.k.crossing.length}, transform = ${r.k.transform}, bytes = ${r.k.bytes}, setup = ${r.setup}, repository access = ${r.k.repository}`;

const t = await run(manifest, "/offer", [SHIPMENT[0]], false);
console.log(format("/offer warm", t));
console.log(`  result = ${JSON.stringify(t.result)}`);

const warm = await run(manifest, "/record", SHIPMENT, false);
const cold = await run(manifest, "/record", SHIPMENT, true);
const stored = await run(manifest, "/record-stored", SHIPMENT, true);
for (const [name, r, key] of [["/record warm", warm, "volume"], ["/record cold", cold, "volume"], ["/record-stored cold", stored, "volume-stored"]]) {
  console.log(`${format(name, r)}\n  ${key} = ${JSON.stringify(r.result[key])}`);
}
console.log(`module state deviation = ${warm.result.volume.volume - cold.result.volume.volume} weight units, ${warm.result.volume.contract - cold.result.volume.contract} contracts`);
const source = readFileSync("serverless/volume.mjs", "utf8");
const lineCount = (name) => source.split(`export function ${name}(`)[1].split("\n}")[0].split("\n").filter((s) => s.trim() !== "").length;
console.log(`cost of moving state outside = ${lineCount("handleStored") - lineCount("handle")} lines`);

const before2 = FUNCTION.map(digest);
const old = readFileSync("serverless/route.mjs", "utf8");
writeFileSync("serverless/route.mjs", old.replace('"plan"]', '"plan", "volume"]'));
const updated = readFileSync("serverless/route.mjs", "utf8").split("\n");
const changed = old.split("\n").filter((s, i) => s !== updated[i]).length;
const { manifest: wider } = await import("./route.mjs?s=2");
const g = await run(wider, "/offer", [SHIPMENT[0]], false);
writeFileSync("serverless/route.mjs", old);
console.log(`volume added to the manifest: function files edited = ${FUNCTION.filter((d, i) => digest(d) !== before2[i]).length}, lines changed in manifest = ${changed}`);
console.log(format("/offer redone", g));
console.log(`  crossing = ${g.k.crossing.join(", ")}`);
```

```sh
node serverless/measure.mjs
```

```
serverless/fee.mjs         lines =  9, server = 0, call = 0
serverless/operation.mjs   lines =  8, server = 0, call = 0
serverless/volume.mjs      lines = 14, server = 0, call = 0
serverless/runtime.mjs     lines = 29, server = 0, call = 5
/offer warm          boundary crossing = 2, transform = 4, bytes = 128, setup = 2, repository access = 0
  result = {"fee":{"id":"G1","net":5100},"plan":{"route":["34","41","35"],"day":3,"carrier":"MT"}}
/record warm         boundary crossing = 6, transform = 12, bytes = 380, setup = 2, repository access = 0
  volume = {"contract":2,"volume":17}
/record cold         boundary crossing = 6, transform = 12, bytes = 380, setup = 6, repository access = 0
  volume = {"contract":1,"volume":12}
/record-stored cold  boundary crossing = 6, transform = 12, bytes = 380, setup = 6, repository access = 13
  volume-stored = {"contract":2,"volume":17}
module state deviation = 5 weight units, 1 contracts
cost of moving state outside = 2 lines
volume added to the manifest: function files edited = 0, lines changed in manifest = 1
/offer redone        boundary crossing = 3, transform = 6, bytes = 192, setup = 3, repository access = 0
  crossing = /offer -> fee, /offer -> plan, /offer -> volume
```

#### Reading the Numbers

The first four lines count the code that leaves the application. None of the three function files
has any run lines: lines containing `createServer`, `listen`, `writeHead`, or `process.argv`
number 0. The same scan found 6 per deployment unit in the microservice lesson, 18 total across
three units. Those lines were not deleted, they moved: the runtime's local stop is 29 lines, and
the call scan finds 5 in it — the lines deciding which function runs and serializing and parsing
the body. In a real runtime these 29 lines never live in the application's repository; they exist
here only so the measurement can run with a single command.

The second gain is the absence of an aggregator. In the microservice arrangement, a third
deployment unit served the offer request: its own server, two services known by name, and a
hand-aggregated response. Here, the `/offer` route's counterpart is a single line in the manifest,
and the runtime builds the result object. For a single request, boundary crossing is 2, transform
point is 4, bytes crossing the boundary is 128 — 64 per shipment, twice across two crossings.

The last two lines give the cost of moving composition into the manifest. Making the offer request
also trigger the volume discount changed 1 line in the manifest and left the function files'
digests unchanged: function files edited is 0. In the event-driven lesson, the same addition cost
1 line of code in the composition root; here, even that line left the code. The counterpart sits
in the same line: boundary crossing rose from 2 to 3, transform from 4 to 6, bytes from 128 to
192. A request's cost is now read in the manifest, not the code, and no resolver warns when the
manifest is written incorrectly.

#### Where State Goes

The three `/record` runs fed the same three shipments in the same order; all three produced
boundary crossing 6, transform 12, and bytes 380 — no difference on the runtime's side. Only the
result diverges. The warm run's volume discount function reported `{"contract":2,"volume":17}`;
the cold run's reported `{"contract":1,"volume":12}`, a deviation of 5 weight units and 1
contract. The source is `volume.mjs`'s module-level `total` map: set up again on every call, it
emptied out each time, so only the last shipment was counted.

This is the style's quietest defect. The code is correct, the result is wrong, and there is no
error message. Because the same code produces the correct result on a warm run, the defect is
visible or invisible depending on the runtime's container decision — a test running in a warm
container never sees it at all.

The fix is to take the state out of the function. `handleStored` writes the same total to the
repository the runtime provides and returns `{"contract":2,"volume":17}` on the cold run —
deviation 0. Its cost: the body grew by 2 lines, and three requests produced 13 repository
accesses, against 0 in the module-state design. Here the repository is a local map; once it
becomes a separate deployment unit, each of those 13 accesses is a boundary crossing, added to the
network calls counted in the microservice lesson.

#### Cold Start Is a Model

The setup count is a real number: on the warm run, three requests did 2 setups; on the cold run,
6, each of the two functions set up again on every request. The setup's *duration*, however, was
not measured — a module's load time depends on the machine, the file system, and the runtime's own
readiness, so the account below is a model, not a measurement.

A call's duration splits into two parts: setup work $B$ and the actual work $I$. If a fraction $c$
of calls starts on a new instance, the average duration is

$$\bar{T} = I + c \cdot B$$

The model says two things. First, $c$ results from the traffic's shape, not the application's:
heavy, continuous traffic reuses the container, and sparse, irregular traffic pushes $c$ toward
1 — the measurement's cold run is the counterpart of $c = 1$. Second, the only way to shrink $B$
is to shrink the setup work itself. `fee.mjs` sets up only a tariff object at module level; a
large table or a connection pool set up in the same place would grow $B$. This is the limit the
style places on how much setup work a function can carry.

The numbers describe a quality attribute trade-off. The quality gained is maintainability: run
lines carried by the application is 0, and function files edited for a new composition is 0. Two
qualities are given up. Performance efficiency loses predictability: the same request does 2 or 6
setups depending on a container decision the application does not make. Reliability loses ground
because every value accumulated in memory is a candidate for the kind of 5-unit deviation measured
here. This is why the style fits work that is independent per call; accumulating work qualifies
only by moving state outside, paying 13 accesses for it.

#### Summary

- In serverless architecture, the deployment unit is not a process but a function that runs when
  called; keeping a process standing and routing it moves outside the application.
- Run lines measured 0 in the three function files, against 6 per unit and 18 total in the
  microservice lesson's scan. Those lines' local counterpart is the 29-line runtime stop.
- There is no aggregator function: the `/offer` route's counterpart is a single manifest line,
  and the runtime builds the result object; a single request produced 2 boundary crossings, 4
  transform points, and 128 bytes.
- Adding a function to the composition edited 0 function files and changed 1 manifest line; the
  same request rose to 3 boundary crossings, 6 transform points, and 192 bytes.
- State accumulated at module level deviated by 5 weight units and 1 contract on a cold run;
  moving state to a repository brought the deviation to 0, at a cost of 2 lines and 13 repository
  accesses.
- Cold start's counted face is the setup count: three requests did 2 setups warm and 6 cold.
  Duration is told through a model, not measured.

#### Next Step

The five styles left five separate sets of measures: files published together, boundary crossing,
bytes crossing the boundary, units still standing when one unit stops, and files edited for a new
capability. No style led on all of these measures; every gain grew another number. How, then, is a
choice made, and what does its defense rest on. The next lesson ties the measures to quality
attributes, measures the same rules on a common footing across two boundary arrangements, and
shows under which threshold a style decision holds up and which threshold flips it; the numbers
measured across the course are gathered there in a single decision table.

### Style Selection and Trade-off Analysis

Source: https://academia.sh/en/courses/architectural-styles/style-selection-and-trade-off-analysis

Turning a style decision into a measurable defense: measuring the same three rules on a common footing across two boundary arrangements, computing pass/fail against the threshold of three quality scenarios, showing that no arrangement passes all three thresholds at once, and finding the threshold that flips the decision.

The five deployment arrangements left five separate sets of numbers: files published together,
boundary crossing, bytes crossing the boundary, units left standing when one unit stops, and
files edited for a new capability. No arrangement led on all of these measures; every gain grew
another number. This lesson takes up how a choice gets made in that situation, and what its
defense rests on.

Quality attribute and quality scenario were established in the Quality and Testing Fundamentals
course's Quality Attributes lesson. They are not defined here; they are used as decision tools.

#### The Three Parts That Carry a Decision

A style defense has three parts. First, the **scenario**: which quality, under which stimulus, in
which environment. Second, the **threshold**: the accepted value on that quality's response
measure. Third, the **measured value**: the number the arrangement actually gets. Missing any one
of the three leaves a preference, not a defense. "Microservices are more flexible" is missing all
three: which change, which threshold, and which measure stays unclear.

Trade-off comes from the same shape: an arrangement grows another measure's value to pass a
threshold, and the trade-off is these two numbers standing side by side in the same decision. A
measurement comparing two arrangements therefore produces not a single number but a vector.

#### Common Footing

Comparison requires a common footing: the same rules, the same input, the same counting
procedure — otherwise the measured difference comes from the two implementations' detail, not the
style. This is why the three rules sit in a single file that both arrangements use; only the
boundary placement changes.

```js
// decision/rule.mjs — three rules: both arrangements use the same rules, only the boundary placement changes
const TARIFF = { tier: [[1, 3000], [5, 4800], [20, 9600]], zone: { "34": 100, "06": 115, "35": 125 }, minimum: 2500 };
const TREE = { "34": ["34"], "06": ["34", "06"], "35": ["34", "41", "35"] };

export const RULE = {
  fee(s) {
    const tier = TARIFF.tier.find(([k]) => s.weight <= k) ?? [0, 9600];
    const base = Math.max(TARIFF.minimum, Math.round((tier[1] * TARIFF.zone[s.zone]) / 100));
    return { net: base - Math.round(base * Math.min(s.rate, 0.4)) };
  },
  plan(s) {
    const route = TREE[s.zone] ?? ["34"];
    return { day: route.length, carrier: route.length > 2 ? "MT" : "AN" };
  },
  volume(s, state) {
    state.set(s.contractNo, (state.get(s.contractNo) ?? 0) + s.weight);
    return { volume: [...state.values()].reduce((t, a) => t + a, 0) };
  },
};
```

An arrangement is a list saying which rule stands in which deployment unit. In the one-unit
arrangement, all three rules share a unit with no boundary between them; in the three-unit
arrangement, each rule has its own unit and the body serializes at every boundary. The runner
measures both with the same counters and carries the option to stop a unit.

```js
// decision/arrangement.mjs — runs the same three rules in two arrangements: one deployment unit and three separate units
import { RULE } from "./rule.mjs";

export const ARRANGEMENT = {
  "one unit": [["fee", "plan", "volume"]],
  "three units": [["fee"], ["plan"], ["volume"]],
};

export function run(units, shipments, down = null) {
  const measure = { crossing: 0, transform: 0, bytes: 0, answered: 0, standing: 0, last: {} };
  const state = new Map();
  for (const s of shipments) {
    const result = {};
    for (const unit of units) {
      if (unit.includes(down)) continue;
      const text = units.length === 1 ? null : JSON.stringify(s);   // the body serializes if there is a boundary
      if (text !== null) measure.transform += 2, measure.bytes += text.length;
      measure.crossing += 1;
      for (const name of unit) Object.assign(result, RULE[name](text === null ? s : JSON.parse(text), state));
    }
    if ("net" in result) measure.answered += 1;                     // a request is unanswered without a fee
    measure.last = result;
  }
  measure.standing = units.filter((b) => b.includes(down) === false).length;
  return measure;
}
```

#### Three Scenarios and a Threshold

The three scenarios come from three separate quality families, each reduced to a single response
measure. Maintainability asks how many rules must be published together when the tariff rule
changes, and accepts at most 1. Performance efficiency asks how many bytes cross the boundary for
three shipments, and accepts at most 100. Reliability asks how many requests are answered while
the volume unit is down, and requires at least 3. The script applies the three thresholds to both
arrangements, then moves each threshold to the arrangement's measured value to find where the
decision flips.

```js
// decision/measure.mjs — measures the two arrangements against the threshold of three quality scenarios and searches for the threshold that flips the decision
import { ARRANGEMENT, run } from "./arrangement.mjs";

const SHIPMENT = [
  { id: "G1", weight: 4, zone: "35", contractNo: "S7", rate: 0.15 },
  { id: "G2", weight: 1, zone: "34", contractNo: "S7", rate: 0 },
  { id: "G3", weight: 12, zone: "06", contractNo: "S9", rate: 0.25 },
];
const SCENARIO = [
  { name: "S1 maintainability", metric: "rules published together", threshold: 1, atMost: true },
  { name: "S2 performance efficiency", metric: "bytes crossing the boundary", threshold: 100, atMost: true },
  { name: "S3 reliability", metric: "requests answered", threshold: 3, atMost: false },
];
const FIELD = ["deployment unit", "rules published together", "boundary crossing", "transform point",
  "bytes crossing the boundary", "requests answered", "units left standing"];

const measurement = Object.entries(ARRANGEMENT).map(([name, units]) => {
  const full = run(units, SHIPMENT);
  const degraded = run(units, SHIPMENT, "volume");
  return {
    name, last: full.last,
    "deployment unit": units.length,
    "rules published together": units.find((b) => b.includes("fee")).length,
    "boundary crossing": full.crossing,
    "transform point": full.transform,
    "bytes crossing the boundary": full.bytes,
    "requests answered": degraded.answered,
    "units left standing": `${degraded.standing}/${units.length}`,
  };
});

console.log(`${"measure".padEnd(29)}${measurement.map((o) => o.name.padStart(12)).join("")}`);
for (const a of FIELD) console.log(`${a.padEnd(29)}${measurement.map((o) => String(o[a]).padStart(12)).join("")}`);
for (const o of measurement) console.log(`${o.name.padEnd(12)} last shipment's result = ${JSON.stringify(o.last)}`);

const passes = (o, s) => (s.atMost ? o[s.metric] <= s.threshold : o[s.metric] >= s.threshold);
for (const s of SCENARIO) {
  const reading = measurement.map((o) => `${o.name} = ${o[s.metric]} ${passes(o, s) ? "passes" : "fails"}`);
  console.log(`${s.name}: ${s.metric} ${s.atMost ? "<=" : ">="} ${s.threshold} -> ${reading.join(", ")}`);
}
for (const o of measurement) console.log(`${o.name.padEnd(12)} scenarios passed = ${SCENARIO.filter((s) => passes(o, s)).length}/3`);
console.log(`arrangements passing all three = ${measurement.filter((o) => SCENARIO.every((s) => passes(o, s))).length}`);

for (const s of SCENARIO) {
  for (const o of measurement.filter((x) => passes(x, s) === false)) {
    console.log(`${s.name}: unless the threshold is ${o[s.metric]} instead of ${s.threshold}, "${o.name}" does not pass`);
  }
}
for (const s of SCENARIO) {
  const remaining = SCENARIO.filter((x) => x !== s);
  const passing = measurement.filter((o) => remaining.every((x) => passes(o, x))).map((o) => o.name);
  console.log(`if ${s.name} is dropped, arrangements passing the remaining two = ${passing.join(", ") || "none"}`);
}
```

```sh
node decision/measure.mjs
```

```
measure                          one unit three units
deployment unit                         1           3
rules published together                3           1
boundary crossing                       3           9
transform point                         0          18
bytes crossing the boundary             0         570
requests answered                       0           3
units left standing                   0/1         2/3
one unit     last shipment's result = {"net":8280,"day":2,"carrier":"AN","volume":17}
three units  last shipment's result = {"net":8280,"day":2,"carrier":"AN","volume":17}
S1 maintainability: rules published together <= 1 -> one unit = 3 fails, three units = 1 passes
S2 performance efficiency: bytes crossing the boundary <= 100 -> one unit = 0 passes, three units = 570 fails
S3 reliability: requests answered >= 3 -> one unit = 0 fails, three units = 3 passes
one unit     scenarios passed = 1/3
three units  scenarios passed = 2/3
arrangements passing all three = 0
S1 maintainability: unless the threshold is 3 instead of 1, "one unit" does not pass
S2 performance efficiency: unless the threshold is 570 instead of 100, "three units" does not pass
S3 reliability: unless the threshold is 0 instead of 3, "one unit" does not pass
if S1 maintainability is dropped, arrangements passing the remaining two = none
if S2 performance efficiency is dropped, arrangements passing the remaining two = three units
if S3 reliability is dropped, arrangements passing the remaining two = none
```

#### Reading the Numbers

The two arrangements' result for the last shipment is identical:
`{"net":8280,"day":2,"carrier":"AN","volume":17}`. This line says what the decision is *not*
about: correctness is the same in both, so the style choice rests on the numbers around it, not
on functional expectation.

The rest of the vector separates the two arrangements in opposite directions. In the one-unit
arrangement, boundary crossing is 3, transform point is 0, bytes is 0 — the three rules are
called in the same memory region, so there is no boundary cost. In exchange, rules published
together when the tariff changes is 3, and requests answered when volume is down drops to 0/3,
units left standing to 0/1: sharing a unit means one stopping stops all. In the three-unit
arrangement the first three numbers grow — crossing 9, transform 18, bytes 570, roughly 63 bytes
carried nine times per shipment — but rules published together falls to 1, requests answered
rises to 3/3.

These two lines relate to what the first and third lessons measured; new are the three lines
beneath them. Against the three thresholds, the one-unit arrangement passes 1/3, the three-unit
arrangement 2/3, and arrangements passing all three is 0 — not a measurement gap but the shape of
the answer: a contradictory threshold set satisfies no arrangement, and the decision turns into
ranking the thresholds.

Which threshold flips the ranking was also computed. S2 is the one: only dropping it produces an
arrangement passing the remaining two — the three-unit arrangement. Dropping S1 or S3 yields
nothing, because both are measures the one-unit arrangement fails. The same computation gives how
far the thresholds would have to move: the three-unit arrangement does not pass unless S2 rises
from 100 to 570, and the one-unit arrangement does not pass unless S1 falls from 1 to 3 and S3
from 3 to 0. A defense reads: "the three-unit arrangement was chosen because the S1 and S3
thresholds were kept and S2 was relaxed to 570 bytes."

Last, the unmeasured axis. The script knows only three measures; asked about a fourth quality, it
finds no number. An unmeasured axis carries no decision — it has no weight in the argument,
because which arrangement passes cannot be computed. This is why every lesson of the course
defined a measure.

#### Summary

- A style defense has three parts: quality scenario, threshold, and measured value; missing one
  leaves a preference, not a defense.
- Comparison requires a common footing: both arrangements used the same three rules, three
  shipments, and counters, and produced an identical result for the last shipment.
- The one-unit arrangement kept boundary cost at zero (crossing 3, transform 0, bytes 0) but left
  rules published together at 3 and requests answered when volume is down at 0/3.
- The three-unit arrangement did the same work with 9 crossings, 18 transforms, and 570 bytes; in
  exchange, rules published together is 1, requests answered 3/3, units left standing 2/3.
- Arrangements passing all three thresholds came out 0; not a measurement gap but the result of a
  contradictory threshold set, which turns the decision into ranking the thresholds.
- The threshold that flips the decision was computed: only dropping S2 lets the three-unit
  arrangement pass the remaining two; it does not pass unless S2 rises from 100 to 570.

#### Course Wrap-Up

The course took up the styles spread across three topics with a single question: when the same
shipment pricing and routing library is set up in that arrangement, which number shrinks and
which grows. The measures the lessons left behind are gathered in the decision table below.

| Lesson | Quality improved | Measured gain | Cost paid |
|---|---|---|---|
| Layered Architecture | maintainability | brought the open-listing read request from 3 layers to 2, and from 2 boundaries to 1 | the same request became satisfiable from two separate places |
| Hexagonal and Onion Architecture | maintainability | after the fix, ring edges dropped from 5 to 3 and both rules' violations to 0 | each rule is blind to the defect the other sees: hexagonal forbids 4 edges, onion forbids 6 |
| Clean Architecture | maintainability | passing the output model through dropped accessible names from 10 to 4 and JSON from 170 to 76 characters | a separate model type at the boundary and one transform point |
| Component-Based Architecture | maintainability | splitting by capability brought exported names from 8 to 6 and the co-release ratio from 0.39 to 0.27 | the single consumer that skipped the surfaces raised exported names from 6 to 10 |
| Microkernel and Plugin Architecture | maintainability | the kernel carries 0 plugin names instead of 4, and the import closure dropped from 5 files to 1 | the names moved to the composition root, whose closure is 6 files; the contract is checked at run time |
| Blackboard Architecture | extensibility | in seven of nine files, other unit names are 0; the fifth resolver added 1 file | the same result cost 3 rounds, 5 polls, and 7 reads; the first-writer-wins rule produced 3 distinct outcomes across 24 orderings |
| Client–Server | maintainability | the delivery side's known modules dropped from 3 to 1, names from 4 to 1 | the error moved from import time to run time; learning three changes took 12 questions, 9 of them blank |
| Peer-to-Peer | reliability | the critical unit is 1 in the star, 0 in the ring and full mesh | known neighbors rose from 10 to 30; the ring took 18 messages and 3 rounds |
| Model–View Families | maintainability | the view's known model names dropped from 5 to 0, the import closure from 2 to 1 | bytes crossing the boundary rose from 67 to 111, files changed from 1 to 2 |
| Pipes and Filters | testability | six of six steps ran standalone, the new step's file recognizes 0 field names | 17 of the 28 fields crossing the filter inputs passed through unread |
| Publish–Subscribe | maintainability | the publisher's known modules dropped from 3 to 1, the import closure from 4 to 2 files | "who handles this event" scanned 7 modules instead of 1 file |
| Message Queues and Streams | performance efficiency | the publisher spent 0 of 60 work units of its own time | the finish order broke in 3 places with a second consumer; 8 messages were delivered 9 times |
| Monolithic Architecture | performance efficiency | boundary crossing 4, transform point 0, bytes crossing the boundary 0 | a 1-file change republished 4 files; units left standing under the defect was 0/2 |
| Service-Oriented Architecture | compatibility | body formats to learn dropped to 1 | canonical field 10, request body 417 bytes; 3 files when `zone` changes |
| Microservice Architecture | reliability | files republished 1, 2 of 3 endpoints kept returning 200 when pricing stopped | 18 lines of server repetition, 4 outcome states, 3 contracts to learn |
| Event-Driven Architecture | maintainability | the third consumer edited 0 publisher lines; requests answered stayed 3/3 on a dropped consumer | transform point rose from 0 to 9, bytes from 0 to 470; the accumulator deviated by 12 |
| Serverless Architecture | maintainability | run lines 0, function files edited for a new composition 0 | setup rose from 2 to 6 on a cold run; module-state deviation 5, repository 13 accesses |

The table has a single reading: the right column is empty in no row. Every style improved one
quality in a measurable way and, in exchange, grew another number. A decision whose right column
looks empty is an unmeasured decision.

This course is the last of the Software Design and Architectural Principles curriculum's six
courses, and all six are ordered by growing scale. Clean Code measured lines and names;
Programming Paradigms measured how the same work is written across computation styles; Design
Principles measured the responsibility boundary of the module and the class; Design Patterns
measured the recurring arrangements of cooperating object sets; Domain-Driven Design measured an
application's domain model and context boundaries; this course measured the application itself
splitting into deployment units. At each step the unit of measure changed: line count to name
count, name count to import closure, import closure to boundary crossing, boundary crossing to
files published together and units left standing.

One more step remains, and there the question departs from this course's. This course compared
styles as a *design decision*: arrangements of the same library, boundaries modeled on a single
machine. The next curriculum, System Design and Distributed Systems, takes up the same units
under a real system's constraints — scale, latency, consistency, failure behavior. The 570 bytes
here become a network budget there, the 3/3 response ratio an availability target, and the state
the volume rule holds a consistency model. The style choice is still defended with the same three
parts; what changes is where the thresholds come from.

