A team building a strictly layered application (presentation -> business -> data access, where each layer may call only the layer below it) keeps merging pull requests that quietly violate the rule - e.g., a controller class importing a repository class directly. Code review alone isn't catching these. What mechanisms can enforce a layered architecture's dependency-direction rule automatically, and what exactly do they check?
answer
- review alone misses buried imports
- ArchUnit / NetArchTest / dependency-cruiser
- test-based vs module-based enforcement
- illegal import = compile error in module split
- watch for disabled tests / carve-out creep
basics
~20 sAutomated build-time checks - tools that scan the code's package/module structure and fail the build if, say, a UI package imports a database package. This catches violations every time, unlike relying on humans remembering the rule during review.
solid answer
~40 sReliable enforcement comes from automated architecture tests running in CI, not review discipline. Tools like ArchUnit (JVM), NetArchTest (.NET), or dependency-cruiser (JS/TS) encode rules such as 'classes in data must not depend on anything in presentation' and scan the compiled bytecode or import graph, failing the build on any violation. Stronger enforcement uses actual module boundaries - separate Gradle/Maven modules per layer with restricted dependency declarations, or the Java Platform Module System - turning an illegal import into a compile error rather than a lint warning. The shared property: they check real code structure mechanically and continuously, catching a violation the moment it's introduced rather than during a later audit.
go deeper
Should recognize that just naming packages/folders by layer doesn't stop illegal imports, and that some kind of automated check is more reliable than remembering the rule.
Should be able to name at least one concrete tool (ArchUnit or an equivalent) and describe roughly how it works - scanning imports/dependencies and failing the build.
Should compare test-based enforcement against module-level/build-system enforcement, including the migration-cost trade-off, and know that CI checks can be disabled and need governance.
Should discuss how to keep architecture rules meaningful over years at scale - governance around carve-outs, periodic review of disabled rules, and the organizational discipline needed to prevent guardrails from decaying, plus recognizing static-analysis blind spots like reflection/DI.
## Why code review misses these Code review fails to reliably catch layer-boundary violations for a structural reason, not because reviewers are careless: - a single illegal import buried in a 200-line diff is easy to miss; - reviewers often don't have the entire dependency graph of the codebase memorized well enough to spot an indirect violation (class A in the data layer doesn't import a presentation class directly, but imports class B which does); - and under deadline pressure, 'this one import is probably fine' judgment calls get waved through. Review is a human, situational check; a dependency-direction rule needs a check that runs the same way, every time, regardless of who's reviewing or how rushed the PR is. ## The standard mechanism — automated architecture tests The standard mechanism for this is **automated architecture testing**, which encodes the layering rule as an executable check that runs in the normal test suite and fails the build on violation. - On the JVM, **`ArchUnit`** is the widely used tool: it's a library added as a test dependency, letting you write rules like 'no classes residing in a data package should depend on classes residing in a presentation package,' or the inverse allow-list version. `ArchUnit` inspects the actual compiled classfiles (via reflection over the classpath) to check every reference, not just the source text, so it catches violations however they're expressed in code. - Equivalent tools exist per ecosystem — **`NetArchTest`** for .NET, **`dependency-cruiser`** for JavaScript/TypeScript, which parses the module import graph against a rules config and can also generate a visual dependency graph. All of these share the property that matters: they run in CI on every PR exactly like any other test, so a violation is caught within minutes of being introduced, not discovered months later during a manual architecture audit. ## The stronger version — separate build modules A stronger version of the same idea moves enforcement from a test that could theoretically be skipped to the build system itself: splitting the codebase into genuinely separate build modules per layer — in a Gradle multi-module project, a presentation module, a business module, and a data module, where the data module's build file declares no dependency on presentation at all. In that setup, an illegal import isn't a failing test, it's a **compile error**, because the class simply isn't on the classpath of the module trying to reference it — there's no way to accidentally slip past it, because it's not a rule being checked, it's a structural fact about what code exists to be imported at all. ## Migration cost versus guarantee strength The trade-off between these two mechanisms is migration cost versus guarantee strength. - **Adding an ArchUnit-style test** to an existing, already-layered-by-convention codebase is comparatively cheap: write the rule, run it against the current codebase, fix whatever it flags, and it's live. - **Restructuring into genuinely separate build modules** is a much bigger undertaking for an existing large system — it usually means untangling accidental cross-layer references that current package-level organization was silently allowing, reorganizing build files, and potentially dealing with slower or more complex build graphs — but it produces a guarantee that can't be silenced by deleting or skipping a test. ## Failure modes that persist Even with these mechanisms in place, failure modes persist. - **Architecture-test rules degrade over time** if exceptions accumulate faster than they're revisited — a rule that started as a clean allow-list gains enough carve-outs over successive deadline-driven PRs that it stops meaningfully testing anything. - **Tests can also simply be disabled** ('disable this for now, we'll fix it later') under release pressure, and if nothing tracks disabled tests, 'later' doesn't arrive. - **And static dependency-graph checks**, whether test-based or module-based, can miss violations introduced through indirection that doesn't show up as a static import — reflection-based access, a dependency-injection container wiring a presentation-layer bean into something the data layer resolves dynamically, or a shared mutable singleton that both layers reach through a common utility class. ## Where this shows up in practice Real-world adoption of this pattern is common in large, multi-team Java codebases specifically because they're the systems most prone to slow architectural erosion across many contributors over years; teams building on Spring Boot frequently add `ArchUnit` test suites (sometimes alongside Spring Modulith's own module-boundary verification, which builds on similar dependency-analysis techniques) specifically to keep a layered or modular structure honest as the team and codebase grow well past the size where any one person can hold the whole dependency graph in their head.
- Why might a team choose ArchUnit-style test rules over splitting into separate build modules, even knowing tests can be disabled?Because the migration cost is dramatically lower - adding a test rule to an existing package-based codebase takes hours, while splitting into genuinely separate build modules can take weeks of untangling accidental cross-references and reorganizing build configuration. For many teams, a test-based guardrail that's visible in CI and requires a deliberate, reviewable action to disable is a good enough guarantee relative to that cost difference.
- Can an architecture test like ArchUnit catch a layer violation introduced through a dependency-injection framework rather than a direct import?It depends on how the violation manifests - if the DI-wired class still ends up with a compiled reference or field of the wrong-layer type, ArchUnit can catch it since it inspects actual compiled class structure, not just source imports. But if the wiring happens through something fully dynamic, like reflection-based lookup by string name with no static type reference at all, a static dependency check has nothing to inspect and will miss it, which is why this class of violation is called out as a known blind spot.
- What's the risk of writing an architecture-test rule as a broad allow-list with many carve-outs, versus a narrow rule with none?Every carve-out is a place the rule no longer actually checks anything, so a rule that accumulates enough exceptions over time can pass while the real dependency structure has drifted arbitrarily far from the intended layering - the team gets false confidence from a green CI check. Keeping carve-outs rare and requiring them to be justified and periodically revisited is what keeps the rule meaningful rather than ceremonial.
It's like relying on a security guard glancing at badges (code review) versus installing a turnstile that physically won't open without a valid badge (automated architecture test) versus building separate locked buildings that don't even have a connecting door (separate build modules) - each step trades convenience for a stronger guarantee that can't be bypassed by someone being in a hurry.
saying these in an interview costs you the question
- Says code review discipline alone is sufficient if the team is careful
- Can't name any concrete tool or mechanism (ArchUnit, dependency-cruiser, module system)
- Doesn't recognize that a test-based rule can be silently disabled or carved out over time
- Thinks a static dependency check catches every possible layer violation including reflection/DI-based ones
- Conflates 'having packages named by layer' with 'having enforced layer boundaries'