DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Over 2 million developers have joined DZone.
Log In / Join
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Related

  • Improving Java Code Security
  • Be Punctual! Avoiding Kotlin’s lateinit In Spring Boot Testing
  • Improving Unit Test Maintainability
  • That’s How You Can Use MapStruct With Lombok in Your Spring Boot Application

Trending

  • How to Submit a Post to DZone
  • DevOps Is Dead, Long Live Platform Engineering
  • Mocking Kafka for Local Spring Development
  • Observability in Spring Boot 4
  1. DZone
  2. Testing, Deployment, and Maintenance
  3. Testing, Tools, and Frameworks
  4. Enforcing Architecture With ArchUnit in Java

Enforcing Architecture With ArchUnit in Java

In this article, we will discuss implementing architectural rules in code using ArchUnit, emphasizing its effectiveness over traditional documentation.

By 
Gunter Rotsaert user avatar
Gunter Rotsaert
DZone Core CORE ·
May. 21, 25 · Tutorial
Likes (8)
Comment
Save
Tweet
Share
7.3K Views

Join the DZone community and get the full member experience.

Join For Free

You create a well-defined architecture, but how do you enforce this architecture in your code? Code reviews can be used, but wouldn't it be better to verify your architecture automatically? With ArchUnit you can define rules for your architecture by means of unit tests.

Introduction

The architecture of an application is described in the documentation. This can be a Word document, a PlantUML diagram, a DrawIO diagram, or whatever you like to use. The developers should follow this architecture when building the application. 

But, we do know that many do not like to read documentation, and therefore, the architecture might not be known to everyone in the team. With the help of ArchUnit, you can define rules for your architecture within a unit test. This is a very convenient way to do so, because the test will fail when an architecture rule is violated.

The official documentation and examples of ArchUnit are a good starting point for using ArchUnit.

Besides ArchUnit, Taikai will be discussed, which contains some predefined rules for ArchUnit.

The sources used in this blog can be found on GitHub.

Prerequisites

Prerequisites for reading this blog are:

  • Basic knowledge of architecture styles (layered architecture, hexagonal architecture, and so on);
  • Basic knowledge of Maven;
  • Basic knowledge of Java;
  • Basic knowledge of JUnit;
  • Basic knowledge of Spring Boot;

Basic Spring Boot App

A basic Spring Boot application is used to verify the architecture rules. It is the starting point for every example used and is present in the base package. The package structure is as follows and contains specific packages for the controller, the service, the repository, and the model.

Plain Text
 
├── controller
│   └── CustomersController.java
├── model
│   └── Customer.java
├── repository
│   └── CustomerRepository.java
└── service
    ├── CustomerServiceImpl.java
    └── CustomerService.java


Package Dependency Checks

Before getting started with writing the test, the archunit-junit5 dependency needs to be added to the pom.

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


The architecture rule to be added will check whether classes that reside in the service package can only be accessed by classes that reside in the controller or service packages.

By means of the @AnalyzeClasses annotation, you can determine which packages should be analyzed.

The rule itself is annotated with @ArchTest and the rule is written in a very readable way.

Java
 
@AnalyzeClasses(packages = "com.mydeveloperplanet.myarchunitplanet.example1")
public class MyArchitectureTest {

    @ArchTest
    public static final ArchRule myRule = classes()
            .that().resideInAPackage("..service..")
            .should().onlyBeAccessed().byAnyPackage("..controller..", "..service..");

}


The easiest way is to run this test from within your IDE. You can also run the test by means of Maven.

Shell
 
mvn -Dtest=com.mydeveloperplanet.myarchunitplanet.example1.MyArchitectureTest test


The test is successful.

Add a Util class in the example1.util package, which makes use of the CustomerService class. This is a violation of the architecture rule you just defined.

Java
 
public class Util {

    @Autowired
    CustomerService customerService;

    public void doSomething() {
        // use the CustomerService
        customerService.deleteCustomer(1L);
    }

}


Run the test again, and now it fails with a clear description of what is wrong.

Java
 
