---
title: 'Hexagonal and Onion Architecture'
source: 'https://academia.sh/en/courses/architectural-styles/hexagonal-and-onion-architecture'
course: 'Architectural Styles'
language: en
updated: '2026-08-23T07:01:10+00:00'
license: 'CC BY-SA 4.0'
---

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