Make an architecture decision enforce itself: an ADR, one dependency rule and a 40-line checker
Agents break boundaries that compile, pass tests and read fine in a diff. Pair each decision record with a failing check and the boundary holds. About 15 minutes to try.

Here’s a pull request that every automatic check approves. An agent was asked to add a refund deadline to billing. It needed an order, so it imported the order store directly, and it needed the time, so it imported the clock from the infrastructure folder. The compiler is happy. The tests pass. The diff is eight lines and reads well.
Two team decisions just died in that diff, and nobody wrote them down in a place the agent could be stopped by. Maybe they’re in a wiki page. Maybe they’re in a Slack thread from March. A human who joined last year wouldn’t know them either, but the human at least might ask.
This Playbook builds the smallest thing that fixes it: an architecture decision record (ADR) per decision, one dependency rule per ADR, and a checker that fails when a decision has no rule or a rule has no decision. Violations fail the build with a message that names the ADR, so the agent (or a person) reads the reason in the same output as the failure. The setup is TypeScript with dependency-cruiser, and it takes about 15 minutes. The idea works with any coding agent that can run a shell, and with any language that has a tool for checking imports.
The setup in one picture
Three pieces, each doing one job:
docs/adr/NNNN-slug.md: the decision, in a fixed shape, with anEnforced-by:line..dependency-cruiser.cjs: one forbidden-dependency rule per decision, named after its ADR.scripts/adr-check.mjs: fails when the two drift apart.
The reasoning is simple. A decision record that nothing checks is documentation, and documentation rots. A rule that nothing explains is a mystery that someone eventually deletes to get a build green. Tying them together by name makes each one hard to remove without the other noticing.
The example project
The demo repo is small on purpose. Three folders under src: orders (with an index.ts as its public face and an internal ledger behind it), billing, and infra (a clock). Billing legitimately calls orders, through the index:
// src/billing/charge.ts
import { getOrder } from "../orders/index.js";
export function charge(id: string): number {
const o = getOrder(id);
if (!o) throw new Error(`no order ${id}`);
return o.totalPence;
}
Two decisions have been made. Orders is reached only through its index, because billing once read the ledger directly and a storage change broke refunds. And domain code never imports infrastructure, because charge tests needed the real clock.
Step 1: write the decision in a shape a script can read
Keep the format boring. The checker only needs a status, an enforcement line and three headings:
# 0002. Orders is reached through its index only
Status: Accepted
Date: 2026-10-12
Enforced-by: adr-0002-orders-public-api-only
## Context
Billing started reading the order ledger directly. A storage change in orders then broke refunds.
## Decision
Code outside src/orders imports only from src/orders/index.ts. Everything else in that folder is private.
## Consequences
Orders can change its storage freely. Anything billing needs from an order has to be added to the public API first, which makes the request visible in review.
Write the Context as the incident or the annoyance, in two sentences. An agent reading it learns why the rule exists, which matters when it has to decide how to satisfy the rule without reaching for a workaround. The second ADR (0003-domain-never-imports-infra.md) has the same shape and the line Enforced-by: adr-0003-domain-never-imports-infra.
Step 2: turn each decision into a rule
Install the tooling. One catch, observed on dependency-cruiser 18.7.2: its TypeScript parser supports versions below 7, and it prints a warning and misses TypeScript sources if it only finds TypeScript 7. Pin TypeScript 6 in this project, or use its swc parser, until the tool’s own notes say TypeScript 7 is supported.
npm i -D dependency-cruiser typescript@6 vitest
Then the config. Each rule is named after its ADR, and the comment says where to read the reason:
// .dependency-cruiser.cjs
/** @type {import('dependency-cruiser').IConfiguration} */
module.exports = {
forbidden: [
{
name: "adr-0002-orders-public-api-only",
comment: "ADR 0002: other modules reach orders only through src/orders/index.ts. See docs/adr/0002-orders-public-api-only.md",
severity: "error",
from: { path: "^src/", pathNot: "^src/orders/" },
to: { path: "^src/orders/", pathNot: "^src/orders/index\\.ts$" },
},
{
name: "adr-0003-domain-never-imports-infra",
comment: "ADR 0003: orders and billing take infrastructure as parameters. See docs/adr/0003-domain-never-imports-infra.md",
severity: "error",
from: { path: "^src/(orders|billing)/" },
to: { path: "^src/infra/" },
},
],
options: {
tsConfig: { fileName: "tsconfig.json" },
tsPreCompilationDeps: true,
doNotFollow: { path: "node_modules" },
includeOnly: "^src/",
},
};
The tsPreCompilationDeps: true line matters more than it looks. By default the dependency graph leaves out imports that disappear when TypeScript compiles, so import type { Order } from "../orders/order.js" from billing sails through. Run with and without it and the difference is visible: the default run printed “no dependency violations found”, and the same file with the option on printed error adr-0002-orders-public-api-only: src/billing/types.ts → src/orders/order.ts. A type import couples modules just as hard as a value import does, so count it.
Step 3: the checker that ties them together
This is the whole script. It has no dependencies beyond Node:
// scripts/adr-check.mjs
import { readdirSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const rules = new Set(require("../.dependency-cruiser.cjs").forbidden.map((r) => r.name));
const dir = "docs/adr";
const problems = [];
const claimed = new Set();
for (const file of readdirSync(dir).filter((f) => /^\d{4}-.+\.md$/.test(f))) {
const text = readFileSync(`${dir}/${file}`, "utf8");
const status = /^Status:\s*(.+)$/m.exec(text)?.[1]?.trim();
const enforced = /^Enforced-by:\s*(.+)$/m.exec(text)?.[1]?.trim();
for (const h of ["## Context", "## Decision", "## Consequences"]) {
if (!text.includes(`\n${h}\n`)) problems.push(`${file}: missing "${h}"`);
}
if (!/^(Proposed|Accepted|Superseded by \d{4})$/.test(status ?? "")) {
problems.push(`${file}: Status must be Proposed, Accepted or Superseded by NNNN (got "${status}")`);
}
if (status === "Accepted") {
if (!enforced) problems.push(`${file}: Accepted ADRs need an Enforced-by line (a rule name, or "review: <why>")`);
else if (!enforced.startsWith("review:")) {
for (const name of enforced.split(",").map((s) => s.trim())) {
if (!rules.has(name)) problems.push(`${file}: Enforced-by names "${name}", which is not in .dependency-cruiser.cjs`);
claimed.add(name);
}
}
}
}
for (const name of rules) {
if (!claimed.has(name)) problems.push(`rule "${name}" has no Accepted ADR that claims it`);
}
if (problems.length) {
console.error(problems.join("\n"));
process.exit(1);
}
console.log(`ADR check passed: ${claimed.size} enforced decisions, ${rules.size} rules.`);
Not every decision can be checked mechanically (“prefer composition”, “keep handlers thin”). Those get Enforced-by: review: <why a script can't check it>, which keeps the honesty visible: the record admits it relies on a human. Superseding a decision works the same way. Mark the old ADR Superseded by 0007, delete its rule, and the checker is satisfied. Delete the rule without touching the ADR and the checker complains that an Accepted ADR names a rule that no longer exists.
Wire the lot into one command:
npm pkg set scripts.check="tsc -p . && vitest run && depcruise src --config .dependency-cruiser.cjs --output-type err-long && node scripts/adr-check.mjs"
npm run check
Run it on the clean project and it prints:
Test Files 2 passed (2)
Tests 2 passed (2)
✔ no dependency violations found (8 modules, 7 dependencies cruised)
ADR check passed: 2 enforced decisions, 2 rules.
The err-long reporter is the one to use, because it prints each rule’s comment under the violation. That’s what puts the ADR’s name in front of the agent.
Step 4: watch it catch the agent
Here’s the refund code the agent wrote in the opening scene:
// src/billing/refund.ts
import { find } from "../orders/internal-ledger.js";
import { now } from "../infra/clock.js";
export function refundDeadline(id: string): Date | undefined {
const o = find(id);
return o ? new Date(now().getTime() + 14 * 864e5) : undefined;
}
tsc passes and the existing test passes. The cruise doesn’t:
error adr-0003-domain-never-imports-infra: src/billing/refund.ts → src/infra/clock.ts
ADR 0003: orders and billing take infrastructure as parameters. See
docs/adr/0003-domain-never-imports-infra.md
error adr-0002-orders-public-api-only: src/billing/refund.ts → src/orders/internal-ledger.ts
ADR 0002: other modules reach orders only through src/orders/index.ts. See
docs/adr/0002-orders-public-api-only.md
x 2 dependency violations (2 errors, 0 warnings). 7 modules, 6 dependencies cruised.
The exit code is the number of errors, so the command fails any pipeline. An agent that runs npm run check before it says it’s done gets the file, the rule and the reason in one block, and the fix is mechanical: use the public API, and take the clock as a parameter.
// src/billing/refund.ts
import { getOrder } from "../orders/index.js";
export function refundDeadline(id: string, now: () => Date): Date | undefined {
return getOrder(id) ? new Date(now().getTime() + 14 * 864e5) : undefined;
}
The fix also makes the code better in a way the ADR promised. The deadline can now be tested without a real clock:
// src/billing/refund.test.ts
import { expect, test } from "vitest";
import { placeOrder } from "../orders/index.js";
import { refundDeadline } from "./refund.js";
test("refunds close 14 days after the clock reads", () => {
placeOrder("o1", 500);
const d = refundDeadline("o1", () => new Date("2026-11-01T00:00:00Z"));
expect(d?.toISOString()).toBe("2026-11-15T00:00:00.000Z");
});
Step 5: tell the agent the check exists
Add three lines to whatever context file your agent reads (AGENTS.md, CLAUDE.md, Cursor rules, Copilot instructions):
Run `npm run check` before reporting a task done. It enforces the decisions in docs/adr.
If a rule fails, change the code to satisfy it. Don't edit .dependency-cruiser.cjs or docs/adr to make a failure go away.
If a decision looks wrong, say so and propose a new ADR instead.
The second line is advice and not a guarantee. An agent that can edit the config can silence the rule, which is why the checker’s two-way link is there: the edit shows up in the diff as a deleted rule with a still-Accepted ADR, or as an ADR changed in the same commit as the rule. Review any pull request that touches docs/adr or the config as a decision, not as a chore. For a hard guarantee, mark those two paths as owned by a person in CODEOWNERS.
Step 6: let the agent draft the next ADR, and keep the judgement
Writing the record is the chore people skip. An agent is good at the first draft, because the material is a diff and a conversation. A prompt that works with any agent (documented technique, not run against an agent here):
Read the diff on this branch and the review thread pasted below. Draft the ADR for the
decision it settles, in docs/adr/ using the same shape as 0002 and 0003: Status Proposed,
Context as the problem in at most three sentences, Decision as one rule that can be checked
by looking at imports, Consequences including one cost. Then propose one dependency-cruiser rule
named after the ADR and show which existing files it would flag. Don't change any other file.
Two checks keep the draft honest. First, the Decision has to be mechanical: if the agent can’t phrase it as something a rule can flag, the ADR goes in with Enforced-by: review: ... and a human decides whether that’s acceptable. Second, make the agent list the files the new rule would flag, then run the rule and compare. A rule that flags nothing might be correct, or might be matching the wrong path, and the only way to tell is to try it against a file that should fail.
Make it yours
Existing codebases already break the rules they’re about to adopt. Don’t fix everything first. dependency-cruiser can record the current violations as a baseline and then fail only on new ones:
npx depcruise-baseline src --config .dependency-cruiser.cjs
npx depcruise src --config .dependency-cruiser.cjs --ignore-known
Run against a project with one existing violation, the baseline command wrote .dependency-cruiser-known-violations.json, and the second command exited 0 with “1 known violations ignored”. Adding a second violating file made it print that file as an error and exit 1. Commit the baseline file, and shrink it over time.
For CI, run the same command the agent runs. A minimal GitHub Actions job (documented configuration, not run):
name: check
on: pull_request
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npm run check
For a C# codebase, the ADR files and adr-check.mjs carry over unchanged, since they only read Markdown and a list of rule names. Swap the rule engine for whatever you use to assert namespace and assembly dependencies in tests, and keep the convention that each rule or test is named after its ADR so the checker can read the names. For a monorepo, put the rules per package and have the checker accept a list of config files.
What goes wrong
- The check sees static imports. A dynamic
import(path)with a computed string, a service locator or reflection won’t show up, so a determined (or sloppy) change gets around it. It stops the common accident, which is what agents produce. - Path aliases only resolve if
tsConfigpoints at the right file. If a rule never fires, test it: add a deliberately bad import, confirm the failure, delete the import. - The TypeScript 7 gap above is the kind of thing that breaks a green build after an upgrade. The pinned TypeScript 6 is deliberate, so leave a comment next to it.
- The checker proves a record and a rule are linked, not that the rule expresses the decision. A rule with a wrong
pathregex passes the check and protects nothing. The “add a bad import, watch it fail” test is the only cure. - Third-party code: dependency-cruiser is MIT-licensed, and the npm registry listed 18.7.2 as last modified on 2 November 2026. Read what any dev dependency does before you add it, this one included.
Don’t bother if the codebase has no boundaries worth defending yet. Two files and no layers need no ADR. The payoff starts when a second team, or a second agent session, can break something the first one cared about.
The first 15 minutes
Pick the one boundary your team argues about most. Write it as ADR 0001 in the shape above, add the matching rule to a new .dependency-cruiser.cjs, and run npx depcruise src --config .dependency-cruiser.cjs --output-type err-long. If it flags real code, baseline it. Then add a deliberately bad import on a scratch branch and confirm the failure names your ADR. Add scripts/adr-check.mjs last, once you have a second decision to keep in step with the first.