java.lang.AssertionError: Architecture Violation [Priority: MEDIUM] - Rule 'classes that reside in a package '..service..' should only be accessed by any package ['..controller..', '..service..']' was violated (1 times):
Method <com.mydeveloperplanet.myarchunitplanet.example1.util.Util.doSomething()> calls method <com.mydeveloperplanet.myarchunitplanet.example1.service.CustomerService.deleteCustomer(java.lang.Long)> in (Util.java:14)

	at com.tngtech.archunit.lang.ArchRule$Assertions.assertNoViolation(ArchRule.java:94)
	at com.tngtech.archunit.lang.ArchRule$Assertions.check(ArchRule.java:86)
	at com.tngtech.archunit.lang.ArchRule$Factory$SimpleArchRule.check(ArchRule.java:165)
	at com.tngtech.archunit.lang.syntax.ObjectsShouldInternal.check(ObjectsShouldInternal.java:81)
	at com.tngtech.archunit.junit.internal.ArchUnitTestDescriptor$ArchUnitRuleDescriptor.execute(ArchUnitTestDescriptor.java:168)
	at com.tngtech.archunit.junit.internal.ArchUnitTestDescriptor$ArchUnitRuleDescriptor.execute(ArchUnitTestDescriptor.java:151)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)


Exclude Test Classes

In the example2 package, a CustomerServiceImplTest is added. This test makes use of classes that reside in the services package, but the test itself is located in the example2 package.

The same ArchUnit test is used as before. Run the ArchUnit test, and the test fails because CustomerServiceImplTest does not reside in the service package.

Java
 
java.lang.AssertionError: Architecture Violation [Priority: MEDIUM] - Rule 'classes that reside in a package '..service..' should only be accessed by any package ['..controller..', '..service..']' was violated (5 times):
Method <com.mydeveloperplanet.myarchunitplanet.example2.CustomerServiceImplTest.testCreateCustomer()> calls method <com.mydeveloperplanet.myarchunitplanet.example2.service.CustomerServiceImpl.createCustomer(com.mydeveloperplanet.myarchunitplanet.example2.model.Customer)> in (CustomerServiceImplTest.java:64)
Method <com.mydeveloperplanet.myarchunitplanet.example2.CustomerServiceImplTest.testDeleteCustomer()> calls method <com.mydeveloperplanet.myarchunitplanet.example2.service.CustomerServiceImpl.deleteCustomer(java.lang.Long)> in (CustomerServiceImplTest.java:88)
Method <com.mydeveloperplanet.myarchunitplanet.example2.CustomerServiceImplTest.testGetAllCustomers()> calls method <com.mydeveloperplanet.myarchunitplanet.example2.service.CustomerServiceImpl.getAllCustomers()> in (CustomerServiceImplTest.java:42)
Method <com.mydeveloperplanet.myarchunitplanet.example2.CustomerServiceImplTest.testGetCustomerById()> calls method <com.mydeveloperplanet.myarchunitplanet.example2.service.CustomerServiceImpl.getCustomerById(java.lang.Long)> in (CustomerServiceImplTest.java:53)
Method <com.mydeveloperplanet.myarchunitplanet.example2.CustomerServiceImplTest.testUpdateCustomer()> calls method <com.mydeveloperplanet.myarchunitplanet.example2.service.CustomerServiceImpl.updateCustomer(java.lang.Long, com.mydeveloperplanet.myarchunitplanet.example2.model.Customer)> in (CustomerServiceImplTest.java:79)

	at com.tngtech.archunit.lang.ArchRule$Assertions.assertNoViolation(ArchRule.java:94)
	at com.tngtech.archunit.lang.ArchRule$Assertions.check(ArchRule.java:86)
	at com.tngtech.archunit.lang.ArchRule$Factory$SimpleArchRule.check(ArchRule.java:165)
	at com.tngtech.archunit.lang.syntax.ObjectsShouldInternal.check(ObjectsShouldInternal.java:81)
	at com.tngtech.archunit.junit.internal.ArchUnitTestDescriptor$ArchUnitRuleDescriptor.execute(ArchUnitTestDescriptor.java:168)
	at com.tngtech.archunit.junit.internal.ArchUnitTestDescriptor$ArchUnitRuleDescriptor.execute(ArchUnitTestDescriptor.java:151)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)


