Architecture Testing for Java with ArchUnit: Stop Trusting Your Diagram, Start Testing It
Your architecture diagram says controllers never touch the repository directly. Your codebase disagrees.
It happens quietly. Someone is in a hurry, injects a CourseRepository straight into a controller to skip a service method, and the pull request gets approved because the tests pass and the change is small. The diagram in the wiki still shows a clean three-layer flow. The code no longer matches it. Six months and forty shortcuts later, the “layered architecture” is a suggestion, and nobody can tell you which rules are still true.
Layering is only one shape this problem takes. If you organize your code package-by-feature instead of by layer, the same erosion happens across feature boundaries: the shared infrastructure package starts importing feature classes, controllers begin returning JPA entities instead of DTOs, and what was supposed to be a set of clean boundaries quietly turns into a tangle. The good news is that the same tool solves all of these problems, and you do not need layer packages to enforce layer rules.
Architecture testing is how you close that gap. Instead of documenting your rules in a diagram that no one reads, you encode them as executable tests that fail the build the moment someone violates them. The rule stops being a convention people are supposed to remember and becomes a sensor that runs on every commit.
An important framing before going further, and it connects to my previous posts on harness engineering and mutation testing with PIT: ArchUnit is not a new category of test you bolt on the side. It is a fitness function, a computational feedback sensor that observes your codebase and reports whether it still respects the structure you designed. Where PIT guards the behavior of your code, ArchUnit guards its shape.
ArchUnit is the most widely used architecture testing library for the JVM. It runs as plain JUnit tests, needs no special runner, and reads your compiled classes to reason about packages, dependencies, and naming. This post walks through setting up ArchUnit in a Spring Boot project with Maven and JUnit 5, writing rules that actually earn their keep, adopting it on a legacy codebase without drowning in violations, and running it in CI.
Want the code? Every rule in this post comes from a real project, the Spring Boot API of my full-stack CRUD example: loiane/crud-angular-spring
In this post, we cover:
- Why architecture diagrams rot and what architecture testing measures instead
- How ArchUnit reads your bytecode to reason about structure
- Setting up ArchUnit in a Spring Boot + Maven + JUnit 5 project (including the Surefire gotcha that silently skips every rule)
- The rule categories that cover most real needs: layers, dependencies, naming, cycles, and DTO discipline
- Enforcing layer rules in a package-by-feature codebase, where there are no layer packages to point at
- Why the same AI agent writing both sides of a contract is exactly the drift ArchUnit catches
- Reading the results, including the HTML report that shows exactly what a rule caught
- Adopting ArchUnit on a legacy codebase with
freeze, the one-way gate that keeps you honest - Running architecture tests in GitHub Actions as part of the normal build
Why Architecture Diagrams Rot
A diagram answers one question: what did we intend the structure to be? It never answers the question that actually matters in month six: what is the structure right now?
The gap is structural, not accidental. A diagram is a snapshot taken at design time. Code changes every day. Nothing connects the two, so they drift apart the moment the first shortcut ships. The diagram is feedforward guidance with no feedback sensor attached, and guidance without a sensor is just a hope.
Consider a standard Spring Boot layering rule: controllers depend on services, services depend on repositories, and nothing reaches backward. The rule says a controller should call a service, never a repository directly. Here is the shortcut that breaks it, and nothing in the compiler or the test suite stops it:
1
2
3
4
5
6
7
8
9
@RestController
public class CourseController {
private final CourseRepository repository;
public CourseController(CourseRepository repository) {
this.repository = repository;
}
}
This compiles. The unit tests pass. Code review might catch it, if the reviewer happens to know the rule and happens to be paying attention on a Friday afternoon. Architecture testing catches it every time, deterministically, before the merge.
The formal idea comes from evolutionary architecture: an architecture fitness function is an objective, automated check that a given architectural characteristic still holds. A layering rule expressed as an ArchUnit test is a fitness function. It converts a rule you hope people follow into a rule the build enforces.
How ArchUnit Works
ArchUnit imports your compiled classes and builds an in-memory model of your codebase: classes, packages, methods, fields, and the dependencies between them. You then write rules against that model using a fluent API, and each rule runs as a normal JUnit test.
There is no bytecode rewriting and no separate runner. ArchUnit reads the .class files on your test classpath, so it sees exactly what the compiler produced, including dependencies you did not write explicitly. Because it is just JUnit, an architecture violation shows up as a failing test in the same report as everything else.
Three things are worth internalizing up front:
| Concept | What it means |
|---|---|
| Import | ArchUnit scans a set of packages and builds a JavaClasses model once per test class |
| Rule | A fluent expression like noClasses().that()...should()... that either holds or fails |
| Violation | A specific class-and-dependency pair that breaks a rule, reported with the exact line |
Unlike mutation testing, architecture analysis is cheap. Importing a few thousand classes and evaluating a handful of rules takes a second or two. That single fact changes where the sensor lives: PIT belongs behind a slow Maven profile, but ArchUnit is fast enough to run in the normal mvn test phase on every build. More on that in the CI section.
Setting Up ArchUnit in a Spring Boot Project
The Example Project
The example is the Spring Boot API from loiane/crud-angular-spring, the full-stack CRUD application I use across this blog and my videos. The backend lives in the crud-spring folder, and it is deliberately not organized package-by-layer. It is organized package-by-feature: everything about courses lives together in one package, with shared infrastructure kept separate.
1
2
3
4
5
6
7
8
9
10
11
12
crud-spring/src/
main/java/com/loiane/
course/ the feature package
Course.java Lesson.java
CourseController.java CourseService.java CourseRepository.java
dto/ (records + MapStruct-style mapper)
enums/ (Category, Status + JPA converters)
exception/ business exceptions
shared/ controller advice, custom validators
test/java/com/loiane/
architecture/
ArchitectureTest.java every rule in this post
That layout matters for this post. Most ArchUnit tutorials assume ..controller.., ..service.., and ..repository.. packages you can point rules at. Real package-by-feature codebases do not have those, and the rules below show how to enforce the same layering with class predicates — names and annotations — instead of package names. If your project is package-by-layer, the same rules get simpler, not harder.
Prerequisites
- Java 25. ArchUnit reads standard class files and keeps pace with recent JDKs.
- Maven 3.9+ (the repo works with a standard Maven install;
mvn -f crud-spring/pom.xml testruns everything). - The project runs on Spring Boot 4.1 with JUnit 5 coming transitively through the
spring-boot-starter-teststarter. The rules are identical on Spring Boot 3.x; only the starter names differ.
The One Dependency
Add the JUnit 5 flavor of ArchUnit as a test dependency. It bundles the core library plus the @AnalyzeClasses and @ArchTest integration.
1
2
3
4
5
6
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>1.4.1</version>
<scope>test</scope>
</dependency>
That is the entire setup. There is no plugin, no profile, and no separate goal. Architecture tests are ordinary JUnit tests, so mvn test already runs them.
Your First Rule
Create a test class annotated with @AnalyzeClasses to declare the packages to import, then express rules as static fields annotated with @ArchTest. ArchUnit imports the classes once and evaluates every @ArchTest field against that model.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.loiane.architecture;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
@AnalyzeClasses(packages = "com.loiane", importOptions = ImportOption.DoNotIncludeTests.class)
class ArchitectureTest {
@ArchTest
static final ArchRule servicesShouldNotDependOnControllers = noClasses()
.that().haveSimpleNameEndingWith("Service")
.should().dependOnClassesThat().haveSimpleNameEndingWith("Controller");
}
Notice there is no package name in the rule. Because the codebase is package-by-feature, CourseService and CourseController live in the same package, so a package-based rule could never separate them. The naming convention is the layer marker, and the rule leans on it.
Run it like any other test:
1
mvn test
If a service ever imports a controller, this test fails with a message naming the exact class and the exact dependency. The DoNotIncludeTests import option keeps your test classes out of the analysis, so a test helper that reaches across layers does not produce false positives.
The Surefire Gotcha
There is one setup trap that will make you think everything works when nothing does. Surefire 3.5.3 fails to run @ArchTest rules declared as fields (TNG/ArchUnit#1442). Rules declared as methods still run, but fields are the idiomatic style and every rule in this post uses them. Your mvn test run stays green, but the report says Tests run: 0 for every architecture test class. The rules never execute, so they never catch anything.
This is the ArchUnit equivalent of the silent-no-assertion problem: a build that passes precisely because it is not checking anything. If your Spring Boot version manages an affected Surefire release, pin it to a known-good version until the regression is resolved:
1
2
3
4
<properties>
<!-- Surefire 3.5.3 silently reports "Tests run: 0" for @ArchTest fields -->
<maven-surefire-plugin.version>3.5.2</maven-surefire-plugin.version>
</properties>
The tell is always the same: if adding a rule you know is violated still produces a green build with zero tests, Surefire is not running your ArchUnit rules. Check the Tests run count, not just the color of the build.
The Rule Categories That Earn Their Keep
ArchUnit can express almost any structural rule, but in practice a handful of categories cover the vast majority of what a Spring Boot codebase needs: layer dependencies, boundary rules, naming and annotation conventions, DTO discipline, cycles, and general hygiene. Every rule below is live in the crud-angular-spring ArchitectureTest and runs on every build. Start here.
Layer Dependencies Without Layer Packages
The core layering contract has two halves: lower layers never depend on upper layers, and repositories are only reachable from services. In a package-by-feature codebase, both are expressed with naming predicates.
1
2
3
4
5
6
7
8
9
10
11
@ArchTest
static final ArchRule repositoriesShouldNotDependOnServicesOrControllers = noClasses()
.that().haveSimpleNameEndingWith("Repository")
.should().dependOnClassesThat().haveSimpleNameEndingWith("Service")
.orShould().dependOnClassesThat().haveSimpleNameEndingWith("Controller");
@ArchTest
static final ArchRule repositoriesOnlyAccessedByServices = classes()
.that().haveSimpleNameEndingWith("Repository")
.should().onlyBeAccessed().byClassesThat().haveSimpleNameEndingWith("Service")
.orShould().onlyBeAccessed().byClassesThat().haveSimpleNameEndingWith("Repository");
The second rule is the one that kills the shortcut from the intro. The moment CourseController gets a CourseRepository injected, the rule fails and names the constructor parameter, the field, and every call site.
If your project uses layer packages instead (the classic controller, service, repository layout), ArchUnit’s built-in layeredArchitecture() API expresses the whole contract in one declaration:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@AnalyzeClasses(
packages = "com.example.app",
importOptions = ImportOption.DoNotIncludeTests.class
)
class LayeredArchitectureTest {
@ArchTest
static final ArchRule layer_dependencies_are_respected = 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");
}
This is the same sensor, different predicate. The layeredArchitecture() API reads the contract almost like prose, and a violation reports both the offending class and the layer rule it broke. For a package-by-feature codebase like crud-angular-spring, there are no layer packages to point at, so the naming-predicate approach above is the right fit — but both express the same architectural intent.
Boundary Rules: Shared Code Stays Generic
Package-by-feature comes with its own boundary contract: shared infrastructure may be used by features, but must never depend on them. The day shared imports a course class, it stops being shared and becomes a hidden appendage of one feature.
1
2
3
4
@ArchTest
static final ArchRule sharedShouldNotDependOnFeatures = noClasses()
.that().resideInAPackage("com.loiane.shared..")
.should().dependOnClassesThat().resideInAPackage("com.loiane.course..");
This is a one-feature codebase, so one rule covers it. As features multiply, the same shape scales: each new feature package gets added to the forbidden list, or you flip to allowlist style with onlyHaveDependentClassesThat() per feature. For a large modular monolith, ArchUnit’s modules API can replace the per-feature repetition with a single rule driven by an annotation on each feature’s package-info.java, but do not reach for that machinery until the explicit rules stop being readable.
Naming and Annotation Conventions
Naming rules feel cosmetic until you realize they are what make the other rules reliable. Every layer rule above keys on class names ending in Controller, Service, or Repository, so those names have to stay honest. These rules make the convention self-enforcing:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@ArchTest
static final ArchRule controllersAreAnnotatedAndPlacedCorrectly = classes()
.that().haveSimpleNameEndingWith("Controller")
.and().resideOutsideOfPackage("com.loiane.shared..")
.should().beAnnotatedWith(RestController.class)
.andShould().resideInAPackage("com.loiane.course..");
@ArchTest
static final ArchRule servicesAreAnnotatedWithService = classes()
.that().haveSimpleNameEndingWith("Service")
.should().beAnnotatedWith(Service.class);
@ArchTest
static final ArchRule repositoriesAreSpringDataInterfaces = classes()
.that().haveSimpleNameEndingWith("Repository")
.should().beInterfaces()
.andShould().beAssignableTo(Repository.class);
Note the resideOutsideOfPackage("com.loiane.shared..") carve-out in the first rule: the shared package contains ApplicationControllerAdvice, which ends in “Advice” but lives alongside controller-adjacent code. Scoping predicates like this are how you keep name-based rules precise instead of noisy.
DTO Discipline: Controllers Never Expose Entities
This is my favorite rule in the whole test class, because it enforces a contract that code review misses constantly: REST controllers accept and return DTOs, never JPA entities. Exposing an entity leaks your persistence model into your API, drags lazy-loading and serialization problems into the web layer, and couples every API client to your database schema.
1
2
3
4
5
6
7
8
@ArchTest
static final ArchRule controllersDoNotExposeEntities = noMethods()
.that().areDeclaredInClassesThat().areAnnotatedWith(RestController.class)
.and().arePublic()
.should().haveRawReturnType(Course.class)
.orShould().haveRawReturnType(Lesson.class)
.orShould().haveRawParameterTypes(Course.class)
.orShould().haveRawParameterTypes(Lesson.class);
The moment someone changes CourseController to return a Course entity instead of a CourseDTO — the classic shortcut when a new field is needed in the response — this rule fails and points at the exact method. In a larger codebase, replace the explicit class list with a predicate like areAnnotatedWith(Entity.class) so new entities are covered automatically.
Cycle Detection
Package cycles are the slow poison of a codebase. They make modules impossible to reason about in isolation and impossible to extract later. ArchUnit’s slices() API cuts the codebase into slices by package and asserts they form no cycles.
1
2
3
4
@ArchTest
static final ArchRule noPackageCycles = slices()
.matching("com.loiane.(*)..")
.should().beFreeOfCycles();
In this project the slices are course, exception, and shared. The rule guarantees that no matter how those packages grow, shared and course can never end up depending on each other in both directions.
General Hygiene
Two more rules from the test class pay for themselves immediately, with zero configuration cost:
1
2
3
4
5
6
7
8
@ArchTest
static final ArchRule noFieldInjection = fields()
.should().notBeAnnotatedWith(Autowired.class)
.because("constructor injection is required");
@ArchTest
static final ArchRule noJavaUtilLogging = noClasses()
.should().dependOnClassesThat().resideInAPackage("java.util.logging..");
The field-injection rule nudges the whole team toward constructor injection without a single code review comment. ArchUnit also ships ready-made versions of these in GeneralCodingRules (NO_CLASSES_SHOULD_USE_FIELD_INJECTION, NO_CLASSES_SHOULD_USE_JAVA_UTIL_LOGGING, NO_CLASSES_SHOULD_THROW_GENERIC_EXCEPTIONS) if you prefer the one-liners; writing them by hand, as here, buys you the custom .because(...) message.
Reading the Results
Because architecture rules are ordinary JUnit tests, their results show up wherever your test results already do. A passing run is quiet: every rule is a green test. The interesting output is a failure.
To turn the raw results into something a reviewer can read in a browser, add the maven-surefire-report-plugin and generate an HTML report:
1
mvn -Dmaven.test.failure.ignore=true surefire-report:report
The report lands in target/reports/surefire.html. Each architecture test class shows up with its rules, and any failing rule is flagged with its full violation message. Here is the per-class summary after someone injected a repository straight into a controller:
Drilling into the failing rule shows exactly what ArchUnit caught, down to the class, the field, the constructor parameter, and the line number:
This is the payoff of a computational sensor. The message is not “something is wrong with your architecture.” It is “this controller has a constructor parameter, a field, and a method call that all reach into the repository, at these exact lines.” A reviewer, or an agent reading the failure, knows precisely what to fix.
Why This Matters More When an AI Writes the Code
I have been building toward this point across the whole specs-driven development series, and architecture testing is where it lands hardest.
When a human writes a controller on Monday and a service on Thursday, the layering conventions live in that person’s head and stay reasonably consistent. When an AI agent generates a controller, a service, and a repository in a single session, it optimizes for making the immediate feature work, not for preserving the boundaries you designed three sprints ago. The agent will happily inject a repository into a controller, or return the JPA entity instead of the DTO, if that is the shortest path to a green test. It is not being careless. It simply has no sensor telling it the boundary exists.
This is the exact failure the harness frame predicts. An instruction in copilot-instructions.md that says “controllers must go through services” is an inferential feedforward guide: it steers the model, but it is a suggestion the model can quietly ignore under pressure. An ArchUnit rule is a computational feedback sensor: it observes what the agent actually produced and fails the build if the boundary was crossed. You want both, but only one of them cannot be talked out of.
The move is the same one I described for the TDD gate in the four-agent kit: take a polite request the agent might ignore and convert it into a deterministic check the build refuses to skip. ArchUnit is that conversion for structure. It is the sensor that catches an agent drifting the architecture at midnight, and it does so whether the code was written by a person, a pair, or a prompt.
Adopting ArchUnit on a Legacy Codebase
Here is the honest problem. The crud-angular-spring project adopted these rules while it was still small, so the first run was green. You add the same sensible layering rule to a codebase that has been running for three years, and the first mvn test reports two hundred violations. Blocking the build on all of them on day one creates so much friction that the team deletes the rule by Friday. This is the same adoption trap I described for PIT thresholds: a rule set too strict against existing debt gets abandoned.
ArchUnit’s answer is freeze. Wrapping a rule in FreezingArchRule.freeze(...) records the current violations to a store on the first run and then only fails the build on new violations. Existing debt is frozen, not ignored, and every violation you fix is removed from the store automatically, so the baseline only shrinks over time.
1
2
3
4
5
6
7
8
9
import static com.tngtech.archunit.library.freeze.FreezingArchRule.freeze;
@ArchTest
static final ArchRule repositoriesOnlyAccessedByServices = freeze(
classes()
.that().haveSimpleNameEndingWith("Repository")
.should().onlyBeAccessed().byClassesThat().haveSimpleNameEndingWith("Service")
.orShould().onlyBeAccessed().byClassesThat().haveSimpleNameEndingWith("Repository")
);
The first run creates a violation store (by default a directory named archunit_store) that you commit to the repository. From then on:
- A new violation fails the build. The rule protects you going forward.
- A fixed violation shrinks the store. The baseline can only get better.
- The frozen store is the honest record of the debt you have left to pay down.
To let ArchUnit create the store on the first run, set the property once:
1
2
# src/test/resources/archunit.properties
freeze.store.default.allowStoreCreation=true
This is a one-way gate: the baseline of allowed violations can only shrink, never grow. It is what lets a real team adopt architecture testing on a real codebase without a two-week cleanup sprint before the first green build. Freeze the debt, block new violations, pay down the baseline as you touch each area.
Running ArchUnit in GitHub Actions
Because architecture tests are ordinary JUnit tests, they run as part of mvn test, which means they are already covered by whatever CI runs your unit tests. That is the payoff of a fast sensor: it does not need its own pipeline stage.
The crud-angular-spring repository goes one step further and gives architecture tests their own named job in the build workflow, alongside the Maven build, the Angular build, and the PIT mutation job. The job is trivial because the tests are just JUnit:
1
2
3
4
5
6
7
8
9
10
11
12
13
architecture-java:
name: Maven Architecture Tests (ArchUnit)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Set up JDK 25
uses: actions/setup-java@v5
with:
java-version: '25'
distribution: 'temurin'
cache: maven
- name: Run ArchUnit tests
run: mvn -B test -Dtest=ArchitectureTest --file crud-spring/pom.xml
A few decisions worth noting:
- A failing architecture rule fails the JUnit test, which fails the job, which turns the pull request red. No extra gating configuration is needed.
- The rules also run inside the main
Maven Buildjob, since they are part of the normal test suite. The dedicated job exists for signal, not coverage: when architecture drifts, the pull request shows a red check named “Architecture Tests” instead of a generic build failure, and the reviewer knows the shape broke, not the behavior. - If you adopt
freeze, commit thearchunit_storedirectory. CI reads the frozen baseline from the repository, so a fresh runner enforces exactly the same debt line as your machine.
Keeping the Rules Trustworthy
A rule set is only as useful as it is honest. A few practices keep ArchUnit from decaying into noise the team routes around:
Exclude test classes from analysis. Use importOptions = ImportOption.DoNotIncludeTests.class. Test fixtures legitimately reach across layers, and analyzing them produces violations that train the team to ignore failures.
Scope generated code out. MapStruct mappers, QueryDSL Q-types, and OpenAPI clients are generated into your packages and will trip naming and dependency rules. Exclude them with a custom ImportOption or a .that().areNotAnnotatedWith(Generated.class) predicate.
Prefer a few sharp rules over many fuzzy ones. The same lesson as PIT mutators: a small set of rules the team understands and trusts beats a sprawling set that everyone learns to freeze away. The crud-angular-spring test class has eleven rules, and every one of them guards a contract that has a name: layering, shared-code direction, naming, DTO discipline, cycles, injection style. Add rules when a real violation motivates them.
Write the failure message for the next developer. ArchUnit lets you attach a custom reason with .because("constructor injection is required"), as the field-injection rule above does. That sentence is the difference between a developer fixing the design and a developer disabling the test.
Conclusion
An architecture diagram is a necessary starting point. It is not a sufficient signal that your structure still holds. Architecture testing is what closes the gap. It tells you not just what you intended the shape of the system to be, but whether the code you shipped this week still respects it.
ArchUnit is the most practical way to add architecture testing to a Java project today. With a single test dependency and a handful of rules, you can encode your layering, dependency, naming, and cycle constraints as executable fitness functions, adopt them on a legacy codebase with freeze, and run them in CI as part of the build you already have, with no extra pipeline stage. And as the crud-angular-spring project shows, you do not need layer packages to do it: naming and annotation predicates enforce the same contracts in a package-by-feature codebase.
A reasonable adoption path:
- Add the
archunit-junit5dependency. Write one layering rule. Run it locally to see where you stand. - Wrap the rule in
freezeso existing violations are baselined, not blocking. Commit the store. - Add cycle detection and the no-field-injection rule. These pay off immediately.
- Add naming rules so your name-based layer predicates stay reliable as the codebase grows.
- Add the DTO-discipline rule so your API contract stops depending on reviewer attention.
- Let the normal
mvn testin CI enforce the rules. Gate pull requests on the same build you already run. - Pay down the frozen baseline as you touch each area. It only moves in one direction: down.
Where PIT told you whether your tests would catch a behavioral bug, ArchUnit tells you whether your codebase still has the shape you designed. Together they are two sensors on the same harness: one for what the code does, one for how the code is built. In a world where an agent can write a whole feature before lunch, both are the difference between a system you can trust and a diagram you used to believe.
References
- ArchUnit official site - documentation home
- ArchUnit User Guide - the full rule API reference
- ArchUnit examples repository - runnable rule samples, including Spring layering
- Freezing arch rules - the one-way gate for legacy adoption
- Building Evolutionary Architectures - the fitness function concept ArchUnit implements
- loiane/crud-angular-spring - the project every rule in this post comes from
Happy Coding!


