---
title: 'Dependency and Layer Control'
source: 'https://academia.sh/en/courses/architecture-governance/dependency-and-layer-control'
course: 'Quality Attributes and Governance'
language: en
updated: '2026-08-23T07:01:05+00:00'
license: 'CC BY-SA 4.0'
---

# Dependency and Layer Control

Testing structural architectural rules against a real import graph: writing the same layer rule in allowlist and denylist form, comparing the caught, missed, and false alarm counts of five rule forms against a known violation set, the aging of sanctioned exceptions, and counting how many places a rule updates when a new module is added.

The previous lesson's fitness function looked at a single module's file. A portion of
architectural rules, though, never shows up in a single file: which module may import which module
can only be checked by reading the entire graph. **Layer check** and **dependency check** are the
names for this kind of rule, and both read the same data — the codebase's import graph.

This graph itself was measured before: the Service Boundaries and Communication course counted
boundary violations from the same kind of import graph. The question here is not that count. **The
question here is the rule itself.** The same layer rule can be written in at least two forms —
allowlist ("these edge types are free") and denylist ("these edge types are forbidden") — and the
two forms give different results on the same graph. The lesson measures this difference with three
numbers: which gives fewer false alarms, which misses more violations, and how many places the
rule updates when a new module enters the codebase. The through-line is the regional library
network, and it is fiction.

## The Import Graph to Check

**GV9 — the codebase consists of thirteen modules, each module declares its own layer in its
source, and there are five layers: entry, application, domain, infrastructure, and shared.**
Reason: a layer check can only run on a codebase where every module is assigned to a layer; keeping
the layer declaration in the source means the checker does not depend on an external map.

```js
// setup.mjs — writes the regional library network's codebase to disk with its layers
import { mkdirSync, writeFileSync } from "node:fs";

const LAYER = {
  branchFront: "entry", staffFront: "entry",
  loanFlow: "application", feeFlow: "application", membershipFlow: "application",
  loanRule: "domain", feeRule: "domain", catalogRule: "domain",
  storageAccess: "infrastructure", catalogBridge: "infrastructure", identityBridge: "infrastructure",
  eventLog: "infrastructure", sharedFormat: "shared",
};

const EDGES = [
  "branchFront->loanFlow", "branchFront->feeFlow", "branchFront->sharedFormat",
  "staffFront->membershipFlow", "staffFront->loanFlow", "staffFront->sharedFormat",
  "loanFlow->loanRule", "loanFlow->catalogRule", "loanFlow->storageAccess",
  "loanFlow->catalogBridge", "loanFlow->sharedFormat",
  "feeFlow->feeRule", "feeFlow->storageAccess", "feeFlow->sharedFormat",
  "membershipFlow->identityBridge", "membershipFlow->sharedFormat",
  "loanRule->sharedFormat", "feeRule->sharedFormat", "catalogRule->sharedFormat",
  "storageAccess->sharedFormat", "eventLog->sharedFormat", "catalogBridge->sharedFormat",
  "catalogBridge->catalogRule", "identityBridge->sharedFormat",
  "branchFront->catalogBridge", "feeFlow->loanFlow",          // the two edges with an exception record
  "loanRule->storageAccess", "feeRule->catalogBridge", "storageAccess->loanFlow",
  "branchFront->storageAccess", "branchFront->catalogRule", "sharedFormat->eventLog",
  "loanFlow->identityBridge", "feeFlow->catalogBridge",
];

mkdirSync("code", { recursive: true });
for (const name of Object.keys(LAYER)) {
  const imports = EDGES.filter((k) => k.split("->")[0] === name)
    .map((k) => `import { ${k.split("->")[1]}Op } from "./${k.split("->")[1]}.mjs";`);
  writeFileSync(`code/${name}.mjs`, `// layer: ${LAYER[name]}\n${imports.join("\n")}\n` +
    `export const ${name}Op = (x) => x;\n`);
}

const L = ["entry", "application", "domain", "infrastructure", "shared"];
const count = {};
for (const k of EDGES) {
  const [a, b] = k.split("->");
  count[`${LAYER[a]}->${LAYER[b]}`] = (count[`${LAYER[a]}->${LAYER[b]}`] ?? 0) + 1;
}
console.log(`${Object.keys(LAYER).length} module files, ${EDGES.length} import edges`);
console.log(`\nlayer-to-layer edge count (row: importer, column: imported)`);
console.log(`${"".padEnd(16)}${L.map((k) => k.padStart(16)).join("")}`);
for (const a of L) {
  console.log(`${a.padEnd(16)}${L.map((b) => String(count[`${a}->${b}`] ?? 0).padStart(16)).join("")}`);
}
```

```
13 module files, 34 import edges

