A codebase started with clean inward-pointing dependencies, but eighteen months later a code review finds a domain class importing a REST client class from the outer web layer. Nothing in the build pipeline caught it before merge. What allowed this drift to happen, and what would you put in place to catch it automatically going forward?
answer
- compiler is architecture-blind
- ArchUnit / dependency-cruiser encode the rule
- runs in CI like a unit test
- needs an exemption mechanism or gets disabled
- module-level verification tools for cross-module drift
basics
~20 sNobody was checking; the compiler happily allows any import as long as types resolve, so 'clean architecture' without an automated check is just a convention people can forget under deadline pressure. Fix: add an automated test that fails the build if a package in the inner layer imports anything from an outer-layer package.
solid answer
~40 sThe compiler enforces type-correctness, not architectural intent — it has no concept of 'domain' vs 'web layer,' so an import that compiles fine can still violate the team's chosen dependency direction, and without a dedicated check nothing catches that until someone notices during review, if ever. The fix is to encode the allowed-dependency rules as an executable test: architecture-testing libraries (e.g., ArchUnit for the JVM, dependency-cruiser for JS/TS) let you assert 'no class in the domain package may depend on a class in the web package' and run that as part of the normal test suite/CI, so a violating import fails the build the same way a broken unit test would, rather than relying on reviewer vigilance.
go deeper
Understands that 'it compiled' doesn't mean 'it's architecturally correct,' even without knowing a specific enforcement tool.
Can name at least one concrete tool (ArchUnit, dependency-cruiser, or similar) and describe running it as part of the test suite.
Can sketch the actual rule (layer/package pattern matching), discuss exemption handling, and recognize the risk of the check being disabled or excluded from CI.
Treats 'architecture erosion' as an expected organizational phenomenon to design against from the start, and weighs the maintenance cost of the ruleset against the cost of drift across a multi-team codebase.
## What the compiler checks A compiler checks that types resolve — that a method called on an object actually exists, that an imported class is on the classpath — but it has **no notion of 'domain layer' or 'web layer'** unless the team builds one. That means an import from a domain class to a REST-client class in the web layer is, from the compiler's point of view, exactly as valid as an import that respects the team's intended architecture; both compile, both link, both run. This is precisely why the scenario is plausible and common: architecture, as a discipline about which dependencies are allowed, exists purely as a **human convention** until someone encodes it as something a machine checks, and conventions are exactly the kind of thing that erodes under: - deadline pressure - staff turnover - the simple fact that a newly onboarded engineer has no way to discover an unwritten rule except by having their PR caught in review — and reviewers miss things, especially in a large diff or when they don't know the rule exists either ## How the drift happens The mechanism that lets this drift happen without anyone deciding to violate the rule on purpose is almost always **incremental**: a developer under time pressure needs 'just one field' from a REST client type inside a domain class to satisfy a one-off requirement, intends it as temporary, and it survives because nothing forces its removal and the shortcut works. Multiply that by dozens of engineers over eighteen months and you get exactly the kind of isolated but real violation described in the scenario — a single import that nobody meant as a policy decision but that nonetheless breaks the guarantee the rest of the team believes still holds (that the domain layer can be tested and understood without the web framework). ## Encode the rule as an executable check The fix is to stop relying on human vigilance and instead encode the rule as an executable check that runs on every build, the same way a unit test does. | Ecosystem | The standard answer | |---|---| | On the JVM | **ArchUnit** is the standard tool for this: it inspects the compiled class graph and lets you write assertions like 'no class residing in a package matching `..domain..` may depend on a class residing in a package matching `..web..`,' or use its built-in layered-architecture DSL to declare the full set of layers and which may access which. | | In the JavaScript/TypeScript world | **dependency-cruiser** plays the same role, walking the actual import/require graph against a configured rule set and failing with a specific offending edge when a forbidden dependency is found. | Either way, the check runs as part of the normal test suite or a dedicated CI step, so a violating import fails the build with a message naming the exact class and the exact rule broken — functionally identical to how a broken unit test blocks a merge, except the thing being tested is the shape of the dependency graph rather than runtime behavior. ## What the check itself costs This isn't free either, and it's worth being honest about the trade-offs. - **Writing and maintaining the rule set is itself work** — someone has to decide and encode what 'domain' and 'web' mean as package patterns, and keep that current as the codebase's package structure evolves. - **Legitimate exceptions exist** (a small, framework-free shared value type that both layers use, for instance) and need an explicit allowlist or exemption mechanism; without one, the first false positive on a legitimate case leads a frustrated team to disable the check entirely rather than refine it, which is its own failure mode — 'arch rule rot,' where the enforcement technically exists in the repo but is skipped in CI, has an accumulated exemption list broad enough to swallow real violations, or was disabled after one too many false alarms and never re-enabled. ## At the module level A concrete, widely used example of this pattern at the module (not just layer) granularity is **Spring Modulith's application-module verification**, typically wired into the test suite: it walks a Spring application's package structure, infers module boundaries, and fails the build if a module accesses another module's internals in a way its declared allowed dependencies don't permit — turning exactly this scenario's 'nothing caught it before merge' into an immediate, specific, CI-blocking failure the next time someone attempts the same kind of violation.
- What's the difference between an architecture-testing rule and a code review checklist item for catching this kind of violation?A checklist item depends on a human reliably remembering to check it on every PR, which degrades under time pressure, large diffs, or reviewer unfamiliarity with the rule; an architecture-testing rule runs automatically and identically on every build with no chance of being forgotten, and it fails the build the same way any other failing test would, making it a hard gate rather than a soft reminder.
- How would you handle a genuine, legitimate exception to a layer rule, like a small shared enum both the domain and web layers need?Most architecture-testing tools support explicit exemptions — ArchUnit lets you exclude specific classes or packages from a rule, and dependency-cruiser supports allowlist entries in its rule config — so the exception is declared explicitly and visibly in the ruleset rather than silently tolerated, which keeps the rule meaningful for everything else.
- If the rule had existed the whole time, would it have definitely caught this violation before merge?Only if it actually ran in CI on every pull request and the build was configured to block merges on failing tests — a rule that exists in the codebase but is excluded from the CI pipeline, or whose failures are treated as non-blocking warnings, provides no more protection than not having it at all.
Like a company relying on employees to remember an unwritten expense policy versus building an expense-report system that simply rejects a submission that violates the policy — memory fades and people miss the memo; the system doesn't.
saying these in an interview costs you the question
- Assumes 'the compiler would catch that' for an architecture violation
- Has no answer for how to prevent recurrence beyond 'be more careful in review'
- Doesn't know of any tool (ArchUnit, dependency-cruiser, or equivalent) for encoding these rules
- Proposes a rule with no exemption mechanism, or doesn't anticipate needing one