Beliebte Suchanfragen
|
//

ArchUnit in practice: Keep your Architecture Clean

20.9.2024 | 17 minutes of reading time

Who hasn’t been there: A new project kicks off or the old code finally needs a cleanup. A big meeting with all the developers is called: “This time, we’ll do it right—clean, correct, and structured!” Architecture Decision Records (ADRs) are created to document the agreed-upon rules. At last there’s a perfect world with a clean package structure, well-named classes, no circular dependencies, and a clear dependency flow.

But often, this ideal world doesn’t last longer than it takes to say “Architecture Decision Records.” Small mistakes creep in at first, and they are quickly fixed. However, it usually doesn’t stop there. The errors accumulate and grow more serious. The ADRs soon fade into oblivion and the team accepts the situation. After all, the next deadlines are looming, and there are still bugs to fix.

A few months later, the impulse arises to bring more structure back into the code. Another meeting is called, discussing what needs to be done, and a new ADR is created. Oh, wait.There was already one—but surely the new one is better.

To break this cycle, you need a mechanism that continuously checks the decisions made and highlights potential mistakes for the development team. This is where architecture tests come into play.

What are Architecture Tests?

Architecture tests are a special type of software test. Instead of checking functions or business logic processes, these tests focus on the structure of the software itself. They verify predefined architectural rules and provide the development team with feedback on where these rules are violated in the code and where adjustments are needed.

These tests ensure that the architectural decisions made meet the system’s requirements. Architecture tests are often conducted using automated tools and frameworks designed specifically for verifying software architecture.

Some well-known tools and frameworks in this area include SonarQube, Structure101, JDepend, and ArchUnit. While these tools aren’t all directly comparable, they all serve the purpose of showing what your architecture looks like and where improvements may be needed.

ArchUnit - Why it is a great Choice among Architecture Test Frameworks

Why is ArchUnit so special that it deserves an entire article? Let’s take a look at the daily life of many developers.

Let’s assume the rules for architecture tests have been written and implemented. A developer picks up a ticket, opens their IDE, and starts coding. A new branch is created, a few classes here, a few dependencies there. Everything is going smoothly. The feature works, the unit and integration tests are written. And of course, it’s Friday afternoon. So they quickly create a pull request and are almost ready for the weekend.

Unfortunately, this is when the pipeline runs for the first time, testing the architecture using frameworks like SonarQube. And of course, the pipeline turns red. So back to the IDE rename the classes, move them into the correct packages, and resolve some improper dependencies. The IDE helps a lot, but some things still have to be done manually.

But now everything seems fine: time to push again and check the pipeline. Oops, still red. Just a minor issue, but honestly, the motivation is already fading. And when I see the architect in the daily meeting on Monday, I’ll ask them why we even bother with this. So much time wasted, just to make sure the port is actually called “Port.”

The result: The developer is frustrated with the architecture decisions and holds a grudge against the architect. The architect is tired of constantly having to fight to enforce the meaningful architectural choices. In the end, both time and morale suffer due to something that’s necessary and important.

This is where ArchUnit comes into play. With this tool, we move one step closer to the development team. The tests can be run directly in the IDE. Even better: there’s no need to integrate a new tool into the infrastructure or learn a new language. ArchUnit tests are based on JUnit (either version 4 or 5), the same language used for other tests. The library is simply added to the pom.xml or build.gradle.kts, and tests can be written right away.

The tests can then be executed directly during development. In the end, it’s a tool that integrates so seamlessly into the existing and familiar tech stack that after a while, you often don’t even notice it’s an extra tool.

Yes, it’s based on Java. However, it doesn’t matter whether you’re using Kotlin, plain Java, or another language. As long as the compiled code ends up as Java classes. It works best with the JUnit test framework. Other frameworks can also be used, but the ArchUnit rules will need to be written manually, which can be more time-consuming.

ArchUnit in Action

Enough talk, what does the implementation look like? We implemented our project using Kotlin and Gradle (Kotlin DSL), with JUnit 5 in place. Additionally, we used Spring, though it had only a secondary influence on our topic.

To use ArchUnit, you first need to add the dependency to your build.gradle.kts:

1dependencies {
2    testImplementation ("com.tngtech.archunit:archunit-junit5:1.2.1")
3}