layer-to-layer edge count (row: importer, column: imported)
                           entry     application          domain  infrastructure          shared
entry                          0               4               1               2               2
application                    0               1               3               6               3
domain                         0               0               0               2               3
infrastructure                 0               1               1               0               4
shared                         0               0               0               1               0
```

The matrix shows which cells the rule is broken in before the rule is even written: the domain row
has two edges to infrastructure, the entry row has one edge to domain and two to infrastructure,
the shared row has one edge to infrastructure. **Writing the rule means naming these cells** — and
there are two separate ways to name them.

## The Same Rule in Five Forms

The checker reads the files, pulls the layer from the `// layer:` line, and the edge from the
`from "./..."` lines. The same rule is written in five forms: an allowlist at layer granularity,
an exception list added to that (with its expiry checked and unchecked), an allowlist at module
granularity, and a denylist at layer granularity.

**GV10 — the architect read all thirty-four edges by hand and marked nine of them as a real
violation; two edges carry a sanctioned exception record, and one of them has expired.** Reason:
catching, missing, and false alarm can only be counted against a hand-labeled set; the exception
records are part of that set too, because counting an approved edge as a violation is a false
alarm.

```js
// layer.mjs — reads the real import graph from the code/ directory; the same rule is run in five forms
import { readFileSync, readdirSync, writeFileSync } from "node:fs";

function graph(dir = "code") {
  const layer = {}, edges = [];
  for (const d of readdirSync(dir)) {
    const name = d.replace(".mjs", "");
    const t = readFileSync(`${dir}/${d}`, "utf8");
    layer[name] = t.match(/^\/\/ layer: (\w+)$/m)[1];
    for (const m of t.matchAll(/from "\.\/(\w+)\.mjs"/g)) edges.push(`${name}->${m[1]}`);
  }
  return { layer, edges };
}
const { layer, edges } = graph();
const lay = (k) => `${layer[k.split("->")[0]]}->${layer[k.split("->")[1]]}`;

// GV10: the architect read all thirty-four edges by hand; the nine below are real violations. The
// exception record on branchFront->catalogBridge is valid; the exception on feeFlow->loanFlow has expired.
const VIOLATIONS = new Set([
  "feeFlow->loanFlow",
  "loanRule->storageAccess", "feeRule->catalogBridge", "storageAccess->loanFlow",
  "branchFront->storageAccess", "branchFront->catalogRule", "sharedFormat->eventLog",
  "loanFlow->identityBridge", "feeFlow->catalogBridge",
]);
const QUARTER = 4;
const EXCEPTIONS = [                                   // edge | end quarter | reason
  ["branchFront->catalogBridge", 5, "migration period: branch front reads the old catalog bridge"],
  ["feeFlow->loanFlow", 2, "temporary: fee flow reads from loan flow"],
];

const LAYER_ALLOWLIST = ["entry->application", "entry->shared", "application->domain", "application->infrastructure",
  "application->shared", "domain->shared", "infrastructure->domain", "infrastructure->shared"];
const LAYER_DENYLIST = ["domain->infrastructure", "domain->application", "domain->entry",
  "infrastructure->application", "infrastructure->entry", "application->entry"];
const MODULE_ALLOWLIST = [
  "branchFront->loanFlow", "branchFront->feeFlow", "branchFront->sharedFormat",
  "staffFront->membershipFlow", "staffFront->loanFlow", "staffFront->sharedFormat",
  "loanFlow->loanRule", "loanFlow->catalogRule", "loanFlow->storageAccess",
  "loanFlow->catalogBridge", "loanFlow->sharedFormat",
  "feeFlow->feeRule", "feeFlow->storageAccess", "feeFlow->sharedFormat",
  "membershipFlow->identityBridge", "membershipFlow->sharedFormat",
  "loanRule->sharedFormat", "feeRule->sharedFormat", "catalogRule->sharedFormat",
  "storageAccess->sharedFormat", "eventLog->sharedFormat", "catalogBridge->sharedFormat",
  "catalogBridge->catalogRule", "identityBridge->sharedFormat",
  "branchFront->catalogBridge",
];

writeFileSync("module_allowlist.json", JSON.stringify(MODULE_ALLOWLIST));

const allowedEdge = (checkExpiry) => new Set(EXCEPTIONS
  .filter(([, end]) => !checkExpiry || end >= QUARTER).map(([k]) => k));
const FORMS = {
  "layer allowlist":               { n: LAYER_ALLOWLIST.length, f: (k) => !LAYER_ALLOWLIST.includes(lay(k)) },
  "layer allowlist + exception":   { n: LAYER_ALLOWLIST.length + EXCEPTIONS.length,
    f: (k) => !LAYER_ALLOWLIST.includes(lay(k)) && !allowedEdge(false).has(k) },
  "layer allowlist + timed exc.":  { n: LAYER_ALLOWLIST.length + EXCEPTIONS.length,
    f: (k) => !LAYER_ALLOWLIST.includes(lay(k)) && !allowedEdge(true).has(k) },
  "module allowlist":              { n: MODULE_ALLOWLIST.length, f: (k) => !MODULE_ALLOWLIST.includes(k) },
  "layer denylist":                { n: LAYER_DENYLIST.length, f: (k) => LAYER_DENYLIST.includes(lay(k)) },
};

console.log(`${edges.length} edges read; known violations ${VIOLATIONS.size}, exception records ${EXCEPTIONS.length} ` +
  `(${EXCEPTIONS.filter(([, b]) => b < QUARTER).length} expired)`);
console.log(`\n${"rule form".padEnd(29)}${"rule lines".padStart(11)}${"flagged".padStart(13)}` +
  `${"caught".padStart(11)}${"missed".padStart(7)}${"false alarm".padStart(14)}`);
for (const [name, b] of Object.entries(FORMS)) {
  const flagged = edges.filter(b.f);
  const y = flagged.filter((k) => VIOLATIONS.has(k)).length;
  console.log(`${name.padEnd(29)}${String(b.n).padStart(11)}${String(flagged.length).padStart(13)}` +
    `${`${y}/${VIOLATIONS.size}`.padStart(11)}${String(VIOLATIONS.size - y).padStart(7)}` +
    `${String(flagged.length - y).padStart(14)}`);
}

console.log(`\nmissed violations (by rule form):`);
for (const [name, b] of Object.entries(FORMS)) {
  const missed = [...VIOLATIONS].filter((k) => !b.f(k));
  console.log(`  ${name.padEnd(29)}${missed.length ? missed.map((k) => `${k} (${lay(k)})`).join("; ") : "-"}`);
}
```

