introduction
Nowadays, many teams work with microservice-based architectures, where each service encapsulates its own logic and solves a specific problem—whether it’s managing customer data, handling user information, or sending notifications. Over time, a team may become responsible for dozens of these microservices, and not all of them evolve at the same pace. As a result, inconsistencies often appear: different project structures, package naming conventions, or even distinct prefixes for equivalent layers.
These inconsistencies increase the cognitive load for developers who need to maintain or update the services. Every time someone opens a microservice in the IDE, they must first recall—or rediscover—where each part of the application lives. This raises a natural question: How can we reduce this friction? Is there a tool or library that can automate standardization?
The Problem: Inconsistency Across Applications
A company—or even an individual team—often makes its own decisions about how to organize an application internally. These decisions include package naming, prefixing specific classes such as controllers or repositories, defining persistence layers, and the rules governing their interactions.
A single team does not always own these decisions. In many organizations, multiple teams are given the freedom to define their own conventions. In other cases, the company may introduce a standard months or even years later, or revise an existing one as the platform evolves. In both scenarios, developers face recurring problems in their day-to-day work, such as increased cognitive load, slower feature development, and hidden technical debt.
The following table is just a simple representation of a possible situation that could happen in a company with multiple microservices and different structures.
| Problems | Microservice A | Microservice B |
| Package | com.api.flights.repository | com.api.hotels.persistence |
| Loggers | static final Logger LOGGER; | Logger logger; |
| Controller | CatalogController | ReservationHandleHTTP |
| DTO | Without business logic | With tons of business logic |
| Settings | Distribute across the application | Centralized on one package |
These problems tend to increase as the platform evolves, especially in legacy microservices that do not undergo migration.
Solution PART 1: Defining an Architectural Guide
One of the main problems with having different structures defined by each team or developer in each microservice is the lack of a single document that contains all the rules. The document does not need to be complex or follow a special format; it just needs to define the rules with some examples, using a tool or webpage that could be accessible to anyone in the company, but only a small group of developers can modify the document.
This document is not a silver bullet that solves all problems. Still, it reduces the risk that someone misunderstands a rule and introduces a mutation into an existing or new application.
Let’s see some examples of possible rules that could be great to have defined in a company:
- Controllers: Must use @GetMapping, @PostMapping, and other dedicated annotations to expose actions when the application uses Spring Boot.
- Services: Must reside in classes annotated with @Service, located in the service package, and contain all the business logic.
- Persistence: Must reside in the
repositorypackage and be implemented using interfaces annotated with@Repository. - DTO: Must be located in a dedicated
dtopackage and must not contain business logic.
After defining the rules in a document, it’s possible to find a tool that takes these definitions, validates them across different applications, and shows developers if anything is not aligned with them.
SOLUTION Part 2: Validating Architecture Automatically
Over the years, several well-known alternatives have emerged to help detect problems early in the development process. Traditional static analysis tools such as PMD, Checkstyle, and FindBugs focus primarily on code quality, style consistency, and standard programming errors.
More recently, tools like ArchUnit have introduced a different approach by allowing teams to express architectural rules directly in code and execute them as part of the test suite.
The following table compares these alternatives, highlighting their strengths and limitations for validating microservice structure and architecture.
| Capability | PMD | Checkstyle | FindBugs | ArchUnit |
| Runs as unit tests rather than as a static analyzer | ❌ | ❌ | ❌ | ✅ |
| Analyzes source code to detect issues | ✅ | ✅ | ✅ | ✅ |
| Supports custom and extensible rules | ✅ | ✅ | ✅ | ✅ |
| Validates package structure and naming conventions | ❌ | ❌ | ❌ | ✅ |
| Detects architectural violations (layering, dependencies) | ❌ | ❌ | ❌ | ✅ |
| Enforces field and class naming constraints | ❌ | ❌ | ❌ | ✅ |
| Strong community and documentation | ✅ | ✅ | ✅ | ✅ |
This list of differences should not be interpreted as an argument for replacing static analysis tools with ArchUnit. Static analyzers remain essential for detecting code smells and defects, whereas ArchUnit excels at validating architectural decisions. The real value comes from combining both approaches to cover different layers of quality control.
Introduction to Archunit
ArchUnit emerges as a practical solution for consistently and automatically enforcing architectural decisions. ArchUnit is an open-source library that allows teams to express architectural rules directly in code and execute them as part of the unit test suite. Using a declarative, fluent API enables developers to define explicit constraints on classes, packages, methods, fields, and dependencies.
EXAMPLE OF USE
To use this library, the first step implies adding the dependencies to the POM file in the case of Maven, as shown in the following fragment:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<properties>
<!-- Other properties -->
<archunit-junit5.version>1.2.1</archunit-junit5.version>
</properties>
<dependencies>
<!-- Other configurations →
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>${archunit-junit5.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5-api</artifactId>
<version>${archunit-junit5.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
As a recommendation, check the latest version of this library on the official webpage or a repository like this regularly.
Let’s create a simple rule that verifies that loggers must be private, static, and final, and that the attribute must be named LOGGER. To do this implies creating a class in the test folder with the content that appears in the following fragment:
@AnalyzeClasses(packages = "com.twa.flights.api", importOptions = ImportOption.DoNotIncludeTests.class) // #1
class GeneralCodingRulesTest {
@ArchTest
static final ArchRule loggersShouldBePrivateStaticAndFinal = fields().that().haveRawType(Logger.class).should()
.bePrivate().andShould().beStatic().andShould().beFinal().andShould().haveName("LOGGER")
.because("Logger variables should be private, static and final, and it should be named as LOGGER"); // #2
}
Before declaring the class, it is essential to specify which classes this test will analyze using the @AnalyzeClasses annotation; it is possible to select entire packages or just the names of the classes. In the example, the tests are excluded from the verification process as a good practice to reduce the execution time.
The annotation @ArchTest indicates that the following fragment is an ArchUnit test that needs to be executed; if the annotation does not exist, the test will be ignored.
The library allows you to analyze fields, methods, classes, interfaces, or packages. The first part of the test must specify which items will be analyzed to detect problems; after that, predefined conditions can be used to create a rule, such as .bePrivate() or .beStatic(). It’s possible combine multiple validations using the method .andShould().
As a best practice, it’s recommended to introduce the .because() with a brief explanation of what happened when the rule detects an issue.
The rules work as a standard unit test, so to execute them, it is only necessary to run the command of the application’s dependency management tool, which in this case is Maven.
$ mvn test
....
....
[INFO]
[INFO] Results:
[INFO]
[ERROR] Failures:
[ERROR] Architecture Violation [Priority: MEDIUM] - Rule 'methods that are declared in classes that reside in a package '..controller..' and are public should not be annotated with @ResponseBody, because Controller endpoints should not be annotated with @ResponseBody. Not include on the declaration the annotation.' was violated (1 times):
Method <com.twa.flights.api.catalog.controller.DocumentationController.redirectToDocumentation(jakarta.servlet.http.HttpServletResponse)> is annotated with @ResponseBody in (DocumentationController.java:27)
[ERROR] Architecture Violation [Priority: MEDIUM] - Rule 'fields that have raw type org.slf4j.Logger should be private and should be static and should be final and should have name 'LOGGER', because Logger variables should be private, static and final, and it should be named as LOGGER' was violated (3 times):
Field <com.twa.flights.api.catalog.controller.CatalogController.lo> does not have modifier FINAL in (CatalogController.java:0)
Field <com.twa.flights.api.catalog.controller.CatalogController.lo> does not have modifier STATIC in (CatalogController.java:0)
Field <com.twa.flights.api.catalog.controller.CatalogController.lo> does not have name 'LOGGER' in (CatalogController.java:0)
....
The console error message is descriptive of where the problem is located and what the root cause is.
This article uses a source from a GitHub repository; feel free to clone it and use it to learn about how to keep the application updated.
Which Types of Rules Exist?
A series of rules can be created with Archunit. Let’s see some of the most common:
- Naming: It’s possible to define the name that fields, classes, interfaces, and packages follow a standard on the prefix, suffix, or a specific regex.
@ArchTest
static final ArchRule finalStaticVariablesInUppercase = fields().that().areStatic().and().areFinal().and()
.doNotHaveName("serialVersionUID").and().doNotHaveModifier(JavaModifier.SYNTHETIC).should()
.haveNameMatching(".*^[A-Z].*")
.because("Variables with static and final modifiers should be named in uppercase");
- Annotations: It’s possible to validate that fields, methods, or classes use some specific annotations. This could be especially useful for checking the correct use of Spring Boot annotations or those of any other framework.
@ArchTest
static final ArchRule publicMethodsShouldAvoidUseRequestMapping =
methods()
.that()
.areDeclaredInClassesThat()
.resideInAPackage("..controller..")
.and()
.arePublic()
.should()
.notBeAnnotatedWith(RequestMapping.class)
.because(
"Controller endpoints should not be annotated with @RequestMapping. " +
"Use @GetMapping, @PostMapping, @PutMapping, @DeleteMapping, and @PatchMapping instead."
);
- Constructors: These rules help enforce correct instantiation patterns.
@ArchTest
static final ArchRule controllersShouldHavePublicConstructors =
constructors()
.that()
.areDeclaredInClassesThat()
.resideInAPackage("..controller..")
.and()
.areDeclaredInClassesThat()
.areNotAnonymousClasses()
.should()
.bePublic()
.because("Classes in the controller package must expose only public constructors.");
- Layers: It’s helpful to define how different parts of the application can interact.
@ArchTest
static final ArchRule architectureStructure =
layeredArchitecture()
.consideringAllDependencies()
.layer("Controller").definedBy("..controller..")
.layer("Service").definedBy("..service..")
.layer("Repository").definedBy("..repository..")
.whereLayer("Controller")
.mayNotBeAccessedByAnyLayer()
.whereLayer("Service")
.mayOnlyBeAccessedByLayers("Controller", "Service")
.whereLayer("Repository")
.mayOnlyBeAccessedByLayers("Service")
.because("Controllers should not be accessed by other layers, " +
"services should only be accessed by controllers or services, " +
"and repositories should only be accessed by services");
Many other validations can be implemented using this library, as ArchUnit allows teams to define fully custom rules.
How to Extract and Share the Rules?
Defining the rules using ArchUnit is just the first step in the process. It’s not a good idea to explain the rules for each microservice, as it leads to problems such as duplicate code, difficulty keeping the rules up to date, and more. So it’s relevant find a way to extract all the rules and create a library. To do this, there is a plugin developed by Société Générale that provides the possibility to create a library with all the rules previously defined with the following POM file:
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<artifactId>flights-architecture-test</artifactId>
<version>1.0.6</version>
<properties>
<main.basedir>${project.basedir}</main.basedir>
<!-- General -->
<archunit-junit5.version>1.2.1</archunit-junit5.version>
<objenesis.version>3.3</objenesis.version>
<junit-jupiter.version>5.8.2</junit-jupiter.version>
<junit-platform-engine.version>1.9.3</junit-platform-engine.version>
<!-- Maven -->
<arch-unit-build-plugin-core.version>3.1.1</arch-unit-build-plugin-core.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.objenesis</groupId>
<artifactId>objenesis</artifactId>
<version>${objenesis.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit-jupiter.version}</version>
</dependency>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-engine</artifactId>
<version>${junit-platform-engine.version}</version>
</dependency>
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit</artifactId>
<version>${archunit-junit5.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>com.societegenerale.commons</groupId>
<artifactId>arch-unit-build-plugin-core</artifactId>
<version>${arch-unit-build-plugin-core.version}</version>
<exclusions>
<exclusion>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
</exclusion>
<exclusion>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-commons</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>${archunit-junit5.version}</version>
</dependency>
</dependencies>
<repositories>
<repository>
<id>maven2</id>
<name>maven2</name>
<url>https://repo.maven.apache.org/maven2</url>
</repository>
<repository>
<id>manning-twa-lib-parent</id>
<name>Repository of parent pom</name>
<url>https://mymavenrepo.com/repo/tJqfV65wRD0LL9UcK4CQ</url>
</repository>
</repositories>
<distributionManagement>
<repository>
<id>flights-architecture-test</id>
<name>Repository of parent pom</name>
<url>https://mymavenrepo.com/repo/9lCyx3X47eTPtjxi1FA8</url>
</repository>
</distributionManagement>
</project>
As a recommendation, check the latest version of this plugin on the official webpage or a repository like this regularly.
The previously defined rules need minor modifications to be usable by the plugin. The first one needs to reside in the folder that contains the source code; for Maven projects, in the src/main/java folder. The second modification requires creating one class per rule, since multiple rules cannot be in a single class.
The following code block shows what a rule looks like that checks the location and the name of the controllers on the application using the approach necessary to externalize the rules:
public class ControllerShouldBeNameProperlyRuleTest implements ArchRuleTest {
@Override
public void execute(String packagePath, ScopePathProvider scopePathProvider, Collection<String> excludedPaths) {
classes().that().resideOutsideOfPackage("..controller..").should()
.haveSimpleNameNotEndingWith("Controller")
.because("Classes in ..controller.. package should be named with Controller suffix")
.check(ArchUtils.importAllClassesInPackage(scopePathProvider.getMainClassesPath(), packagePath));
}
}
The structure of the test that resides inside the method executed is the same as what is possible to write in Archunit, so the most significant change to externalize the rules is create one class for each rule and nothing more.
The next step after migrating the rules into the library is building and uploading them to a repository accessible to anyone in the company, and introducing the modifications in the applications to use them. On the different project, the changes are reduced to include the following block:
<plugin>
<groupId>com.societegenerale.commons</groupId>
<artifactId>arch-unit-maven-plugin</artifactId>
<version>${arch-unit-maven-plugin.version}</version>
<configuration>
<rules>
<preConfiguredRules>
<!-- Naming rules -->
<rule>com.twa.flights.architecture.rules.controller.ControllerShouldBeNameProperlyRuleTest</rule>
<rule>com.twa.flights.architecture.rules.resource.ResourceShouldBeNameProperlyRuleTest</rule>
</preConfiguredRules>
</rules>
</configuration>
<executions>
<execution>
<phase>test</phase>
<goals>
<goal>arch-test</goal>
</goals>
</execution>
</executions>
<dependencies>
<dependency>
<groupId>com.societegenerale.commons</groupId>
<artifactId>arch-unit-build-plugin-core</artifactId>
<version>${arch-unit-build-plugin-core.version}</version>
</dependency>
<dependency>
<groupId>com.twa.flights.common</groupId>
<artifactId>flights-architecture-test</artifactId>
<version>${flights-architecture-test.version}</version>
</dependency>
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>${archunit-junit5.version}</version>
</dependency>
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5-api</artifactId>
<version>${archunit-junit5.version}</version>
</dependency>
</dependencies>
</plugin>
It’s possible to enable rules one by one or all the rules in the same package.
Challenges and Best Practices
Introducing a new mechanism of validation, as usual, has some challenges that need to be considered, such as:
- Execution time: Adding more tests—whether run locally or in a CI pipeline—can increase build times and potentially delay deployments. One way to mitigate this is to introduce a dedicated profile and execute ArchUnit tests only when needed, or to run them in a separate pipeline stage that is not part of the production deployment flow.
- Code duplication: Many architectural rules refer to the same packages, layers, or classes, leading to repeated string literals across tests. A standard solution is to centralize these definitions in a shared constants class, making rules easier to maintain and reducing duplication.
- Legacy code: Enabling all architectural rules at once in a legacy project can surface many violations and require disruptive changes. A more practical approach is incremental adoption: enable the rules that do not cause immediate issues first, and progressively address the remaining ones. Prioritize rules that require minimal effort while delivering clear architectural improvements.
- Keeping rules up to date: Architectural rules are not static; they evolve as platforms and standards change. For this reason, it is essential to notify teams whenever a new version of the rules library is released and to ensure that applications do not continue using outdated versions. Without proper visibility, legacy rule sets can silently persist across the organization. One effective way to address this is to use tools such as Dependency-Track, which enable the identification of the dependencies and versions used by each application. This visibility helps teams detect services that still rely on legacy versions of the architectural rules and plan upgrades in a controlled, consistent manner.
These points represent only a subset of the challenges that may arise in day-to-day development. Still, it is essential to recognize that ArchUnit addresses architectural concerns that other tools are not designed to cover.
What’s next?
There are many resources on the quality and structure of applications. Still, just a few tackles, solving or mitigating it without thinking of one particular tool. The following is just a short list of resources:
- Fundamentals of Software Architecture: A Modern Engineering Approach by Neal Ford
- Modernizing Enterprise Java: A Concise Cloud Native Guide for Developers by Markus Eisele
- Software Architecture: The Hard Parts: Modern Trade-Off Analyses for Distributed Architectures by Neal Ford
- Refactoring: Improving the Design of Existing Code by Martin Fowler
Other resources that could be great for understanding some concepts related to good practices and the relevance of the architecture of an application:
- Clean Code: A Handbook of Agile Software Craftsmanship by Robert C. Martin
- Good Code, Bad Code by Tom Long
- Building Evolutionary Architectures: Automated Software Governance by Neal Ford
- Re-Engineering Legacy Software by C. Birchall
- SonarQube in Action by G. Dubois, A. Delmas, and M. Rodiere
- Clean as You Code: Elevating Software Quality through Continuous Code Improvement By Peter McKee
This is just a small list of available resources for defining and validating an architecture. If something is unclear, find another video, book, or resource.
CONCLUSION
Problems related to an application’s structure will inevitably arise because nothing in a software ecosystem remains static—especially in a platform composed of multiple microservices that evolve independently. This is why it is essential to have a mechanism that automatically detects structural issues, without relying on manual checks, at least during the application’s verification process. Naturally, someone within the organization must define the architectural rules that every project should follow and express them in a way that ArchUnit can enforce.
Another significant challenge is ensuring that teams are aware when new versions of these rules are released and encouraging everyone to update them. Not all rules can be applied immediately, particularly when dealing with legacy applications, but an iterative approach can work. Teams can gradually enable rules, begin the standardization process, and progressively align the structure of all applications—while preserving the differences that belong only to each service’s internal logic.

This article is part of the JAVAPRO magazine issue:
Engineering Intelligence – Building Systems in the AI Age
Explore what engineering means in the AI age — beyond code generation and automation.
Gain insights into performance-critical Java systems, evolving architectures, and the responsibilities that come with building intelligent software.
Discover the edition →