Once the dependency is added, you can start writing ArchUnit tests. In a typical setup using JUnit 5, the ArchUnit test code might look like this:

1[1] @AnalyzeClasses(
2    packagesOf = [Application::class],
3    packages = ["de.codecentric.example"],
4    importOptions = [DoNotIncludeTests::class]
5)
6[2] class ArchitectureTest {
7
8    [3] @ArchTest
9    val `services should reside in service package`: [4] ArchRule = classes()
10        .that().haveSimpleNameEndingWith("Service")
11        .should().resideInAPackage("..service..")
12}

Let’s take a closer look at the individual components of the code:

  1. @AnalyzeClasses: This annotation defines which classes and packages should be analyzed by ArchUnit. In this case, the entire application (specified by Application::class) and the package de.codecentric.example are analyzed. Additionally, the option DoNotIncludeTests::class ensures that test classes are excluded from the analysis.
  2. class ArchitectureTest: This defines the test class that contains the ArchUnit tests. This class is structured like any other JUnit test class and serves as a container for the architecture rules that need to be verified.
  3. @ArchTest: This annotation marks the ArchUnit tests within the test class. In this case, a rule is defined that checks if all classes whose names end with "Service" are located in the service package.
  4. ArchRule: The rule itself is created using classes(), which represents a collection of class types. It then specifies that these classes should have names ending with "Service" (that().haveSimpleNameEndingWith("Service")). Finally, the method should().resideInAPackage("..service..") defines that these classes must reside in the service package.

The structure of tests in ArchUnit always follows a similar pattern: you start with classes() or methods() as a starting point, depending on whether you want to test classes or methods. Then you can check various aspects like class names, package structures, annotations, and other criteria to ensure that architectural rules are adhered to.

Since the structure of standard tests generally follows this schema, we won’t focus further on the implementation of the typical cases here. For a detailed description and more examples, I recommend to take a look into the official ArchUnit documentation.

Instead, in the next section, we’ll describe some special cases that can occur in practice and how to handle them with ArchUnit.

Special Cases and Challenges

Nested Classes

Nested classes, especially in Kotlin, are quite common and are often attached to the top-level class, like companion objects. For exampl it might look like this:

1TopLevelClass$Companion

This leads to such classes not being correctly recognized during a name check:

1Class <TopLevelClass$Companion> does not have simple name ending with 'Class' in (TopLevelClass.kt:0)

To avoid this problem, we only check the top-level classes using the .areTopLevelClasses() method.

1@ArchTest
2val `check mapper`: ArchRule = classes()
3    .that().resideInAPackage("..mapper..")
4    .and().areTopLevelClasses()
5    .should().haveSimpleNameEndingWith("Mapper")

Classes and Functions in One File

Let’s say I have a data class named FooEntity. In this file, I also have a function. For example an extension function, alongside the data class.

1@Entity(name = "foo")
2data class FooEntity(
3    @Id
4    var id: UUID,
5    var name: String,
6)
7
8fun Foo.toBar(): String {
9    return "Bar is better"
10}

If you test this with the following code, you will get an error message:

1@ArchTest
2val `check entities`: ArchRule = classes()
3    .that().haveSimpleNameEndingWith("Entity")
4    .or().resideInAPackage("..entity..")
5    .or().areAnnotatedWith(Entity::class.java)
6    .and().areTopLevelClasses()
7    .should().haveSimpleNameEndingWith("Entity")
8    .andShould().resideInAPackage("..entity..")
9    .andShould().beAnnotatedWith(Entity::class.java)
1Class <...FooEntityKt> does not have simple name ending with 'Entity' in (FooEntity.kt:0)

This happens because both a FooEntity.class and a FooEntityKt.class are generated.

There are several solutions for this. One option is to place the extension function in a companion object, or you can create a separate file for the extension function:

1- entities
2  |- FooEntity
3  |- BarEntity
4  |- ExtensionFunctions
5  |- ...

However, the same error will occur here as the FooEntityKt.class is still generated and placed in the entities package. It would need to be further extracted, but this would make things less clear. And that’s exactly what we want to achieve with ArchUnit: better code readability, more consistency, and thus a better understanding.

The solution is quite simple but requires some consideration in terms of the approach:

In the case of entities, you can simply split the test into two tests:

1@ArchTest
2val `check entities by name`: ArchRule = classes()
3    .that().haveSimpleNameEndingWith("Entity")
4    .and().areTopLevelClasses()
5    .should().resideInAPackage("..entity..")
6    .andShould().beAnnotatedWith(Entity::class.java)
7
8@ArchTest
9val `check entities by annotation`: ArchRule = classes()
10    .that().areAnnotatedWith(Entity::class.java)
11    .and().areTopLevelClasses()
12    .should().resideInAPackage("..entity..")
13    .andShould().haveSimpleNameEndingWith("Entity")

Now, the additional class generated by the extension function is no longer recognized as an error, because although it still resides in the package, it neither ends with Entity nor has the @Entity annotation.

The "disadvantage" here is that you could now have a class in the package named FooEntity that is not annotated with @Entity, but is still considered correct.

The advantage is that you now have the flexibility to include other helper classes within this package that are solely related to the entities. This gives you a bit more freedom from strict coupling. So you need to weight the trade-offs carefully.

But what if we have classes without annotations, like ports?

1@ArchTest
2val `check ports`: ArchRule = classes()
3    .that().haveSimpleNameEndingWith("Port")
4    .or().resideInAPackage(portsPackage)
5    .and().areTopLevelClasses()
6    .should().haveSimpleNameEndingWith("Port")
7    .andShould().resideInAPackage(portsPackage)
8    .andShould().beInterfaces()

Here, you need to decide whether it should be allowed to have classes in the ports package that are not ports. If you want to avoid this, the test needs to be narrowed down:

1@ArchTest
2val `check ports by name`: ArchRule = classes()
3    .that().haveSimpleNameEndingWith("Port")
4    .and().areTopLevelClasses()
5    .should().resideInAPackage(portsPackage)
6    .andShould().beInterfaces()

However, if there is a port in the package named FooPortWrong, it will not be recognized as an error.

For each case, you need to decide whether it makes sense to keep the whole package clean and only allow its main classes, or whether other classes are also permitted. In the case of ports, we decided to strictly allow only ports. For entities, we kept it more flexible because we have the annotation as an additional way to achieve greater clarity.

Handling Empty Test Cases

There may be situations where a rule needs to be defined, but it cannot yet be applied. An example would be enforcing a specific naming convention and package structure for scheduler classes.

However if no scheduler class exists in the code yet, ArchUnit will throw an error:

...failed to check any classes. This means either that no classes have been passed to the rule at all, or that no classes passed to the rule matched the that() clause. To allow rules being evaluated without checking any classes you can either use ArchRule.allowEmptyShould(true) on a single rule or set the configuration property archRule.failOnEmptyShould = false to change the behavior globally.

There are different approaches to avoid this error. One option would be to delete or comment out the respective test—though this is not ideal.

Instead ArchUnit offers several options, which are also mentioned in the error message.

One possibility is to explicitly specify that no error should be thrown if no class is found:

1ArchRule.allowEmptyShould(true)

Alternatively, this setting can also be applied globally for all tests. To do this, create the file archunit.properties under src/test/resources and add the following line:

1archRule.failOnEmptyShould=false

This approach should be chosen carefully. The rule is enabled by default for a reason.

Let’s assume there is a class that violates a rule and is located in the services package. If this package is renamed to service (without the "s") without adjusting the ArchUnit tests, the ArchUnit test will pass successfully. The reason is that no classes are found in the services package that violate the rule anymore. The test stays green simply because no classes are being checked.

Refactoring: The small task that packs a punch

In a newly started "greenfield" project, it might seem easy to apply all ArchUnit rules right away. However in practice, such projects are rare. Many projects are already well-established, possibly even in production. Adapting such projects to all new rules at once can take a significant amount of time. Time that is rarely available all at once. What we need is a way to define the rules and implement them gradually. This is exactly where ArchUnit's freeze feature comes in handy.

Freeze

The freeze feature is extremely useful and perhaps the most important one when it comes to integrating ArchUnit into existing projects. After all tests are written, you can enclose the tests you plan to fix later with a freeze:

1@ArchTest
2val `check ports by name`: ArchRule = 
3freeze(
4    classes()
5        .that().haveSimpleNameEndingWith("Port")
6        .and().areTopLevelClasses()
7        .should().resideInAPackage(portsPackage)
8        .andShould().beInterfaces()
9)