```
34 edges read; known violations 9, exception records 2 (1 expired)

rule form                     rule lines      flagged     caught missed   false alarm
layer allowlist                        8            8        7/9      2             1
layer allowlist + exception           10            6        6/9      3             0
layer allowlist + timed exc.          10            7        7/9      2             0
module allowlist                      25            9        9/9      0             0
layer denylist                         6            3        3/9      6             0

missed violations (by rule form):
  layer allowlist              loanFlow->identityBridge (application->infrastructure); feeFlow->catalogBridge (application->infrastructure)
  layer allowlist + exception  feeFlow->loanFlow (application->application); loanFlow->identityBridge (application->infrastructure); feeFlow->catalogBridge (application->infrastructure)
  layer allowlist + timed exc. loanFlow->identityBridge (application->infrastructure); feeFlow->catalogBridge (application->infrastructure)
  module allowlist             -
  layer denylist               feeFlow->loanFlow (application->application); branchFront->storageAccess (entry->infrastructure); branchFront->catalogRule (entry->domain); sharedFormat->eventLog (shared->infrastructure); loanFlow->identityBridge (application->infrastructure); feeFlow->catalogBridge (application->infrastructure)
```

## The Difference Between an Allowlist and a Denylist

The table breaks a common expectation. **The denylist gives zero false alarms and misses six of
nine violations.** The reason is in the form itself: a denylist only sees the edge types it
enumerates. `domain->infrastructure` and `infrastructure->application` are written into the list,
so three violations are caught; but `entry->infrastructure`, `entry->domain`,
`shared->infrastructure`, and the same-layer `application->application` are not in the list, and
those edges are never seen at all. The more incomplete a denylist is, the more silent it is, and
nowhere does its incompleteness show.

