A package diagram in a design doc says controller talks to service,
service talks to repository, and nothing skips a layer. Six months
and forty pull requests later, someone under deadline pressure wires a controller straight to a
repository to save one method call, code review doesn’t catch it because the diff looks
small and reasonable, and the diagram is now a polite fiction. ArchUnit is a
library that turns that diagram into something that actually runs: a JUnit test that reads your
project’s own compiled .class files and fails the build the moment the real
dependency graph stops matching the one you decided on. This post builds the smallest version of
that test, breaks it on purpose to see a real failure, and then does the thing every “just add a
rule” tutorial skips: what you do when the rule you want to add already has violations sitting
in the codebase from before anyone was checking.
If you’ve used JUnit before but never ArchUnit, the first surprise is the one worth sitting with: none of the tests in this post start a Spring application, or any application. They inspect compiled bytecode directly, which is both why they run in about a second and why they can catch a mistake a human reviewer, skimming a diff, genuinely might not.
Versions used in this post. ArchUnit 1.5.1 · JUnit 6.1.3 · Spring Framework 7.0.9 (justspring-contextandspring-webmvc, for the annotations — no Spring Boot, no embedded server, noApplicationContextanywhere in this module) · JDK 25 LTS. Every rule, every failure message, and every line of console output below came out of a realmvn testrun against this post’s companion repository.
The problem code review alone can’t catch
A layered architecture — controllers call services, services call repositories, nothing
calls sideways or backwards — isn’t enforced by Java’s compiler. The compiler
is perfectly happy to let a @RestController take a repository interface as a
constructor argument and call it directly. Nothing turns red. The project still builds, the
tests that exist still pass, and the only record that this was ever a rule is a paragraph in a
README nobody opened before making the change.
Code review is supposed to catch this, and for one obvious violation in one small pull request, it usually does. It stops working at exactly the scale where it matters most: dozens of contributors, hundreds of classes, a reviewer who has fifteen minutes and a diff that touches three files they’ve never looked at before. A rule that depends on every reviewer noticing every violation, forever, is not a rule — it’s a hope. What’s needed is something that checks the actual compiled structure of the codebase the same way a unit test checks the actual behavior of a method: mechanically, every time, with no human attention required to catch the boring cases.
- ArchUnit isn’t the only way to do this — Spring Modulith’s
ApplicationModules.verify(), covered in this series’ module-boundary post, checks an entire codebase’s module boundaries against a convention in one call. ArchUnit is lower-level and more general: it has no opinion about what a “module” is, which is exactly what makes it fit a plain layered architecture, or any other rule you can phrase in terms of packages, classes, fields, and methods.
The smallest correct mental model: a test that reads bytecode, not behavior
Every other test you’ve probably written calls a method and checks what comes back.
ArchUnit tests don’t call anything. @AnalyzeClasses points ArchUnit at a Java
package; it reads every compiled .class file under that package — the same
bytes a JVM would load to actually run the code — and builds an in-memory model of every
class, field, method, constructor, and the calls between them. A rule is then a question asked
of that model: “does any class in the controller package get referenced from
outside the layers I said could reference it?”
The dashed red line in that picture is not hypothetical — it’s the exact violation
the next section captures for real, from a class that existed in this repository for about
thirty seconds. The box at the bottom is the part worth re-reading: there is no “application” in
this diagram at all, because ArchUnit doesn’t need one. It needs the .class
files mvn compile already produced.
The smallest thing that works: three rules, no Spring context, under two seconds
The sample app behind this post is intentionally tiny — a controller, a service, and an in-memory repository, each exactly one class — because the point of this section is the test, not the app. All three rules live in one class:
@AnalyzeClasses(packages = "com.ankurm.tutorials.junit.archunit")
class LayeredArchitectureTest {
@ArchTest
static final ArchRule controllersOnlyCallServicesWhichOnlyCallRepositories = layeredArchitecture()
.consideringAllDependencies()
.layer("Controller").definedBy("..controller..")
.layer("Service").definedBy("..service..")
.layer("Repository").definedBy("..repository..")
.whereLayer("Controller").mayNotBeAccessedByAnyLayer()
.whereLayer("Service").mayOnlyBeAccessedByLayers("Controller")
.whereLayer("Repository").mayOnlyBeAccessedByLayers("Service");
@ArchTest
static final ArchRule noFieldInjection = noFields()
.that().areDeclaredInClassesThat().resideInAPackage("com.ankurm.tutorials.junit.archunit..")
.should().beAnnotatedWith(Autowired.class);
@ArchTest
static final ArchRule packagesAreFreeOfCycles = slices()
.matching("com.ankurm.tutorials.junit.archunit.(*)..")
.should().beFreeOfCycles();
}
archunit/src/test/java/com/ankurm/tutorials/junit/archunit/LayeredArchitectureTest.java
Three completely different kinds of rule, same mechanism underneath. The first is the layering
rule from the diagram above. The second has nothing to do with layers at all — it bans
@Autowired on a field anywhere in the package, because constructor injection is what
lets a test construct a class with plain new and makes a missing dependency a
compile error instead of a null field discovered at 2am. The third checks that no two packages
depend on each other in a cycle, which matters even in a project with only three packages,
because cycles are exactly the kind of thing that creeps in two packages at a time and is
invisible from inside any single one of them. Run it, and nothing in the sample app starts:
[INFO] Running com.ankurm.tutorials.junit.archunit.LayeredArchitectureTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.311 s -- in com.ankurm.tutorials.junit.archunit.LayeredArchitectureTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
Full transcript: archunit/docs/output/00-baseline-passing-run.txt
Three architectural rules, checked across every compiled class in the module, in about 1.3 seconds, with no “Started Application” line anywhere in the output — because nothing here is an integration test. That speed isn’t a minor convenience; it’s what makes it realistic to run these on every single build instead of nightly or “when someone remembers.”
- The full dependency list and the exact Maven/JDK versions this was built against are in the companion repository’s README.
How the layering rule actually reads your code
layeredArchitecture() doesn’t know what a “layer” is until you tell it,
with .definedBy("..controller..") — a package-matching pattern, not a literal
string; the two leading and trailing dots mean “contains this segment anywhere in the package
path.” .consideringAllDependencies() matters more than its name suggests: it tells
ArchUnit to check every kind of reference a class can have to another — field types,
constructor and method parameter types, return types, generic type arguments, annotations,
thrown exceptions, and actual method calls — not only direct method invocations. A
class that merely declares a field of a forbidden type violates the rule even if that
field is never read, because the dependency exists in the compiled bytecode the moment the field
exists.
That’s not a theoretical distinction. It’s exactly what shows up in the real violation captured in the next section: three separate facts about one bad class, reported together, because a constructor parameter, the field it gets assigned to, and the method call that uses it are three distinct dependencies ArchUnit’s bytecode importer sees — even though a human reading the same class would describe it as “one mistake.”
Going deeper: what @AnalyzeClasses actually scans, and how to narrow it
@AnalyzeClasses(packages = "...") is the common case, but the annotation has more to it. packages accepts several strings, scanning the union of all of them. packagesOf takes a class and derives the package from it — handy when you want “whatever package this marker class is in” instead of a string that can drift out of sync with a rename. importOptions takes classes implementing ImportOption; the built-in ImportOption.DoNotIncludeTests is worth knowing even though this post’s own test classes are small enough not to need it — on a real project, scanning your own test helpers and mocks as if they were production classes produces confusing false violations. cacheMode controls whether the imported class graph is cached across multiple @AnalyzeClasses test classes in the same package within one JVM — the default, PER_CLASS, re-imports for every test class; FOREVER reuses the import across the whole run, which matters once a project has more than a couple of ArchUnit test classes and the repeated bytecode scan starts to show up in build time.
What breaks, and exactly what the failure says
A rule you’ve never watched fail is a rule you’re trusting on faith. To see this
one fail honestly, a second controller was added for about thirty seconds — one that takes
OrderRepository directly in its constructor, skipping OrderStatusService
entirely, the exact shape of the “small, reasonable-looking” shortcut from the opening section:
@RestController
public class BadOrderLookupController {
private final OrderRepository orderRepository;
public BadOrderLookupController(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@GetMapping("/orders/{orderId}/raw-status")
public String rawStatus(@PathVariable String orderId) {
return orderRepository.findStatus(orderId).orElse("UNKNOWN");
}
}
This class was deleted immediately after producing the failure below — it never shipped, and there is no source file for it in the repository. The real transcript it produced is captured verbatim at archunit/docs/output/01-layering-violation-failure.txt.
Architecture Violation [Priority: MEDIUM] - Rule 'Layered architecture considering all dependencies, consisting of
layer 'Controller' ('..controller..')
layer 'Service' ('..service..')
layer 'Repository' ('..repository..')
where layer 'Controller' may not be accessed by any layer
where layer 'Service' may only be accessed by layers ['Controller']
where layer 'Repository' may only be accessed by layers ['Service']' was violated (3 times):
Constructor <com.ankurm.tutorials.junit.archunit.controller.BadOrderLookupController.<init>(com.ankurm.tutorials.junit.archunit.repository.OrderRepository)> has parameter of type <com.ankurm.tutorials.junit.archunit.repository.OrderRepository> in (BadOrderLookupController.java:0)
Field <com.ankurm.tutorials.junit.archunit.controller.BadOrderLookupController.orderRepository> has type <com.ankurm.tutorials.junit.archunit.repository.OrderRepository> in (BadOrderLookupController.java:0)
Method <com.ankurm.tutorials.junit.archunit.controller.BadOrderLookupController.rawStatus(java.lang.String)> calls method <com.ankurm.tutorials.junit.archunit.repository.OrderRepository.findStatus(java.lang.String)> in (BadOrderLookupController.java:25)
Full transcript: archunit/docs/output/01-layering-violation-failure.txt
Read that message slowly and it tells you more than “you broke the rule.” It names the exact
constructor, the exact field, and the exact method call — three separate facts about the
same one bad wire, matching the “considers all dependencies” behavior from the previous section.
It gives a real file and line number (BadOrderLookupController.java:25), pulled from
the compiled class’s own debug information, not from re-parsing the source file. And
“Priority: MEDIUM” is a property of the rule itself, not a severity level assigned after the
fact — layeredArchitecture() rules default to that priority, and a build can be
configured to only fail above a chosen threshold.
This is a compile-the-and-run failure, not a lint warning. Nothing about this check needs a human to notice a small diff. If this rule is wired into CI, the pull request introducing BadOrderLookupController fails the build with this exact message, before anyone has to catch it by eye.
What the defaults don’t do: turning a rule on when it already fails
Every example so far assumes you’re adding a rule to code that already satisfies it.
Real codebases rarely offer that luxury. Suppose this project already had, from before anyone
had heard of ArchUnit, a method that declares the vague throws Exception instead of
something specific — a real, if minor, design smell:
public class LegacyOrderExporter {
public void export() throws Exception {
// Deliberately vague: this is the thing a real legacy codebase actually looks like.
}
}
archunit/src/main/java/com/ankurm/tutorials/junit/archunit/service/LegacyOrderExporter.java
A plain rule banning this — noMethods().should().declareThrowableOfType(Exception.class)
— fails on day one, against code nobody is fixing today. The realistic choice most teams
face isn’t “fix everything first” versus “enforce nothing”; it’s a third option
ArchUnit builds in directly: FreezingArchRule.freeze(...) wraps any rule and records
its current violations as an accepted baseline, then only fails the build on a violation that
is new since that baseline was recorded.
@ArchTest
static final ArchRule noNewGenericExceptionsDeclared = freeze(
noMethods().should().declareThrowableOfType(Exception.class)
.because("a specific exception type documents what a caller actually "
+ "has to handle; `throws Exception` documents nothing"));
archunit/src/test/java/com/ankurm/tutorials/junit/archunit/FreezingArchRuleTest.java
Where that baseline lives, and whether it can be created or updated, is controlled by three properties on the classpath:
freeze.store.default.path=archunit_store
freeze.store.default.allowStoreCreation=true
freeze.store.default.allowStoreUpdate=true
archunit/src/test/resources/archunit.properties
Run the test for the first time, with no store on disk yet and creation allowed, and it passes — not because the violation vanished, but because ArchUnit recorded it as already known instead of failing on it:
[INFO] Running com.ankurm.tutorials.junit.archunit.FreezingArchRuleTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.268 s -- in com.ankurm.tutorials.junit.archunit.FreezingArchRuleTest
[INFO] BUILD SUCCESS
Full transcript: archunit/docs/output/02-freeze-run1-baseline-captured.txt
The baseline this run wrote to disk is plain text, checked into the companion repository alongside the code it describes, so it travels with the project the same way the code it covers does:
archunit_store/stored.rules:
no\ methods\ should\ declare\ throwable\ of\ type\ java.lang.Exception,\ because\ a\ specific\ exception\ type\ documents\ what\ a\ caller\ actually\ has\ to\ handle;\ `throws\ Exception`\ documents\ nothing=9e504a56-cf50-43d0-a977-82f8a097091b
archunit_store/9e504a56-cf50-43d0-a977-82f8a097091b:
Method <com.ankurm.tutorials.junit.archunit.service.LegacyOrderExporter.export()> does declare throwable of type java.lang.Exception in (LegacyOrderExporter.java:14)
archunit/archunit_store (committed baseline)
Now the part that actually earns the feature’s name: add a second, genuinely new
throws Exception after the baseline exists, flip allowStoreCreation to
false (the sane setting for CI once a baseline has been established, so nobody can
accidentally wipe it by deleting the store directory), and run again:
[ERROR] FreezingArchRuleTest.noNewGenericExceptionsDeclared -- Time elapsed: 1.145 s <<< FAILURE!
java.lang.AssertionError:
Architecture Violation [Priority: MEDIUM] - Rule 'no methods should declare throwable of type java.lang.Exception, because a specific exception type documents what a caller actually has to handle; `throws Exception` documents nothing' was violated (1 times):
Method <com.ankurm.tutorials.junit.archunit.service.NewOrderBatchJob.run()> does declare throwable of type java.lang.Exception in (NewOrderBatchJob.java:15)
Full transcript: archunit/docs/output/03-freeze-run2-new-violation-caught.txt
Read that failure closely: it names exactly one method, NewOrderBatchJob.run().
LegacyOrderExporter.export() still declares throws Exception too —
nothing about it changed between these two runs — and it is not mentioned anywhere in this
failure, because it was already accepted into the baseline before this run started. A rule that
would otherwise have to fail against every pre-existing offender at once instead fails against
exactly the one thing that showed up after the team agreed to stop adding more of them. That
temporary class was deleted immediately after producing this transcript; the committed repository
keeps the frozen LegacyOrderExporter violation and a clean, green build.
Going deeper: un-freezing a violation once it’s actually fixed
The frozen violation isn’t permanent — it’s a snapshot. Fix LegacyOrderExporter.export() to declare a specific exception type instead of Exception, and on the next run FreezingArchRule notices the violation it had on record no longer occurs and removes it from the store (allowStoreUpdate=true is what permits that). The opposite move — deliberately re-accepting every currently violating case as the new baseline, which is useful right after a large refactor introduces a batch of violations you’ve already reviewed and accepted — is the freeze.refreeze=true property, which replaces the stored baseline with whatever the rule finds right now, rather than only adding to it. That one is worth using deliberately and sparingly: it can just as easily paper over something you meant to fix as accept something you meant to keep.
Should every project wire this in? The three structural rules in this post — layering, constructor injection, no cycles — are cheap enough (under two seconds, zero new dependencies beyond the test scope) that there’s little reason not to, on any project past a handful of classes where more than one person touches the code. Where it earns real caution is scope: an overly specific rule, written to match today’s package layout exactly, becomes a maintenance burden the moment a legitimate refactor comes along, and a team that starts writing dozens of narrow rules without freeze() for the backlog they already have will spend more time arguing with the test suite than their actual code. Start with the handful of rules that encode a decision the team has already explicitly made and would be unhappy to see silently violated — not every stylistic preference anyone has ever had.
Further reading
- archunit companion repository — versions, quickstart, and the full source/output index
- Spring Modulith 2.1: Enforcing Module Boundaries Inside a Spring Boot Monolith — the whole-codebase, convention-based version of boundary enforcement, for when “module” means more than one package
- CQRS in Spring Boot Without a Framework — a hand-written reflection test proving one specific boundary, the same instinct this post generalizes into a reusable rule
- Hexagonal Architecture (Ports and Adapters) in Spring Boot 4 — another architecture proved by a real command instead of a comment, from a different angle
- ArchUnit user guide, the primary reference for the full rule DSL this post only samples
- ArchUnit on Maven Central, for checking the current GA version against what this post used
No Comments yet!