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.
1. 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 at GitHub.
2. 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;
3. 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.
├── controller
│ └── CustomersController.java
├── model
│ └── Customer.java
├── repository
│ └── CustomerRepository.java
└── service
├── CustomerServiceImpl.java
└── CustomerService.java
4. Package Dependency Checks
Before getting started with writing the test, the archunit-junit5 dependency needs to be added to the pom.
<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 which reside in the service package can only be accessed by classes which 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.
@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.
mvn -Dtest=com.mydeveloperplanet.myarchunitplanet.example1.MyArchitectureTest test
The test is successful.
Add a Util class in the example1.util package which makes uses of the CustomerService class. This is a violation of the architecture rule you just defined.
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.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)
5. Exclude Test Classes
In the example2 package, a CustomerServiceImplTest is added. This test makes use of classes which 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.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.
@AnalyzeClasses(packages = "com.mydeveloperplanet.myarchunitplanet.example2",
importOptions = ImportOption.DoNotIncludeTests.class)
Run the test again and now it is successful.
6. 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.
@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.
@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 not allowed access of the service by the Persistence layer.
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)
7. 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.
<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.
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 as with ArchUnit.
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) already immediately. A new checkAll method is added which checks all rules.
@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.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)
8. 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.
@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.
.spring(spring -> spring
.noAutowiredFields()
.boot(boot -> boot
.springBootApplicationShouldBeIn("com.mydeveloperplanet.myarchunitplanet.example5"))
9. Conclusion
ArchUnit is an easy-to-use library for enforcing some architectural rules. A developer will be noticed 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 which can be applied immediately without too much configuration.
Discover more from
Subscribe to get the latest posts sent to your email.