**An allowlist works the opposite way: everything not in the list is a violation.** The
layer-granularity allowlist catches seven of nine violations with eight lines. The two it misses
are the limit of the rule's expressive power: `loanFlow->identityBridge` and
`feeFlow->catalogBridge` fall into the `application->infrastructure` pair, which is free at the
layer level; a rule that says both are forbidden has to be written by module name, not by layer.
In exchange, the allowlist has one false alarm, and that alarm flags an approved migration edge.

The exception list removes that false alarm but brings its own flaw. In the exception list whose
expiry is not checked, the catch count drops from 7 to 6: an exception whose term ended two
quarters ago is still hiding a real violation. When the expiry is checked, the catch count returns
to 7 and the false alarm count stays at zero. **An exception list erodes the rule itself for as
long as its expiry goes unchecked** — and the size of that erosion is exactly the number of
expired exceptions.

The module-granularity allowlist is flawless in the table: all nine of nine violations are caught,
nothing gets through, no false alarms. Its cost shows up in the line count — twenty-five lines
instead of eight, meaning the rule is no longer a summary of the architecture but a full copy of
the graph. Its real cost does not show up in this table at all; the next run measures it.

## When a New Module Enters

A rule's maintenance cost is zero for as long as the codebase stands still. The block below adds a
new module to the codebase and runs the same rules in their unupdated form.

```js
// new.mjs — a new module is added to the codebase; how many places does each rule form update
import { readFileSync, readdirSync, writeFileSync } from "node:fs";

// GV11: in the next quarter a single new module enters the codebase and opens six new import edges
const NEW = "reservationFlow";
const NEW_EDGES = [                                            // five comply with the rule, one is a violation
  `branchFront->${NEW}`, `staffFront->${NEW}`, `${NEW}->loanRule`,
  `${NEW}->storageAccess`, `${NEW}->sharedFormat`, `${NEW}->staffFront`,
];
writeFileSync(`code/${NEW}.mjs`, `// layer: application\n` +
  NEW_EDGES.filter((k) => k.startsWith(`${NEW}->`))
    .map((k) => `import { ${k.split("->")[1]}Op } from "./${k.split("->")[1]}.mjs";`).join("\n") +
  `\nexport const ${NEW}Op = (x) => x;\n`);
for (const k of NEW_EDGES.filter((k) => k.endsWith(`->${NEW}`))) {
  const a = k.split("->")[0];
  const t = readFileSync(`code/${a}.mjs`, "utf8");
  writeFileSync(`code/${a}.mjs`, t.replace(/\n/, `\nimport { ${NEW}Op } from "./${NEW}.mjs";\n`));
}

const layer = {}, edges = [];
for (const d of readdirSync("code")) {
  const name = d.replace(".mjs", "");
  const t = readFileSync(`code/${d}`, "utf8");
  layer[name] = t.match(/^\/\/ layer: (\w+)$/m)[1];
  for (const m of t.matchAll(/from "\.\/(\w+)\.mjs"/g)) edges.push(`${name}->${m[1]}`);
}
const lay = (k) => `${layer[k.split("->")[0]]}->${layer[k.split("->")[1]]}`;

const LAYER_ALLOWLIST = ["entry->application", "entry->shared", "application->domain", "application->infrastructure",
  "application->shared", "domain->shared", "infrastructure->domain", "infrastructure->shared"];
const LAYER_DENYLIST = ["domain->infrastructure", "domain->application", "domain->entry",
  "infrastructure->application", "infrastructure->entry", "application->entry"];
const MODULE_ALLOWLIST_OLD = JSON.parse(readFileSync("module_allowlist.json", "utf8"));
const compliantNew = NEW_EDGES.filter((k) => LAYER_ALLOWLIST.includes(lay(k)));
const MODULE_ALLOWLIST_NEW = [...MODULE_ALLOWLIST_OLD, ...compliantNew];

const FORMS = {
  "layer allowlist":            { data: LAYER_ALLOWLIST, f: (k) => !LAYER_ALLOWLIST.includes(lay(k)) },
  "layer denylist":             { data: LAYER_DENYLIST, f: (k) => LAYER_DENYLIST.includes(lay(k)) },
  "module allowlist (old)":     { data: MODULE_ALLOWLIST_OLD, f: (k) => !MODULE_ALLOWLIST_OLD.includes(k) },
  "module allowlist (updated)": { data: MODULE_ALLOWLIST_NEW, f: (k) => !MODULE_ALLOWLIST_NEW.includes(k) },
};
const NEW_VIOLATIONS = new Set([`${NEW}->staffFront`]);

