Architecture rules
Architecture rules
Conventions that only live in a document decay. These are the ones the build checks, and where each kind belongs.
Two mechanisms, split by what they can see:
| Mechanism | Sees | Good for |
|---|---|---|
| ESLint | one file at a time | banned calls, banned syntax, required shapes |
dependency-cruiser |
the whole module graph | cycles, layer boundaries, “X may not reach Y” |
Run them with make lint and make arch; make precommit runs both.
ESLint: rules about code inside a file
Most single-file rules need no plugin. no-restricted-syntax takes
esquery selectors, so a rule is one selector plus the sentence
you want the author to read:
{
selector: "NewExpression[callee.name='Date'][arguments.length=0]",
message: "`new Date()` reads an ambient clock. …",
}
Scope it with a config block listing the enforced areas in files. The ambient-clock ban (ADR 0004)
is the worked example: it covers db/, emails/, packages/ and utils/.
Exempt a line, not a file, with eslint-disable-next-line <rule> -- <reason>. A file-wide ignores
entry would stop checking the other hundred lines too, and an exemption is usually one call in an
otherwise ordinary file. The -- reason is mandatory: it is what the next reader has instead of
this page.
Gotcha: a files entry that matches nothing looks exactly like one that works. Flat config
globs are minimatch, so [ and ( in a path are glob syntax, not literal characters.
When a selector can’t express the rule, write a local rule inline — flat config takes a plugin object literally, so there is no package to publish and no build step:
const local = {
rules: {
"my-rule": {
create(context) {
/* … */
},
},
},
};
export default tseslint.config({
plugins: { local },
rules: { "local/my-rule": "error" },
});
Because projectService is on, such rules get the TypeScript type checker, not just the syntax
tree.
dependency-cruiser: rules about the module graph
ESLint reads one file at a time, so it cannot see a cycle or a layer violation three hops out.
.dependency-cruiser.cjs holds those. A rule is a from set, a to set, and the reason:
{
name: "domain-stays-pure",
severity: "error",
comment: "why this edge is wrong, and what to do instead",
from: { path: "^packages/domain/" },
to: { pathNot: "^packages/domain/" },
}
dependencyTypesNot: ["type-only"] would narrow a rule to runtime edges only — a useful escape
valve mid-migration, when a shared type is still declared on the wrong side of a boundary. One rule
uses it, by decision: a use case may name the db/ container’s repository types until its ports are
its own (use-cases-take-only-container-types, ADR 0012). Otherwise an import the compiler erases
still points the wrong way, and a boundary that holds only at runtime is one nobody can reason about
from the import list.
The direction that matters is inward. db/ is an adapter and may depend on the workspace packages;
the packages may not depend on it (ADR 0010). packages/domain
imports nothing but itself and luxon, and packages/contracts only domain and zod. Types
follow the same direction: an entity type is domain vocabulary and belongs in domain, where both
adapters can import it. A type that only means something to the database — a row shape, a driver’s
handle — stays in db/ and is used only inside db/.
db/repositories/ holds the repository ports and their SQLite adapters, and nothing outside db/
imports from it (repositories-stay-in-db); the rest of the app reaches the repositories through
@/db/container. A port’s input or page type stays next to the port while only db/ uses it; once
code outside db/ needs to name it, it is vocabulary and moves to domain.
server/ follows ADR 0012: server/kernel/ reaches no module, no HTTP
and no framework, not even through a helper it imports; a module’s application/ imports no HTTP,
framework or db/ code; a module is imported only through its module.ts, by other modules and
tests alike. See The server and the API.
utils/ does not import the repository container (@/db/container) directly: it reads through
pageReads() or a use case. Exempt: the job loop, notification and push infrastructure
(utils/jobs/, utils/notifications.ts, utils/push.ts).
apps/web (ADR 0015) imports packages/domain,
packages/contracts and packages/api-client only: never server/, utils/ or db/. Nothing
imports it, features/ never imports api-client (screens call commands/ and queries/), and it
may import only what its own package.json declares. See The web app.
make arch-graph renders the graph to arch-graph.svg (needs graphviz), which is usually faster
than arguing about where a boundary should go.
Adding a rule
- Write it, and check that it fails on a file that violates it — a mis-scoped rule reports success and enforces nothing.
- Fix the violations it finds, or list them as exemptions with the reason. Prefer fixing: an exemption granted at introduction tends to be permanent.
- Put the reason in the rule’s
message/comment, not only in a doc. The person who hits it is reading the error, not this page.