Skip to main content

ArchUnit for Spring Boot: Enforcing Layered Architecture as a Unit Test

A layered-architecture rule, a no-field-injection rule, and a no-cycles rule, all checked against real compiled bytecode in under two seconds with no Spring context anywhere — plus a real captured violation, and a FreezingArchRule demonstration showing how to turn a rule on against a codebase that already fails it.

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 (just spring-context and spring-webmvc, for the annotations — no Spring Boot, no embedded server, no ApplicationContext anywhere in this module) · JDK 25 LTS. Every rule, every failure message, and every line of console output below came out of a real mvn test run 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?”

Allowed dependencies flow one way; ArchUnit checks that nothing else does controller OrderStatusController service OrderStatusService repository OrderRepository skips the service layer — this is the violation BadOrderLookupController constructs with OrderRepository directly @AnalyzeClasses reads .class files off disk — no ApplicationContext, no server, nothing started

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.”

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)

freeze() changes which violations fail the build, not which ones exist before any rule exists LegacyOrderExporter.export() throws Exception — nobody is checking yet after freeze(), baseline recorded LegacyOrderExporter frozen — passes NewOrderBatchJob added later — fails same class, same violation, now accepted mvn test after both exist 1 failure reported: NewOrderBatchJob only LegacyOrderExporter is never mentioned — it was already known before this run started

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

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.