An introduction to ArchUnit

An introduction to ArchUnit

Linting your Java/Kotlin/Groovy architecture with Unit Tests

In our day-to-day project work we write Unit Tests, Integration Test, etc. to test our business functionality. But what if we could apply the same principles to test our architecture?

That is where ArchUnit comes into the picture. ArchUnit is a Java testing library which lets us make assertions on our code's structure, relationships and properties.

By using this library we can maintain code quality and standards by creating conventions, either on a per-project or across-project basis, that developers can follow, thus raising overall efficiency. A lax following of architectural rules risks introducing bugs and headaches down the line; this is what ArchUnit is great for.

A complete working example can be found here.

Project installation

For a typical project we can get the archunit base dependency from MavenCentral.

If we are also using JUnit in our project then it is recommended that we get the more specific modules archunit-junit4 or archunit-junit5 instead. They help with the runtime of our unit tests by caching the imported classes but also have additional features. In the examples for this article I am going to use the archunit-junit5 dependency.

For Maven:

<dependency>
    <groupId>com.tngtech.archunit</groupId>
    <artifactId>archunit-junit5</artifactId>
    <version>0.23.1</version>
    <scope>test</scope>
</dependency>

For Gradle:

dependencies {
    testImplementation 'com.tngtech.archunit:archunit-junit5:0.23.1'
}

Simple Example

Using the archunit-junit5 module

If we use the specific junit5 module for our project then by using the @AnalyzeClasses annotation, we can skip some boilerplate code. Additionally, by using the @ArchTest annotation it is enough if we declare an ArchRule for that test. The import and the assertions against the classes are done automatically.

@AnalyzeClasses(packages = ["io.cloudflight.archunit"])
class MyFirstArchTest {

    @ArchTest
    val services_should_only_be_accessed_by_controllers: ArchRule = classes()
        .that().resideInAPackage("..service..")
        .should().onlyBeAccessed().byAnyPackage("..controller..", "..service..");
}

Under the hood ArchUnit also automatically caches the imported classes between tests, thus also improving the efficiency of our tests.

When using the @AnalyzeClasses annotation we can have a few ways of controlling the import classes. By default, the whole classpath is loaded and checked against our tests. We can define specific packages that we want to test like in the above example. If we need to further filter the imported classes then we could use the importOptions attribute for example by defining that we do not want to check against the test classes: @AnalyzeClasses(importOptions = [ImportOption.DoNotIncludeTests::class])

Writing ArchUnit tests

Here is a list of the types of tests we can potentially write using this libary:

  • Package dependency checks
  • Class dependency checks
  • Class and package containment checks
  • Inheritance checks
  • Annotation checks
  • Layer checks
  • Cycle checks
  • Custom checks

Examples

Using the Library API and including predefined rules

The ArchUnit Library API provides a set of common rules that many projects would likely need.

import com.tngtech.archunit.library.GeneralCodingRules.*
...
@ArchTest
val classes_should_not_access_standard_streams_from_library = NO_CLASSES_SHOULD_ACCESS_STANDARD_STREAMS
@ArchTest
val classes_should_not_throw_generic_exceptions = NO_CLASSES_SHOULD_THROW_GENERIC_EXCEPTIONS
@ArchTest
val classes_should_not_use_java_util_logging = NO_CLASSES_SHOULD_USE_JAVA_UTIL_LOGGING

These are just a few examples, there are more GeneralCodingRules.

Defining a layered architecture

By defining a set of layers and the relationship between them, the library can check the interaction between those layers. For example one of the most common layered architecture conventions is that Repositories should only be accessed by Services.

@ArchTest
val layer_dependencies_are_respected: ArchRule = layeredArchitecture()
    .withOptionalLayers(true)
    .layer(Layers.API)
    .definedBy(Packages.API)
    .layer(Layers.CONTROLLER)
    .definedBy(Packages.CONTROLLER)
    .layer(Layers.SERVICE)
    .definedBy(Packages.SERVICE)
    .layer(Layers.ENTITY)
    .definedBy(Packages.ENTITY)
    .layer(Layers.REPOSITORY)
    .definedBy(Packages.REPOSITORY)

    .whereLayer(Layers.API)
    .mayOnlyBeAccessedByLayers(Layers.SERVICE)
    .whereLayer(Layers.CONTROLLER)
    .mayNotBeAccessedByAnyLayer()
    .whereLayer(Layers.SERVICE)
    .mayOnlyBeAccessedByLayers(Layers.CONTROLLER)
    .whereLayer(Layers.ENTITY)
    .mayOnlyBeAccessedByLayers(Layers.SERVICE, Layers.REPOSITORY)
    .whereLayer(Layers.REPOSITORY)
    .mayOnlyBeAccessedByLayers(Layers.SERVICE)
    .`as`("Package structuring does not match the expected one.")

Where Layers.API is the definition of a layer in our code and Packages.API is the definition for a package identifier (see the code below for details).

object Packages {
    const val API = "..api.."
    const val CONTROLLER = "..controller.."
    const val ENTITY = "..entity.."
    const val SERVICE = "..service.."
    const val REPOSITORY = "..repository.."
}

object Layers {
    const val API = "Api"
    const val CONTROLLER = "Controller"
    const val SERVICE = "Service"
    const val REPOSITORY = "Repository"
    const val ENTITY = "Entity"
}

In case we are using a Onion/Hexagonal Architecture, the library provides support for that also here.

Defining packaging containment conventions

By using the library's fluent DSL we can easily write custom test like the example below, which checks if classes that are annotated with the @Service annotation have indeed a simple name that ends with the string Service and are in a package called service.

@ArchTest
val services_must_reside_in_service_package = classes()
    .that().areAnnotatedWith("org.springframework.stereotype.Service")
    .should().resideInAPackage(Packages.SERVICE)
    .andShould().haveSimpleNameEndingWith(Layers.SERVICE)
    .`as`("Services should reside in a package ${Packages.SERVICE}")

@ArchTest
val controllers_must_reside_in_controller_package = classes()
    .that().areAnnotatedWith("org.springframework.web.bind.annotation.RestController")
    .should().resideInAnyPackage(Packages.CONTROLLER)
    .andShould().haveSimpleNameEndingWith(Layers.CONTROLLER)
    .`as`("Controllers should reside in a package ${Packages.CONTROLLER}")

Custom convention checks

If we agree on a convention with our team, we can also write custom checks for those cases. A simple example would be checking that interfaces should not have @RequestMapping annotation on them:

@ArchTest
val no_interface_should_do_request_mapping: ArchRule = noClasses()
    .that().areInterfaces()
    .should().beAnnotatedWith(RequestMapping::class.java)
    .`as`("Implementations only should define RequestMappings.")

Conclusion

A key takeaway from ArchUnit for me is that it is easier to explicitly enforce an architectural decision by having written Unit Tests for it, than implicitly trying to remember and follow these conventions. This way we are not relying on reading wiki pages to establish conventions. Bugs that would not be spotted at code-review which would lead to technical debt, can be prevented. Thus improving the resiliency of our architecture.

Another big advantage of using this library is that code refactoring through the IDE will also be applied for our tests, meaning that we are not breaking our tests by refactoring our code.

By writing ArchUnit tests we can follow decisions, structures, conventions that were agreed upon and written down in the form of Unit Tests, thus eliminating code changes that were not intended that are hard to change down the road.

At Cloudflight we use ArchUnit for our custom Archunit-CleanCode-Verifier library, which verifies common best-practices for our projects.

Footnotes