During the next test run, any rule violations within this test will be saved and ignored in future tests. However, you only get this "Get Out of Jail Free" card once. If a future change touches the code that caused the error, ArchUnit will no longer ignore it. Then, you’ll need to fix that specific error. But only at that location, not across the entire repository. This way ArchUnit helps you fix errors in small, iterative steps.

Store

The rule violations are stored in a Violation Store. To create this store, you first need to adjust the archunit.properties file accordingly:

# store location
freeze.store.default.path=src/test/resources/frozen

# allow creation of a new violation store (default: false)
freeze.store.default.allowStoreCreation=true

Storing the Rules

Each rule that is frozen is stored in the stored.rules file located in the test/resources folder, and it is assigned a unique ID (UUID). Example of stored.rules:

1#
2#Fri Jul 26 08:06:22 CEST 2024
3classes\ that\ have\ simple\ name\ ending\ with\ 'Port'\ or\ reside\ in\ a\ package\ 'de.codecentric.domain.port..'\ and\ are\ top\ level\ classes\ should\ have\ simple\ name\ ending\ with\ 'Port'\ and\ should\ reside\ in\ a\ package\ 'de.codecentric.domain.port..'\ and\ should\ be\ interfaces=ce02ea80-2508-4c9b-a4e6-7772b06bee83

Storing Rule Violations

For each rule a store containing the rule violations is created. These files are named with a UUID that corresponds to the respective rule in stored.rules. Example of a stored rule violation:

1Class <de.codecentric.domain.port.outbound.TestPortClient> does not have simple name ending with 'Port' in (TestPortClient.kt:0)

In this way you can use ArchUnit in existing projects and gradually refactor the code step by step.

Exploring Hexagonal Architecture

When searching the internet for ways to use ArchUnit with more complex architectures, you quickly come across suggestions to use Architectures.layeredArchitecture. This approach already allows you to capture many aspects. On one hand, layers relevant for the checks can be defined by specifying the individual layers through .consideringOnlyDependenciesInLayers(). You specify the layer's name and the package in which it resides.

1.layer("inbound")
2.definedBy("..adapter.inbound..")

ArchUnit will then examine only the specified packages, but it still doesn’t know the relationships between them. Just because a layer is called “domain” doesn’t mean anything to ArchUnit—it’s just for better code readability. For example to tell ArchUnit that the domain layer in the hexagonal architecture shouldn’t have dependencies on other layers, these relationships can be explicitly defined. This is done by appending further instructions to the last defined layer:

1.whereLayer("domain")
2.mayNotAccessAnyLayer()
3
4.whereLayer("port")
5.mayOnlyAccessLayers("domain")

It might look like this:

1@ArchTest
2val `check hexagonal architecture`: ArchRule = layeredArchitecture()
3        // define Layer
4        .consideringOnlyDependenciesInLayers()
5        .layer("inbound")
6        .definedBy("..adapter.inbound..")
7        .optionalLayer("usecase")
8        .definedBy("..application.usecase..")
9        ...
10        
11        // define Access
12        .whereLayer("inbound")
13        .mayOnlyAccessLayers("usecase", "domain")
14        .whereLayer("usecase")
15        .mayOnlyAccessLayers("domain")
16        ...
17
18@ArchTest
19val `inbound adapters are not dependent from each other`: ArchRule =
20        slices()
21            .matching("..adapter.inbound.(*)..")
22            .should()
23            .notDependOnEachOther()
24            .allowEmptyShould(true)
25}
26
27@ArchTest
28val `outbound adapter are not dependent from each other`: ArchRule =
29        slices()
30            .matching("..adapter.outbound.(*)..")
31            .should()
32            .notDependOnEachOther()
33            .allowEmptyShould(true)

But it gets even simpler! In fact, ArchUnit offers onionArchitecture in addition to Architectures.layeredArchitecture, which is also excellent for modeling hexagonal architecture.

This provides predefined functions for defining the core components of hexagonal architecture. To achieve similar functionality as in the previous code, only a few lines are needed:

