ArchUnit is a nice tool that has finally received the attention it deserves. Unfortunately, people tend to focus on the very narrow use of it. The problem begins with the official website that advertises it as a library to check the architecture of Java/JVM systems, and more than that, loves to focus on UML diagrams. Plus, the name itself creates predictable associations. As a result, nearly all articles presenting ArchUnit repeat the same topics: detection of cyclic dependencies and counting software layers.
Don’t get me wrong; these aren’t completely unimportant things. But in daily life, there are down-to-earth issues that deserve more attention. For example, calling library functions that behave in unpredictable and dangerous ways. Or people forgetting to add some annotations and introducing bugs that are hard to diagnose.
You can use ArchUnit to guard against those non-architectural issues. Things that are hard to achieve in existing style checkers/linters like CheckStyle or ktlint can be done in ArchUnit without much effort. Contrary to those tools, rules written in ArchUnit are not language-specific. You can have a Java team writing some and sharing them with their colleagues using Kotlin in their team.
Recently I saw Spring Framework adopting ArchUnit, citing some reasons above. My team has been using the tool for more than a year. I suggest you learn about ArchUnit even if you have no interest in anything related to architecture.
An earlier version of this article is available in Polish:
czytaj po polsku
How to Start
ArchUnit is not a heavy library. It’s enough to add a single dependency:
testImplementation("com.tngtech.archunit:archunit-junit5:0.22.0")
It does not carry too much with it:
./gradlew dependencies --configuration testComClass
...
\--- com.tngtech.archunit:archunit-junit5:0.22.0
\--- com.tngtech.archunit:archunit-junit5-api:0.22.0
\--- com.tngtech.archunit:archunit:0.22.0
\--- org.slf4j:slf4j-api:1.7.30 -> 1.7.32
Tests using ArchUnit are short and readable:
@AnalyzeClasses(packages = ["com.acme"])
class ArchitectureTest {
@ArchTest
val absurdRule = noClasses().should().beAnonymousClasses()
}
They are executed together with standard JUnit tests. There is no need to configure anything additionally. We have a normal test; just the DSL is different.
Most of ArchUnit’s execution time is taken for startup, when your application bytecode is analyzed. In contrast, rule execution is fast. In our real-life code created by a single team, an ArchUnit test start takes 2 to 6 seconds, while a single rule execution is in tens of milliseconds. Which means that you should not fear adding more rules, as they won’t noticeably slow down the execution.
In case something in the examples is not clear to you, see the
official documentation that does a great job in explaining the ArchUnit DSL.
Detecting JUnit Misuse
Let’s apply ArchUnit to a real problem. JUnit is somewhat tricky because it makes heavy use of annotations. You can make mistakes, and the compiler won’t stop you. Let’s see an example:
@Test
fun wrongAnnotation() =
Role.values().map { dynamicTest("test $it") { assertCorrectFor(it) } }
@TestFactory
fun forgottenReturn() {
Role.values().map { dynamicTest("test $it") { assertCorrectFor(it) } }
}
To an inexperienced eye, these are two test methods. But in reality, these are two pieces of dead code that will never catch any regression. In the first method, assertions won’t be executed because, without @TestFactory annotation, the framework won’t run returned dynamic tests. In the second method, the annotation is correct, but a return statement is missing.
IntelliJ is kind enough to show a warning for the first of the two methods above. That’s nice, but people can fail to see or just ignore IDE warnings. It’s better to have something automatic that fails the build and prevents merging such code.
For example, ArchUnit rules:
@ArchTest
val testMustBeVoid = noMethods().should(
not(haveRawReturnType("void"))
.and(beAnnotatedWith(Test::class.java))
)
@ArchTest
val testFactoryMustReturnType = noMethods().should(
haveRawReturnType("void")
.and(beAnnotatedWith(TestFactory::class.java))
)
We get a clear error message:
Architecture Violation [Priority: MEDIUM] - Rule 'no methods should not have raw return type void and be annotated with @Test' was violated (1 times): Method <pl.pkubowicz.dynamictest.ControllerTest.wrongAnnotation()> does not have raw return type void in (ControllerTest.kt:11) and Method <pl.pkubowicz.dynamictest.ControllerTest.wrongAnnotation()> is annotated with @Test in (ControllerTest.kt:11)
Before you copy the snippet above into your codebase, I must warn you that the rule regarding TestFactory is not precise. According to the
documentation, a method annotated this way needs to return something like List<DynamicTest> or Stream<DynamicTest>. Why doesn’t my example cover this? An explanation will appear soon. Spoiler alert: we won’t be able to write a rule that requires that what is inside a list or a stream is for sure an instance of DynamicTest.
Dangerous Library Methods
Sometimes a library has some dark corners. Several methods that do bad things when you call them. The library as a whole may be ok: with a clear interface, well tested and maintained. Just those little details. You may end up using the library because its pros outweigh the cons.
A team can agree not to use bad parts of the library and watch out during code reviews. Maybe dark corners are marked by library authors as @Deprecated. Still, you are exposed to risk. People can fail to notice a bad call during a code review. IDE warnings can be ignored. There will be new people in the team, and what if nobody tells them what things should not be used?
I think it’s better to have conventions like that in written form. An even better: in executable form.
ArchUnit is a great help in such cases. It’s easy to write that we don’t allow calling a particular method. Or even: that we don’t allow calling one overloaded version of a method, the one where the last parameter is String instead of Instant.
It’s possible because ArchUnit operates on bytecode. Typical style checkers/linters see code as text, so they don’t allow for such a fine-grained control.
The reason above was cited when recently
Spring Framework added ArchUnit to their build:
It would be good to try to prohibit the use of the APIs that prevent configuration avoidance. Checkstyle won’t help as the necessary type of information isn’t available. Archunit is an option, though, as it has full type information available.
Enforcing Uniqueness
Sometimes things need to be unique, but it’s not easy to be sure of it. When using Spring Framework, we are safe: there will be an exception when loading a context where we declare two beans of the same name. In general, however, libraries may not have such a good developer experience.
For example, we use a migration tool where migrations can be assigned a number controlling the order in which they are executed. This is great, but there is nothing prohibiting using the same number twice. The application will run without any errors or warnings. It’s very hard to notice that migration execution is nondeterministic in such a case. It’s also not easy to write an integration test that will fail when an ordinal number is reused.
This is why we have an ArchUnit rule in our code that forbids such a reuse.
@ArchTest
fun migrationsMustHaveDistinctOrder(classes: JavaClasses) {
val migrationsDuplicatingOrder = classes
.filter { it.isAnnotatedWith(Migration::class.java) }
.groupBy { it.getAnnotationOfType(Migration::class.java).order }
.filter { (_, classes) -> classes.size > 1 }
org.assertj.core.api.Assertions.assertThat(migrationsDuplicatingOrder)
.isEmpty()
}
Earlier in the article, the rules were declarative; this one is more imperative. This is because previously, the high-level API of ArchUnit called
Lang API was used. It’s convenient to use, but it cannot cover all use cases, like asserting the uniqueness of a value of an annotation. The last example uses “Core API”, where all the framework does is providing a list of classes, and it’s our job to do the rest. As a plus: we have full control over processing and assertions.
Limitations
ArchUnit operates on JVM bytecode, so on something that has suffered from
type erasure. The consequence is that we are not able to write rules like: “no method declared in a controller should return SecretKey or Mono<SecretKey> or Flux<SecretKey>”. ArchUnit will see our code returning Mono<Object> and Flux<Object> everywhere, no matter what the sources contain. We won’t be able to control what is inside a Mono or a Flux (or a List, Stream, etc.). At least ArchUnit is clear about its limitations: the API allows writing rules with haveRawReturnType(), but there is no method haveReturnType().
Summary
ArchUnit is not just about controlling system architecture. It can enforce conventions in our code and help avoid traps some libraries have.
Sometimes a library does not validate that what we do makes sense (for example, JUnit allows writing a void @TestFactory). In such a case, we write the validation in our code as an ArchUnit rule.
Sometimes a part of a library code is confusing and dangerous, but for the sake of backward compatibility, authors don’t remove it. We can fix it: an ArchUnit rule can fail a build if a forbidden class or method is used.
It is possible to employ ArchUnit to enforce complex rules, where the compiler or conventional tests cannot help. Do you have classes that need to be assigned unique numbers? No problem, it’s easy to write a rule for it. Or maybe you have custom annotations that need to be assigned to some kind of classes – again, a simple rule.
ArchUnit rules execute fast. They can follow code evolution: you can write rules importing classes like regular code does. If a class is renamed or deleted, the compiler will tell you your rule does not work. IDE refactorings will work on your rules. This is not the case with style checkers/linters that treat code as text.
Consider adding ArchUnit to your code as a kind of high-level linter.