Four invariants
Rules DMA enforces at every scale — from a pet project to a monorepo.
An invariant in DMA is a rule that does not depend on team or project size. Four such rules cover dependency direction, module boundaries, and how code grows over time.
If a rule can't be verified by tooling — it's a wish, not part of the architecture. One exception: the boundary between services and shared partly remains your judgment (see Layers).
1. Imports only downward
Modules import only from layers below in rank:
app/pages/routes → features → services → sharedThe composition root (app/, pages/, routes/) assembles the application. Features don't know about each other. Services may depend on other services, but the graph stays acyclic. Shared — only within shared.
Why: cycles and “leaks upward” are the main reason refactoring gets expensive. Direction is fixed and visible in the graph.
What tooling checks: rules layer-direction, feature-to-feature, no-cycle.
2. Public API without barrel files
When one module uses another, the import goes directly to a file inside */public/*:
// ✓
import { CartPanel } from "@/features/cart/public/cart-panel";
// ✗ barrel hides where the dependency really comes from
import { CartPanel } from "@/features/cart/public";For small modules (stage 0), the whole file may be public — no public/ folder. Once the module grows internals, the public surface moves into public/.
Why: the linter and dma check build the graph from real import paths. A barrel with re-exports breaks that graph.
What tooling checks: public-api, no-barrel.
Want to go deeper — Why no barrel files.
3. Colocation by default
New code lives next to its sole consumer until a second one appears. Don't create shared/format-date.ts if format-date is only needed in checkout.
Why: premature generalization bloats shared/ and blurs module boundaries. Colocation keeps code where it's easier to understand and delete.
What tooling checks: partially — shared-candidate in dma doctor, when the same file is already legally imported by 2+ modules (often after a lift into shared/ or on a services/*/public/* surface).
4. Second-use rule
You may move code up a level only when a second consumer appears — not “for the future,” not “just in case.”
Typical path:
- Helper colocated inside feature A — one consumer.
- The same code is needed by feature B → first extract (to
shared/or promotion intoservices/), don't deep-import from A's internals. - If B imported A's internals directly —
checkfails onpublic-api/feature-to-featurebefore doctor showsshared-candidate. - After a legal second consumer,
dma doctormay hintshared-candidateon the shared file.
Why: architecture grows from actual use, not expectations.
What tooling checks: shared-candidate, inbound predicates on promotion.
Important detail about types
import type counts as a dependency the same as a regular import. A type link is still a link. You can't bypass rules via import type.