You might want to exclude test classes from the architecture rules checks. This can be done by adding importOptions to the @AnalyzeClasses annotation as follows.

Java
 
@AnalyzeClasses(packages = "com.mydeveloperplanet.myarchunitplanet.example2",
        importOptions = ImportOption.DoNotIncludeTests.class)


Run the test again, and now it is successful.

Layer Checks

ArchUnit provides some built-in checks for different architecture styles like a layered architecture or an onion (hexagonal) architecture. These are present in the Library API.

The example3 package is based on the base package code, but in the CustomerRepository, the CustomerService is injected and used in method updateCustomer. This violates the layered architecture principles.

Java
 
@Repository
public class CustomerRepository {

    @Autowired
    private DSLContext dslContext;

    @Autowired
    private CustomerServiceImpl customerService;

    ...

    public Customer updateCustomer(Long id, Customer customerDetails) {
        boolean exists = dslContext.fetchExists(dslContext.selectFrom(Customers.CUSTOMERS));
        if (exists) {
            customerService.deleteCustomer(id);
            dslContext.update(Customers.CUSTOMERS)
                    .set(Customers.CUSTOMERS.FIRST_NAME, customerDetails.getFirstName())
                    .set(Customers.CUSTOMERS.LAST_NAME, customerDetails.getLastName())
                    .where(Customers.CUSTOMERS.ID.eq(id))
                    .returning()
                    .fetchOne();
            return customerDetails;
        } else {
            throw new RuntimeException("Customer not found");
        }
    }


In order to verify any violations, the ArchUnit test makes use of the layeredArchitecture. You define the layers first, and then you add the constraints for each layer.

Java
 
@AnalyzeClasses(packages = "com.mydeveloperplanet.myarchunitplanet.example3")
public class MyArchitectureTest {

    @ArchTest
    public static final ArchRule myRule = layeredArchitecture()
            .consideringAllDependencies()
            .layer("Controller").definedBy("..controller..")
            .layer("Service").definedBy("..service..")
            .layer("Persistence").definedBy("..repository..")

            .whereLayer("Controller").mayNotBeAccessedByAnyLayer()
            .whereLayer("Service").mayOnlyBeAccessedByLayers("Controller")
            .whereLayer("Persistence").mayOnlyBeAccessedByLayers("Service");

}


The test fails because of the lack of access to the service by the Persistence layer.

Java
 
java.lang.AssertionError: Architecture Violation [Priority: MEDIUM] - Rule 'Layered architecture considering all dependencies, consisting of
layer 'Controller' ('..controller..')
layer 'Service' ('..service..')
layer 'Persistence' ('..repository..')
where layer 'Controller' may not be accessed by any layer
where layer 'Service' may only be accessed by layers ['Controller']
where layer 'Persistence' may only be accessed by layers ['Service']' was violated (2 times):
Field <com.mydeveloperplanet.myarchunitplanet.example3.repository.CustomerRepository.customerService> has type <com.mydeveloperplanet.myarchunitplanet.example3.service.CustomerServiceImpl> in (CustomerRepository.java:0)
Method <com.mydeveloperplanet.myarchunitplanet.example3.repository.CustomerRepository.updateCustomer(java.lang.Long, com.mydeveloperplanet.myarchunitplanet.example3.model.Customer)> calls method <com.mydeveloperplanet.myarchunitplanet.example3.service.CustomerServiceImpl.deleteCustomer(java.lang.Long)> in (CustomerRepository.java:52)

	at com.tngtech.archunit.lang.ArchRule$Assertions.assertNoViolation(ArchRule.java:94)
	at com.tngtech.archunit.lang.ArchRule$Assertions.check(ArchRule.java:86)
	at com.tngtech.archunit.library.Architectures$LayeredArchitecture.check(Architectures.java:347)
	at com.tngtech.archunit.junit.internal.ArchUnitTestDescriptor$ArchUnitRuleDescriptor.execute(ArchUnitTestDescriptor.java:168)
	at com.tngtech.archunit.junit.internal.ArchUnitTestDescriptor$ArchUnitRuleDescriptor.execute(ArchUnitTestDescriptor.java:151)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)


Taikai