1@ArchTest
2val `hexagonal architecture is respected`: ArchRule = onionArchitecture()
3    [1].domainModels(domainModelPackage)
4    [2].domainServices(domainServicePackage, portsPackage, useCasePackage)
5    [3].applicationServices(applicationPackage)
6    [4].adapter("adapter", adapterPackage)
7    [5].withOptionalLayers(true)

In this ArchUnit test, we are verifying that the hexagonal architecture is respected by using the onionArchitecture() method, which helps enforce architectural boundaries based on the hexagonal architecture pattern.

  1. domainModels(domainModelPackage): This specifies the domain models, which are typically part of the core domain logic and often reside in a domain package if following Domain-Driven Design (DDD) principles.
  2. domainServices(domainServicePackage, portsPackage, useCasePackage): This includes the domain services, which handle the business logic of the application. These are also part of the core domain and usually live in the domain package, alongside domain models. The Ports and UseCases are also included here.
  3. applicationServices(applicationPackage): These are services that exist outside the core domain but are not considered adapters. They might include configurations, utilities or something similar, that doesn’t directly interact with the domain layer.
  4. adapter("adapter", adapterPackage): This defines the adapters, which form the outer layer of the hexagon. Adapters can be anything that interacts with the external world, such as controllers or database connections.
  5. withOptionalLayers(true): This allows the existence of additional layers (or packages) beyond the core ones defined. These could be helper packages or other components that don’t strictly fit into the domain or adapter categories.

The Hidden Strengths of ArchUnit

In addition to its obvious features, ArchUnit offers a number of benefits that support development but are not mentioned in the documentation or immediately visible in the test results. Nonetheless these are some of ArchUnit's most important qualities.

A major benefit is that you are forced to deeply engage with the architecture of your project. As a team, you will think about how to name classes, what package structure makes sense and how to organize everything in the best way for your solution. Ideally these considerations should be documented in an ADR.

The time invested in these decisions will pay off later. ArchUnit ensures that these decisions are enforced without the need for constant manual oversight.

Another advantage of ArchUnit is that it allows you to directly test well-known architectural concepts like layered architecture or onion architecture. You don't need to fully understand all the details of these concepts right away. You can build up your knowledge gradually. At first, ArchUnit may flag errors, such as classes being in the "wrong" packages. Which you may not fully grasp yet. But with time and some research, it becomes clearer why these errors occur. This way even less experienced developers can implement clean architecture from the start.

It's a trap

Like any tool in software development, you shouldn't blindly rely on ArchUnit. There can be false negatives, or worse, false positives that aren’t immediately obvious. As mentioned earlier renaming packages could result in ArchUnit no longer finding classes in those packages, and therefore no errors.

It’s also important to consider what is defined as "correct." While ArchUnit checks if, for example, the domain layer in a hexagonal architecture doesn't access outer layers, whether your package structure is organized by technology or domain is another question. A technically correct structure may still not be optimal architecturally.

You must always remember that you are the owners of your code, not the tool. "A wrench helps you tighten or loosen a hex bolt (hexagon). How tight you make it is up to you. Turn too little, and it’s not secure; turn too much, and the bolt may break."

Conclusion

ArchUnit is a powerful tool that helps you ensure and maintain the architecture of your software. It allows seamless integration into the existing tech stack and ensures that architectural decisions are checked early. This way, errors are visible during the development phase and don’t need to be detected through elaborate pipeline checks.

A major advantage of ArchUnit is its flexibility. It can be easily introduced in both greenfield projects and existing projects. Thanks to the freeze function violations can be fixed step by step and iteratively without blocking the development process. Moreover, ArchUnit fosters team culture by encouraging developers to think about architecture and enforce the decisions made.

Additionally, it offers the possibility to directly test and embed complex architectural concepts like Hexagonal Architecture or Onion Architecture into the code. This makes the transition from theory to practice easier and allowing even less experienced developers to implement clean and sustainable architectures from the very beginning.

ArchUnit is more than just a testing framework. It becomes a tool that helps ensure the long-term maintainability and quality of your code. When used carefully, it becomes a helpful partner that makes sure your code is not only technically correct but also well-structured from an architectural perspective.

|

share post

Likes

7

//

More articles in this subject area

Discover exciting further topics and let the codecentric world inspire you.

//

Gemeinsam bessere Projekte umsetzen.

Wir helfen deinem Unternehmen.

Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.

Hilf uns, noch besser zu werden.

Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.