Hello everyone, I actually thought the whole time I had already written an article about this. Then I realized that although I’ve already mentioned ArchUnit a few times, but I haven’t written anything about it, except in my Obsidian Vault. So I’ll catch up on that today.
When the chart lies
We all know this scenario: At the beginning of a project, we create beautiful architectural diagrams. We define layers, modules and dependencies. Everything looks clean – at least on the whiteboard or in the Confluence wiki.
But then reality comes. Deadlines approach, “quick fixes” are implemented, and new team members who don’t know the historical context add dependencies that should be forbidden. Six months later, the code bears little resemblance to the original plan.
Manual code reviews help, but they are error prone. A senior developer might miss on a Friday evening that a service is accessing a DTO directly from the web layer.
The solution? We need to treat our architecture the same way we treat our business logic: we need to test it in an automated way.
What is ArchUnit?
ArchUnit is a Java library that allows us to code architecture rules as executable tests. You can think of it like unit testing, but instead of checking the correctness of a method, we check the structure of our application.
The brilliant thing about it:
- No separate infrastructure: ArchUnit runs with any normal Java unit testing framework (JUnit 4, JUnit 5, TestNG).
- Bytecode Analysis: It imports the compiled Java classes and checks the dependencies at the bytecode level. This is fast and reliable.
- Fluent API: The rules can be read almost like English sentences (e.g.
classes().should().onlyHaveDependentClassesThat()...).
With ArchUnit, we move architecture feedback from “sometime in code review” to “immediately during build.” If someone breaks the rules, the build will fail. Point.
Setup & “Hello World”
First we need the dependency. ArchUnit is lightweight and can be easily integrated via Maven or Gradle.
Maven:
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>1.4.0</version>
<scope>test</scope>
</dependency>
Gradle:
testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.0'
The first test
Instead of writing individual test methods, we often use the @AnalyzeClasses annotation. With this we tell ArchUnit which package (and its sub-packages) to scan.
Here’s a classic rule: Classes in the service package should also have “Service” in their name.
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.classes;
@AnalyzeClasses(packages = "com.julianpaul.blog.example")
public class ArchitectureTest {
@ArchTest
static final ArchRule services_should_be_named_correctly =
classes()
.that().resideInAPackage("..service..")
.should().haveSimpleNameEndingWith("Service");
}
Pro tip: Use static fields (
static final) to define rules. ArchUnit automatically detects these through the@ArchTestannotation. This makes the test code very clean.
The classics: rules that (almost) every project “needs”
Now let’s look at three scenarios that have caused headaches for almost every developer.
A. Layered Architecture prüfen
This is often the main reason for using ArchUnit. We want to make sure that the Controller does not access the Repository directly, but rather goes through the Service.
ArchUnit offers a very powerful API for this that looks almost like a configuration file:
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;
@ArchTest
static final ArchRule layer_dependencies_are_respected =
layeredArchitecture()
.consideringOnlyDependenciesInLayers()
.layer("Controller").definedBy("..controller..")
.layer("Service").definedBy("..service..")
.layer("Persistence").definedBy("..repository..")
.whereLayer("Controller").mayNotBeAccessedByAnyLayer()
.whereLayer("Service").mayOnlyBeAccessedByLayers("Controller")
.whereLayer("Persistence").mayOnlyBeAccessedByLayers("Service");
B. Avoid circular dependencies
Cycles (package A needs B, B needs A) are the death of any modularization. They make refactorings hell. ArchUnit can automatically detect and monitor these “slices”.
import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;
@ArchTest
static final ArchRule no_cycles_in_structure =
slices().matching("com.julianpaul.blog.example.(*)..")
.should().beFreeOfCycles();
This one-liner ensures that top-level modules do not reference each other.
C. Clean interfaces
A detail that is often overlooked: We don’t want implementation details (like JPA entities) to leak into the API layer (controller). Controllers should only return DTOs (Data Transfer Objects).
@ArchTest
static final ArchRule controllers_should_not_return_entities =
classes().that().resideInAPackage("..controller..")
.should().noMethod().should().haveRawReturnType(residesInAPackage("..entity.."))
.because("wir wollen unsere interne Datenbank-Struktur nicht nach außen exponieren");
Note: The
.because(...)method is optional but extremely valuable. It explains to the developer in the error message why the test failed, rather than just that it failed.
For professionals: ArchUnit in legacy projects (freezing)
The biggest obstacle to introducing architecture testing in existing projects is the amount of legacy issues. If you enable the “no cycles” rule in a 5 year old monolith, you will probably get hundreds of errors.
The team’s reaction? “We can’t fix everything, let’s expand ArchUnit again.”
Here comes the killer feature: FreezingArchRule.
With “Frozen Rules” we can accept the current state but prevent deterioration. ArchUnit remembers all existing violations in a file (the Violation Store) and ignores them in future tests. But: As soon as someone creates a new injury, the test fails.
Here’s how it works
You simply wrap your rule in FreezingArchRule.freeze():
import com.tngtech.archunit.library.freeze.FreezingArchRule;
@ArchTest
static final ArchRule no_cycles_frozen =
FreezingArchRule.freeze(
slices().matching("com.julianpaul.blog.example.(*)..")
.should().beFreeOfCycles()
);
The first time you run it, the following happens:
- ArchUnit finds all cycles.
- It creates a text file in the
archunit_storefolder where these errors are stored as UUIDs. - The test turns green!
Now when you repair one of the old cycles, ArchUnit will automatically update the store. The error is gone and must not come back. This is the perfect way to reduce technical debt step by step without keeping the build permanently red.
Conclusion
ArchUnit bridges the gap between the architecture diagram on the wall and the actual code in the repository. It transforms architecture from a “moral obligation” into a hard build metric.
My advice for starting:
- Start small: Check simple things like package cycles or naming conventions first.
- Use Freezing: Don’t try to clean up the entire project in one day.
- Explain the “Why”: Use
.because()to help your colleagues understand why the build failed.
Code quality is not a coincidence, but a choice. With ArchUnit we can ensure that this decision remains valid in the long term.
![[EN] Unit Tests für deine Architektur? Einführung in ArchUnit](/images/ArchUnit-BlogHeader.jpeg)