Skip to content
academia.sh

Lesson 03 / 18

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.

Contents

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.

mkdir -p clean/entities clean/use-cases clean/adapters clean/infrastructure
// 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),
});
// 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.

// 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];
  });
// 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,
      };
    },
  };
};
// 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" };
// 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;
};
// 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,
};
// 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.

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

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.

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

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

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close