The Taikai library provides some predefined rules for various technologies and extends the ArchUnit library. Let's see how this works.

First, add the dependency to the pom.

XML
 
<dependency>
	<groupId>com.enofex</groupId>
	<artifactId>taikai</artifactId>
	<version>1.8.0</version>
	<scope>test</scope>
</dependency>


In the example4 package, you add the following test. As you can see, this test is quite comprehensive.

Java
 
class MyArchitectureTest {

    @Test
    void shouldFulfillConstraints() {
        Taikai.builder()
                .namespace("com.mydeveloperplanet.myarchunitplanet.example4")
                .java(java -> java
                        .noUsageOfDeprecatedAPIs()
                        .methodsShouldNotDeclareGenericExceptions()
                        .utilityClassesShouldBeFinalAndHavePrivateConstructor()
                        .imports(imports -> imports
                                .shouldHaveNoCycles()
                                .shouldNotImport("..shaded..")
                                .shouldNotImport("org.junit.."))
                        .naming(naming -> naming
                                .classesShouldNotMatch(".*Impl")
                                .methodsShouldNotMatch("^(foo$|bar$).*")
                                .fieldsShouldNotMatch(".*(List|Set|Map)$")
                                .fieldsShouldMatch("com.enofex.taikai.Matcher", "matcher")
                                .constantsShouldFollowConventions()
                                .interfacesShouldNotHavePrefixI()))
                .logging(logging -> logging
                        .loggersShouldFollowConventions(Logger.class, "logger", List.of(PRIVATE, FINAL)))
                .test(test -> test
                        .junit5(junit5 -> junit5
                                .classesShouldNotBeAnnotatedWithDisabled()
                                .methodsShouldNotBeAnnotatedWithDisabled()))
                .spring(spring -> spring
                        .noAutowiredFields()
                        .boot(boot -> boot
                                .springBootApplicationShouldBeIn("com.enofex.taikai"))
                        .configurations(configuration -> configuration
                                .namesShouldEndWithConfiguration())
                        .controllers(controllers -> controllers
                                .shouldBeAnnotatedWithRestController()
                                .namesShouldEndWithController()
                                .shouldNotDependOnOtherControllers()
                                .shouldBePackagePrivate())
                        .services(services -> services
                                .shouldBeAnnotatedWithService()
                                .shouldNotDependOnControllers()
                                .namesShouldEndWithService())
                        .repositories(repositories -> repositories
                                .shouldBeAnnotatedWithRepository()
                                .shouldNotDependOnServices()
                                .namesShouldEndWithRepository()))
                .build()
                .check();
    }

}


Run the test. The test fails because it is not allowed to have classes ending with Impl. The error is similar to that with ArchUnit.

Java
 
java.lang.AssertionError: Architecture Violation [Priority: MEDIUM] - Rule 'Classes should not have names matching .*Impl' was violated (1 times):
Class <com.mydeveloperplanet.myarchunitplanet.example4.service.CustomerServiceImpl> has name matching '.*Impl' in (CustomerServiceImpl.java:0)

	at com.tngtech.archunit.lang.ArchRule$Assertions.assertNoViolation(ArchRule.java:94)
	at com.tngtech.archunit.lang.ArchRule$Assertions.check(ArchRule.java:86)
	at com.tngtech.archunit.lang.ArchRule$Factory$SimpleArchRule.check(ArchRule.java:165)
	at com.enofex.taikai.TaikaiRule.check(TaikaiRule.java:66)
	at com.enofex.taikai.Taikai.lambda$check$1(Taikai.java:70)
	at java.base/java.lang.Iterable.forEach(Iterable.java:75)
	at com.enofex.taikai.Taikai.check(Taikai.java:70)
	at com.mydeveloperplanet.myarchunitplanet.example4.MyArchitectureTest.shouldFulfillConstraints(MyArchitectureTest.java:60)
	at java.base/java.lang.reflect.Method.invoke(Method.java:580)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)


However, unlike with ArchUnit, this test fails when the first condition fails. So, you need to fix this one first, run the test again, and the next violation is shown, and so on. I created an improvement issue for this. This issue was fixed and released (v1.9.0) immediately. A new checkAll method is added that checks all rules.

