practice

Layer Enforcement Ratchet

also called Violation Baseline, Architecture Debt Ratchet, Boundary Gate with Baseline

Gating a layering rule on a recorded baseline of existing violations that may shrink but never grow, so an architectural boundary becomes mechanical on a legacy codebase without a cleanup project first.

layeringdependency rulearchunitbaselinetechnical debt

A team decides the domain layer must not import the persistence layer, writes it in a wiki page, and relies on code review. That 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 the moment.

Making the rule a build check is the obvious fix, and the obvious version fails: a check that fails on any violation produces on the order of 300 failures on a ten-year-old codebase, so it is switched off within a day.

The ratchet makes the check survivable. Record the existing violations in a baseline file and fail the build only when the count rises above it. New code is held to the rule from the first commit, old code is not blocked, and nobody has to get a cleanup funded against feature work.

Why it matters

A boundary that is not mechanical does not exist in any useful sense. It lives in the minds of the people who remember it, a declining population, and its violations are invisible because nothing counts them.

It also converts an unbounded argument into a number. "Is our layering holding?" is unanswerable; "the baseline was 312 in January and is 241 now" settles it in one query.

Implementation patterns

  • Assert one dependency direction, not all of them. A four-layer stack has twelve ordered pairs, and asserting all twelve produces a baseline nobody reads. Pick the direction you would pay money to keep: domain code must not depend on infrastructure.
  • Keep the baseline in version control and review its diffs. A pull request that adds a baseline entry should be as visible as one that adds a dependency, because it is the same act.
  • Use the stack's existing tool - ArchUnit, import-linter, Packwerk, dependency-cruiser, NetArchTest - all of which parse imports rather than running code, so the check finishes in under 10 seconds on 300,000 lines.
  • Make the error message name the alternative. "Domain must not import persistence" trains nobody; "use the repository port in domain.ports" is a teaching tool.

Industry example

The mechanism is how large codebases in production adopt a rule they cannot currently satisfy, and it appears in module-boundary tooling for big monoliths, in type-coverage rollouts and in lint adoptions alike: the tool reports against a recorded list rather than demanding a clean tree. A platform team that insists on zero violations instead usually ships the rule as documentation and nothing else.

Failure scenarios

  • The baseline becomes the escape hatch. Entries are added under deadline pressure and the build stays green, because each addition was approved. The tell is a rising count with a passing build.
  • A frozen count that has not moved in two quarters, meaning nobody edits those files and the boundary is cosmetic there.
  • Too many rules at once, producing thousands of entries in which nobody can find signal.
  • The check runs in a job nobody blocks on, so it reports and never fails, which is the wiki page with extra steps.
  • A rule that is actually wrong. A baseline growing in one area usually means the rule forbids something legitimate.

Trade-offs

Choose Gains Pays
Baseline plus no-increase gate Adoptable on day one on any codebase The violations already there may live for years
Zero-tolerance gate The boundary is genuinely true everywhere Needs a cleanup project before it can be switched on

A baseline is a permanent record of accepted defects, which some teams find demoralising. The counter is that the defects existed either way and were previously uncounted.

When not to use it

Below roughly two developers and a few thousand lines, skip it: everyone holds the system in their head and the check costs more than the drift. Skip it too where the language provides the constraint, because separate build modules with real visibility rules mean the compiler is the check.

And do not reach for it when the layering is wrong rather than violated. A pass-through service layer that forwards every call does not need a gate; it needs deleting.

Interview question

Q: You join a team whose domain code imports its ORM entities throughout. What do you switch on in week one, what do you deliberately not block, and how would you tell six months later whether it worked?

What a strong answer covers: one rule rather than a layering scheme; a recorded baseline so the gate can be switched on immediately; the gate failing only on increases; and a six-month test that is the trend in the count plus whether the deleted entries sat in the areas the team actually worked, which distinguishes a boundary being paid down from one nobody touches.

Quick check

Quiz: Your layering check passes and the violation count has risen from 180 to 240 over two quarters. Most likely explanation? Answer: People are adding baseline entries to get builds through, which is evidence the rule forbids something legitimate rather than that the team is careless.

Flashcard: What makes a layering rule enforceable on a legacy codebase? - A recorded baseline of existing violations plus a gate that fails only on an increase, so the check runs before any cleanup.