Enforcing Java Architecture with ArchUnit

Sugumar Panneerselvam
4 min readJun 25, 2024

--

Introduction

Photo by Jigar Panchal on Unsplash

Ensuring a robust architecture is crucial for the scalability and maintainability of software applications. In Java projects, maintaining a clean and well-defined architecture can be challenging as the project grows. This is where ArchUnit comes in, providing a powerful tool to help developers enforce architectural rules through unit tests. Previously, we explored similar concepts for .NET applications, highlighting the importance of architectural testing. In this article, we’ll dive into ArchUnit and how it can be used to ensure the integrity of your Java projects.

What is ArchUnit?

ArchUnit is a Java library that enables developers to test the architecture of their Java applications. It allows for the definition and enforcement of architectural rules to ensure code adheres to the desired structure. Whether you’re working on a monolithic application or a complex microservices architecture, ArchUnit can help maintain the integrity of your design.

Setting Up ArchUnit

To get started with ArchUnit, you’ll need to add it to your project. Here’s how you can do it using Maven:

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

Basic Usage

Let’s start with a simple example. Suppose you want to ensure that no class in your service layer depends on the repository layer. You can write an ArchUnit test like this:

import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.lang.ArchRule;
import com.tngtech.archunit.library.dependencies.SlicesRuleDefinition;
import org.junit.jupiter.api.Test;

public class ArchitectureTest {

@Test
void servicesShouldNotDependOnRepositories() {
JavaClasses importedClasses = new ClassFileImporter().importPackages("com.myapp");

ArchRule rule = noClasses()
.that().resideInAPackage("..service..")
.should().dependOnClassesThat().resideInAPackage("..repository..");

rule.check(importedClasses);
}
}

This rule ensures that classes in the service package do not depend on classes in the repository package.

Advanced Usage

ArchUnit allows you to write custom rules and check more complex architectural constraints. For example, you can enforce a layered architecture where each layer can only access the layers directly below it:

@AnalyzeClasses(packages = "com.myapp")
public class LayeredArchitectureTest {

@ArchTest
public static final ArchRule layerDependenciesAreRespected = layeredArchitecture()
.layer("Controllers").definedBy("com.myapp.controller..")
.layer("Services").definedBy("com.myapp.service..")
.layer("Repositories").definedBy("com.myapp.repository..")
.whereLayer("Controllers").mayNotBeAccessedByAnyLayer()
.whereLayer("Services").mayOnlyBeAccessedByLayers("Controllers")
.whereLayer("Repositories").mayOnlyBeAccessedByLayers("Services");
}

Using Lang API for Class Member Level Checks

ArchUnit’s Lang API provides fine-grained control to define and enforce rules at the class member level, such as fields and methods. This is useful for ensuring detailed architectural constraints within classes.

For example, you might want to ensure that service classes do not have public fields. Here’s how you can enforce this using the Lang API:

import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.domain.JavaField;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.lang.ArchCondition;
import com.tngtech.archunit.lang.ArchRule;
import com.tngtech.archunit.lang.syntax.ArchRuleDefinition;

import org.junit.jupiter.api.Test;

import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.fields;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods;

public class MemberLevelChecksTest {

@Test
void servicesShouldNotHavePublicFields() {
JavaClasses importedClasses = new ClassFileImporter().importPackages("com.myapp");

ArchRule rule = fields()
.that().areDeclaredInClassesThat().resideInAPackage("..service..")
.should().notBePublic();

rule.check(importedClasses);
}

@Test
void serviceMethodsShouldHaveSpecificNamingConvention() {
JavaClasses importedClasses = new ClassFileImporter().importPackages("com.myapp");

ArchRule rule = methods()
.that().areDeclaredInClassesThat().resideInAPackage("..service..")
.should().haveNameMatching("perform.*");

rule.check(importedClasses);
}
}

In these examples, the first test ensures that service classes do not have public fields, while the second test enforces that methods in service classes follow a specific naming convention.

Practical Examples

Consider a scenario where you want to ensure that your utility classes are stateless. You can write a rule to check that utility classes do not have fields:

@ArchTest
public static final ArchRule utilityClassesShouldBeStateless = classes()
.that().haveSimpleNameEndingWith("Util")
.should().haveOnlyFinalFields();

Why ArchUnit Tests are Essential

In large development teams, maintaining a consistent coding style and architectural integrity can be challenging. Here’s why ArchUnit tests are crucial:

  • Enforce Architectural Rules: ArchUnit helps ensure that architectural rules are consistently followed, preventing deviations that can lead to technical debt.
  • Improve Code Quality: By enforcing design patterns and architectural constraints, ArchUnit helps maintain high code quality and reduces the risk of errors.
  • Facilitate Code Reviews: Automated architectural checks simplify code reviews by automatically flagging violations, allowing reviewers to focus on functionality and logic.
  • Promote Team Alignment: ArchUnit tests serve as a living documentation of architectural decisions, helping new team members understand and adhere to established practices.
  • Prevent Regression: ArchUnit tests catch architectural violations early in the development process, preventing regressions that can occur as the codebase evolves.

Best Practices

  • Keep tests simple and focused: Each ArchUnit test should check a specific architectural rule.
  • Run ArchUnit tests as part of your CI/CD pipeline: This ensures that architectural violations are caught early.
  • Review and update rules regularly: As your project evolves, your architectural rules may need to be adjusted.

Conclusion

ArchUnit is a powerful tool for enforcing architectural rules in Java projects. By incorporating ArchUnit tests into your development workflow, you can maintain a clean and well-structured codebase, making your software more scalable and maintainable. In large teams, ArchUnit is invaluable for maintaining coding standards and architectural consistency, ultimately contributing to the overall health of the project.

--

--