Java
 
@Test
void shouldFulfillConstraintsCheckAll() {
    Taikai.builder()
            .namespace("com.mydeveloperplanet.myarchunitplanet.example4")
            ...
            .build()
            .checkAll();
}


Run this test, and all violations are reported. This way, you can fix them all at once.

Java
 
java.lang.AssertionError: Architecture Violation [Priority: MEDIUM] - Rule 'All Taikai rules' was violated (7 times):
Class <com.mydeveloperplanet.myarchunitplanet.example4.controller.CustomersController> has modifier PUBLIC in (CustomersController.java:0)
Class <com.mydeveloperplanet.myarchunitplanet.example4.service.CustomerService> is not annotated with org.springframework.stereotype.Service in (CustomerService.java:0)
Class <com.mydeveloperplanet.myarchunitplanet.example4.service.CustomerServiceImpl> does not have name matching '.+Service' in (CustomerServiceImpl.java:0)
Class <com.mydeveloperplanet.myarchunitplanet.example4.service.CustomerServiceImpl> has name matching '.*Impl' in (CustomerServiceImpl.java:0)
Field <com.mydeveloperplanet.myarchunitplanet.example4.controller.CustomersController.customerService> is annotated with org.springframework.beans.factory.annotation.Autowired in (CustomersController.java:0)
Field <com.mydeveloperplanet.myarchunitplanet.example4.repository.CustomerRepository.dslContext> is annotated with org.springframework.beans.factory.annotation.Autowired in (CustomerRepository.java:0)
Field <com.mydeveloperplanet.myarchunitplanet.example4.service.CustomerServiceImpl.customerRepository> is annotated with org.springframework.beans.factory.annotation.Autowired in (CustomerServiceImpl.java:0)

	at com.enofex.taikai.Taikai.checkAll(Taikai.java:102)
	at com.mydeveloperplanet.myarchunitplanet.example4.MyArchitectureTest.shouldFulfillConstraintsCheckAll(MyArchitectureTest.java:108)
	at java.base/java.lang.reflect.Method.invoke(Method.java:580)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)


Taikai: Issues Fixed

In the example5 package, all issues of the Taikai test are fixed. This reveals that some checks do not seem to function correctly. Also for this an issue is registered and again a fast reply of the maintainer. It appeared to be some misunderstanding of how the rules are implemented.

Reading the documentation a bit more carefully, the rule failOnEmpty checks whether rules are matched or not matched at all. In the latter case, it is possible that a rule is misconfigured. This is the case with fieldsShouldMatch and springBootApplicationShouldBeIn. A new test is added to show this functionality.

Java
 
@Test
void shouldFulfillConstraintsFailOnEmpty() {
    Taikai.builder()
            .namespace("com.mydeveloperplanet.myarchunitplanet.example5")
            .failOnEmpty(true)
            ...
}


The springBootApplicationShouldBeIn should be configured for the package where the main Spring Boot application should be located.

Java
 
.spring(spring -> spring
                .noAutowiredFields()
                .boot(boot -> boot
                        .springBootApplicationShouldBeIn("com.mydeveloperplanet.myarchunitplanet.example5"))


Conclusion

ArchUnit is an easy-to-use library that enforces some architectural rules. A developer will be notified of an architectural violation when the ArchUnit test fails. This ensures that the architecture rules are clear to everyone. The Taikai library provides easy-to-use predefined rules that can be applied immediately without too much configuration.

Architecture Library Java (programming language) Spring Boot unit test

Published at DZone with permission of Gunter Rotsaert. See the original article here.

Opinions expressed by DZone contributors are their own.

Related

  • Improving Java Code Security
  • Be Punctual! Avoiding Kotlin’s lateinit In Spring Boot Testing
  • Improving Unit Test Maintainability
  • That’s How You Can Use MapStruct With Lombok in Your Spring Boot Application

Partner Resources

×

Comments

The likes didn't load as expected. Please refresh the page and try again.

  • RSS
  • X
  • Facebook

ABOUT US

  • About DZone
  • Support and feedback
  • Community research

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 215
  • Nashville, TN 37211
  • [email protected]

Let's be friends:

  • RSS
  • X
  • Facebook