console.log(`new module: ${NEW} (layer: application), ${NEW_EDGES.length} new edges; ` +
  `${compliantNew.length} comply with the layer rule, ${NEW_EDGES.length - compliantNew.length} violate it`);
console.log(`graph now has ${Object.keys(layer).length} modules and ${edges.length} edges`);
console.log(`\n${"rule form".padEnd(29)}${"rule lines".padStart(11)}${"lines updated".padStart(17)}` +
  `${"new violation caught".padStart(22)}${"new false alarm".padStart(17)}`);
for (const [name, b] of Object.entries(FORMS)) {
  const flagged = NEW_EDGES.filter(b.f);
  const y = flagged.filter((k) => NEW_VIOLATIONS.has(k)).length;
  const updated = name === "module allowlist (updated)" ? compliantNew.length : 0;
  console.log(`${name.padEnd(29)}${String(b.data.length).padStart(11)}${String(updated).padStart(17)}` +
    `${`${y}/${NEW_VIOLATIONS.size}`.padStart(22)}${String(flagged.length - y).padStart(17)}`);
}
console.log(`\nthe new module's own file has the same single line for every rule form: "// layer: application"`);
```

```
new module: reservationFlow (layer: application), 6 new edges; 5 comply with the layer rule, 1 violate it
graph now has 14 modules and 40 edges

rule form                     rule lines    lines updated  new violation caught  new false alarm
layer allowlist                        8                0                   1/1                0
layer denylist                         6                0                   1/1                0
module allowlist (old)                25                0                   1/1                5
module allowlist (updated)            30                5                   1/1                0

the new module's own file has the same single line for every rule form: "// layer: application"
```

This is the hidden cost of the module-granularity allowlist. The new module arrives with a
single-line layer declaration; both layer-granularity forms are updated nowhere and still catch
the new violation. The module-granularity allowlist, left unupdated, produces **five false
alarms**, and all five of those alarms sit on edges that fully comply with the rule; once updated,
the alarms drop to zero and the rule grows from twenty-five lines to thirty. **Every new module
grows the rule by five lines.**

This is the same phenomenon as the previous lesson's disabling run. A check that blocks a
compliant change gets closed after a few releases, or the rule quietly turns into a self-approving
list. The module-granularity allowlist looks flawless in the table, but in practice it is the most
fragile form, because its correctness depends on being updated by hand at every new edge.

The choice among the three measured forms is a combination of these three numbers: the denylist
needs no maintenance and misses six violations; the layer allowlist needs no maintenance, misses
two violations, and depends on the exception list's expiry check to catch them; the module
allowlist misses nothing and asks for five lines of maintenance per module. **As the rule's
granularity rises, the catch rate rises, and the maintenance cost rises with it; no form makes
both cheap at once.**

## Summary

- Layer checking was run on an import graph read from real files: 13 modules, 34 edges, 9
  hand-labeled violations, and 2 sanctioned exception records.
- The denylist form gives zero false alarms with 6 lines but misses 6 of 9 violations; the edge
  types it misses pass silently because they are not enumerated in the list.
- The layer-granularity allowlist catches 7 violations with 8 lines, misses 2, and gives 1 false
  alarm on an approved migration edge; the two it misses are rules that can only be written by
  module name, not by layer.
- The exception list whose expiry is not checked drops the catch count from 7 to 6; a single
  expired exception hides one real violation, and once the expiry check is added, the catch count
  returns to 7 and the false alarm count is zero.
- The module-granularity allowlist catches 9 of 9 with 25 lines and gives zero false alarms; its
  cost shows up at a new module — left unupdated it gives a false alarm on 5 compliant edges, and
  updated it grows by 5 lines.
- As the rule's granularity rises, the catch rate rises and the maintenance cost rises with it;
  because the layer declaration lives in the source, the layer forms ask for 0 lines of update at
  a new module.

## Next Step

Across these three lessons, rules were written, converted into checks, and their catches and
misses counted. All three share one assumption: that the architecture is fixed and the rule
protects it. Yet this lesson's last run showed the opposite — the moment a module entered the
codebase, the rule itself had to change too. The quality an architecture needs to be measured on is
not only whether it complies with the rule today, but **how cheaply it accepts change**. The next
lesson takes up that quality: can the structural decisions that make change cheap themselves be
converted into a check, and if so, what number is measured, and what stands in for the part that
cannot be converted.
