A team wants to guarantee that classes in a controller package never call classes in a repository package directly, skipping the service layer, and that this is caught automatically if someone violates it. Visibility modifiers alone can't express this since both packages are legitimately public within the same module. How would an ArchUnit rule enforce this, and why is a runnable test needed here instead of relying on code review?
answer
- reads compiled bytecode, builds dependency graph
- noClasses().should().dependOnClassesThat() DSL
- layeredArchitecture() pre-built rule set
- runs as a normal JUnit test in CI
- freeze/baseline API for adopting against legacy violations
basics
~20 sArchUnit is a testing library that reads your compiled Java/Kotlin code and lets you write rules like 'classes in the controller package must not access classes in the repository package.' You put that rule in a normal unit test, so if someone writes code that breaks the layering, the test fails and the build breaks — catching it automatically instead of hoping a reviewer notices.
solid answer
~50 sArchUnit inspects compiled bytecode to build a graph of which classes reference which, then lets you assert structural rules with a fluent API, such as noClasses().that().resideInAPackage(\"..controller..\").should().dependOnClassesThat().resideInAPackage(\"..repository..\"), run as a plain JUnit test. This matters because layering is a cross-package rule about the relationship between two packages that are each internally public — no single class's visibility modifier can express 'repository is public, but only to service, not to controller.' A code-review-only backstop is unreliable: reviewers miss transitive/indirect violations, such as a controller calling a helper that calls the repository, the rule isn't documented anywhere machine-checkable, and it silently erodes as the team grows or turns over. Running it as a test means it executes on every CI build, catches violations at the commit that introduced them, cheap to fix, rather than months later, expensive to unwind, and it doubles as living documentation of the intended architecture.
go deeper
Should understand that an automated test can check 'this package shouldn't call that package' and that it runs like any other test.
Should be able to sketch the basic ArchUnit DSL shape and explain why visibility modifiers can't express layering.
Should design layering rules including handling for legitimate cross-cutting exceptions, and know the freeze/baseline adoption pattern for legacy code.
Should decide, across a whole platform, which architectural rules deserve an ArchUnit test versus a module system boundary, and own the rollout/adoption strategy including handling false positives at scale.
## How ArchUnit works ArchUnit works by reading already-compiled `.class` files, via a bytecode-analysis library (ASM) under the hood, and constructing an in-memory graph of every class, its fields, methods, and every reference one class makes to another: - method calls - field accesses - type usage - inheritance On top of that graph it exposes a **fluent DSL** for asserting structural rules, run as ordinary JUnit tests: `noClasses().that().resideInAPackage("..controller..").should().dependOnClassesThat().resideInAPackage("..repository..")` walks every class ArchUnit found in a package matching `..controller..` and asserts that none of its outgoing dependencies land in a class from a package matching `..repository..`, where a violation produces a normal JUnit assertion failure naming the exact class and dependency that broke the rule. ArchUnit also ships **pre-built rule sets** for common shapes, notably `Architectures.layeredArchitecture()`, which lets you declare named layers and `mayOnlyBeAccessedByLayers(...)` relationships in one readable block instead of hand-writing each pairwise rule. ## Why a visibility modifier cannot express layering Layering is fundamentally a statement about the relationship between two packages, not a property any single class's visibility modifier can express. Both controller and repository are legitimately public, or internal, within the same module: - the repository has to be reachable by the service layer; - the controller has to be reachable by the framework's dispatcher. So no visibility level says "public to service, but not to controller." Only a rule that inspects the actual call graph across the whole codebase, rather than a per-class access check, can enforce that kind of cross-cutting architectural constraint, which is exactly the gap between what the compiler already gives you for free and what a dedicated architecture test is needed for. ## The cost — the rules are code too The cost is that these rules require ongoing authorship and maintenance — someone has to write the rule, decide package-matching patterns precisely enough to catch real violations without false-positiving on legitimate exceptions, such as a shared `dto` or `mapper` package legitimately reachable from both layers, and keep it updated as the codebase's package structure evolves. - Rules that are **too strict** block legitimate work and get bypassed or disabled under deadline pressure if the team doesn't trust them. - Rules that are **too loose** give false confidence — the test suite is green, but the architecture has actually drifted. Adopting these tests against a pre-existing, already-violating codebase also has a bootstrapping cost, typically solved with a freeze/baseline mechanism, such as ArchUnit's `FreezingArchRule`, that records existing violations and only fails on new ones, letting the team pay down debt incrementally. ## Why code review alone misses this Code review alone misses layering violations for structural reasons. 1. **Reviewers see one diff at a time** and rarely have the whole call graph in their head, so an indirect violation — controller calls a shared `OrderHelper` utility that itself calls the repository — is easy to miss since the diff never mentions "repository" by name. 2. **The rule isn't written down anywhere machine-checkable**, so new team members have no artifact to consult besides tribal knowledge. 3. **The constraint silently erodes** as the team grows, turns over, or forgets under deadline pressure — nothing fails loudly, the violating code just merges and sits there, usually discovered only when someone later tries to refactor the repository layer and finds unexpected callers reaching straight past the service layer. A test, in contrast, runs on every single CI build, fails at the exact commit that introduced the violation when it's cheap to fix, rather than months later when it might require untangling dozens of call sites, and simultaneously serves as living, runnable documentation of the intended architecture that never goes stale the way a wiki page does. ## Where it shows up — a Spring Modulith backend A Spring Modulith Kotlin backend typically layers this exact rule two ways: a plain ArchUnit `layeredArchitecture()` rule enforcing controller-to-service-to-repository call direction within a module, plus Spring Modulith's own `ApplicationModules.of(...).verify()` enforcing the coarser, module-to-module allowed-dependency graph. Both run as fast JUnit tests, with no server, database, or network involved, so they execute in every CI run and in a local fast quality gate, catching an accidental layering or module-boundary violation within seconds of it being written rather than after it ships and someone tries to unwind months of accumulated shortcuts.
- If both the controller and repository packages are marked public/internal, visible to each other, what actually stops the layering rule from being just a style preference nobody enforces?Without an automated architecture test, nothing does — it's purely a convention, exactly as fragile as any unenforced rule under deadline pressure and team turnover. The ArchUnit test is what converts the preference into a hard constraint: a violation fails the build, enforced the same way a compile error would be, just one layer up from what visibility modifiers alone can express.
- How would you adopt a layering ArchUnit rule against a large legacy codebase that already has dozens of violations, without blocking the whole team's ongoing work?Use ArchUnit's freeze/baseline mechanism, FreezingArchRule, which records currently-known violations into a baseline file the first time the rule runs and only fails the build on genuinely new violations going forward. This lets the team stop the bleeding immediately while paying down the existing debt on its own schedule, the same pattern used for static-analysis baselines like detekt or ktlint.
- What kind of violation would an ArchUnit layering test catch that a simple package-private/internal visibility check would not?An indirect violation — for example, a controller class calling a shared OrderHelper utility class, itself legitimately visible to the controller package, that in turn calls the repository directly. No single class's visibility modifier is violated anywhere in that chain, since every individual reference is technically permitted; only a rule inspecting the full transitive dependency graph catches that the controller layer ultimately reaches the repository layer at all.
ArchUnit is like a building inspector who checks the actual wiring behind the walls rather than trusting the room labels — a room can be correctly labeled 'kitchen,' but the inspector still verifies no gas line was accidentally run straight into the bedroom.
saying these in an interview costs you the question
- Thinks ArchUnit rules run at compile time rather than as JUnit tests against compiled bytecode
- Believes visibility modifiers alone can express a layering rule between two mutually-public packages
- Assumes code review is sufficient and an automated architecture test is redundant
- Doesn't know indirect/transitive violations exist and can bypass a naive review
- Can't describe any strategy for adopting the rule against a codebase with pre-existing violations