beginner 3 min answer

A team's rule is that the domain layer must not import the persistence layer. Today the rule lives in a wiki page and in code review. What changes when the same rule becomes a check that fails the build, and what does the violation count over time tell you that today's count does not?

layeringdependency rulearchunitratchetcode review
Show the full answer Hide the answer

The mechanism

A rule enforced by review is enforced by attention. A reviewer reads the diff and notices the forbidden import, which works until the pull request is forty files long, or the author is senior, or the fix is going out at 18:00 on a Friday. Nothing records the violations that got through, so the team's belief about its own architecture drifts away from the code with no event marking it.

A build check replaces attention with a predicate. The rule becomes an assertion over the dependency graph: no type in domain may reference a type in persistence. Every mainstream stack has a tool for this - ArchUnit for Java, import-linter for Python, Packwerk for Ruby, dependency-cruiser or an ESLint restricted-import rule for TypeScript, NetArchTest for .NET - and the check parses imports rather than running code, so it finishes in under 10 seconds on 300,000 lines and can live in the same job as the unit tests. After that, the check is the only statement about the layering that is guaranteed true of the code in production.

What makes it adoptable

The naive version fails on the first run. A ten-year-old codebase will have on the order of 300 existing violations, and a check that fails on any violation can never be switched on, so it gets switched off and the rule returns to the wiki.

The working version is a ratchet. Record the existing violations in a baseline file, and fail the build only when the count rises above the baseline. New code is held to the rule from day one, the old code is not blocked, and nobody has to schedule a cleanup project to get the gate running. Every file touched for other reasons is an opportunity to delete a baseline entry.

What the trend tells you

Today's count is close to meaningless: it is a function of how old the codebase is. The derivative is the signal.

  • Falling steadily, say twenty entries a quarter, means the boundary is being paid down by ordinary work. No intervention needed.
  • Flat for two quarters means nobody edits those files. The boundary is cosmetic there, and the honest move is to accept that the layer does not exist in that area.
  • Rising while the gate is green means people are adding baseline entries to get builds through. That is evidence the rule is wrong, not that the team is careless. Find the legitimate need it blocks; usually a read path wants a query the domain cannot express.

When not to add the check

Below roughly two developers and a few thousand lines, the check costs more than the drift does - everyone has the whole system in their head and the boundary is maintained by conversation. It is also redundant where the language already provides the constraint: separate build modules with real visibility rules mean the compiler is the check, and asserting what the compiler already enforces is duplicated work.

Choose one direction, not all of them. A four-layer stack has twelve ordered pairs, and asserting all twelve on an existing codebase produces a baseline nobody will read. Pick the one dependency direction you would pay money to keep, which is almost always that domain code must not depend on infrastructure.

Common weak answers

  • "Add it to the definition of done." That is the same mechanism with more words: it still depends on somebody remembering during review.
  • "Split them into separate services so the network enforces the boundary." This pays deployment, latency and partial-failure costs to solve a compile-time problem. Distribution is a terrible import linter.
  • "Set the threshold to zero and clean up first." The cleanup never gets funded against feature work. The baseline exists precisely so the gate does not wait for it.