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 app/, db/,
emails/, model/ 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: route-group and dynamic-segment paths need **. Flat config globs
are minimatch, which reads [eventSlug] as a character class, so a literal
app/(site)/[eventSlug]/kiosk.tsx silently matches nothing — and a files
entry that matches nothing looks exactly like one that works.
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: "^model/" },
to: { path: "^(app|db)/" },
}
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. Neither layer rule uses it: 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/ and app/ are adapters and may
depend on model/; model/ may not depend on them. Types follow the same
direction: an entity type is domain vocabulary and belongs in model/, 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/.
The repository ports belong in the centre too, next to the types they speak in;
db/ is then adapters and row shapes only.
We are not there yet. db/repositories/interfaces.ts still holds the entity
vocabulary alongside the ports, and 62 files outside db/ import their domain
types from it. CONTACT_TYPES moving to model/guest.ts is the first instance
of the fix; issue #965 has the inventory and the order to do the rest in.
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.