How to Ensure Clean Architecture with ArchUnit?
Architectural integrity in software development is essential for creating scalable, maintainable systems. One of the most effective tools for enforcing architectural rules in Java is ArchUnit.
This powerful testing library ensures that the architecture of your system adheres to predefined rules, providing confidence that your architecture remains solid and is not compromised by anti-patterns.
In this article, I am going to explain the basics of ArchUnit, a tool that helps architects maintain clean, structured, and scalable applications by defining and enforcing architectural rules.
What is ArchUnit?
ArchUnit is a testing library for Java that helps developers define and test architecture-specific rules. It allows you to enforce the design and layering of your application by creating unit tests that check for architectural violations in your codebase.
ArchUnit enables you to prevent cyclical dependencies, enforce proper package structures, and maintain a clear separation of concerns. These principles are critical in ensuring that your code remains clean, structured, and easy to maintain.
Why Use ArchUnit?
A well-architected codebase is like a well-designed building with clear and logical pathways. If the rooms (or modules) are connected in a disorderly manner, it can be difficult to navigate. Similarly, without a clear and enforceable structure, your codebase can quickly become a tangled web of dependencies, making maintenance and scaling challenging.
How Does ArchUnit?Work?
ArchUnit operates by analyzing the compiled bytecode of your classes. You can define rules in simple Java code, which are then enforced by running tests. These rules can be as straightforward or complex as your architecture demands. By using ArchUnit tests, you can catch architectural violations early on, reducing the risk of accumulating technical debt
Avoiding Hidden Issues with?ArchUnit
A poorly managed codebase can hide undetected issues that may later disrupt development stages. These hidden problems might include tight coupling between classes, broken architectural principles, or complex dependencies. By using ArchUnit, you can prevent these issues by identifying them early. ArchUnit allows you to enforce best practices from the start, reducing the likelihood of these issues appearing as the codebase grows.
Common Use Cases for?ArchUnit
ArchUnit can be used to enforce a range of architectural decisions. Here are a few common use cases:
Enforcing Package and Layer Structures: Define and validate the intended relationships between packages and layers.
@Test
public void testNoOtherPackagesAreAccessedFromApiPackage() {
ArchRule rule =
classes()
.that()
.resideInAPackage("..api")
.should()
.onlyDependOnClassesThat()
.resideInAnyPackage("lombok..", "java..", "javax..", "mypackage.api..");
rule.check(importedClasses);
}
Classes Should Not Depend on Deprecated Classes: Identify deprecated usages early on before they are removed.
领英推荐
@Test
public void classesShouldNotDependDeprecatedClasses() {
ArchRule rule =
noClasses()
.should()
.dependOnClassesThat()
.areAnnotatedWith(Deprecated.class)
.because("Classes should not depend on deprecated classes");
rule.check(importedClasses);
}
Rest Controller Classes Should Not Be Called From Others: This helps to protect the MVC pattern of the codebase, ensuring that controllers are not called in between.
@Test
public void controllerClassesShouldNotBeCalledFromOtherClasses() {
ArchRule rule =
noClasses()
.should()
.accessClassesThat()
.areAnnotatedWith(org.springframework.stereotype.Controller.class)
.because("Controller methods should not be accessed by any classes");
rule.check(importedClasses);
}
Interfaces Should Start with ‘I’ Notation: If you have such naming conventions, it is good to ensure these with ArchUnit.
@Test
public void restInterfacesShouldStartWithINotation() {
ArchRule rule =
classes()
.that()
.areInterfaces()
.should()
.haveSimpleNameStartingWith("I")
.because("Interfaces should start with I notation");
rule.check(importedClasses);
}
Tests Should Have Assertions: Many tests are written without assertions just to achieve line coverage. You can enforce assertions with the following rule:
public ArchCondition<JavaMethod> callAnAssertion =
new ArchCondition<>("a unit test should assert something") {
@Override
public void check(JavaMethod item, ConditionEvents events) {
for (JavaMethodCall call : item.getMethodCallsFromSelf()) {
if ((call.getTargetOwner()
.getPackageName()
.equals(org.junit.jupiter.api.Assertions.class.getPackageName())
&& call.getTargetOwner()
.getName()
.equals(org.junit.jupiter.api.Assertions.class.getName()))
|| (call.getTargetOwner()
.getName()
.equals(com.tngtech.archunit.lang.ArchRule.class.getName()))
|| (call.getName().contains("check") || call.getName().contains("verify"))) {
return;
}
}
events.add(
SimpleConditionEvent.violated(
item, item.getDescription() + "does not assert anything."));
}
};
@BeforeEach
public void setUp() {
importedClasses =
new ClassFileImporter()
.withImportOption(ImportOption.Predefined.ONLY_INCLUDE_TESTS)
.importPackages(APP_PACKAGE_ROOT);
}
@Test
public void unitTestsShouldAssertSomething() {
ArchRule rule =
methods()
.that()
.areAnnotatedWith(Test.class)
.should(callAnAssertion)
.because("Unit tests should assert something");
rule.check(importedClasses);
}
Utility Classes Should Be Stateless: Utility classes should have no states and contain static methods that don’t need to have states at all. This rule ensures that.
@Test
public void utilityClassesShouldBeStateless() {
ArchRule rule =
classes()
.that()
.haveSimpleNameContaining("Utility")
.should()
.haveOnlyFinalFields()
.because("Utility classes should be stateless");
rule.check(importedClasses);
}
Built-In ArchUnit Rules: Although you can write your own rules, ArchUnit also provides predefined rules. Here are a few examples:
@Test
public void builtInArchUnitRules() {
GeneralCodingRules.NO_CLASSES_SHOULD_THROW_GENERIC_EXCEPTIONS.check(importedClasses);
GeneralCodingRules.NO_CLASSES_SHOULD_USE_FIELD_INJECTION).check(importedClasses);
}
Bonus: Extra Rules & Examples: You can find many more examples suited to your architecture in the ArchUnit example repository
Conclusion
While architectural issues may not be immediately apparent, their long-term consequences can significantly impact a system’s maintainability, scalability, and performance.
Hidden architectural flaws often lead to technical debt, where even small changes become challenging, dependencies become entangled, and the codebase becomes more difficult to understand and modify. Over time, this hinders productivity and increases the risk of introducing bugs.
ArchUnit provides a robust, proactive solution, enabling teams to define and enforce architectural rules directly in their?code.
ArchUnit helps prevent architectural drift and keeps the code aligned with its intended structure. Integrating ArchUnit tests into your CI/CD pipeline adds an additional safeguard, encouraging disciplined practices and helping your application remain clean, maintainable, and scalable.
If you find this article interesting, kindly consider liking and sharing it with others, allowing more people to come across it.
If you’re curious about technology and software development, I invite you to explore my other articles. You’ll find a range of topics that delve into the world of coding, app creation, and the latest tech trends. Whether you’re a professional developer for many years or just starting, there’s something here for everyone.