Skip to content
academia.sh

Lesson 02 / 18

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.

Contents

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.

mkdir -p rings/domain-model rings/domain-services rings/application-services rings/outer
// rings/domain-model/tier.mjs — ring 0: weight tier value
export const tier = (capWeight, fee) => ({ capWeight, fee: Math.round(fee) });
// 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),
};
// 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);
  },
};
// 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}`));
// 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 };
    },
  };
};
// 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"],
};
// 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 };
// 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×4=164 \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.

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

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.

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

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close