In the realm of Java enterprise development, the "modular monolith" has seen a resurgence as a pragmatic alternative to premature microservices decomposition. However, without strict architectural guardrails, these systems often suffer from "architectural entropy," where modules slowly bleed into one another, creating tight coupling that makes future extraction to microservices painful or impossible. This decay is rarely intentional; it happens through convenience. To combat this, we must move beyond convention and enforce boundaries at the build level.
The Problem: Implicit Coupling in Modular Monoliths
A well-designed modular monolith treats each module as a potential future microservice. This means that Module A should never directly access the internal entities of Module B. Instead, it should interact with Module B via a well-defined API. In practice, developers often bypass these APIs when it is "easier" to call a repository or service directly. Over time, these shortcuts accumulate, turning the monolith into a "big ball of mud."
Manual code reviews are not scalable for this purpose. A developer may overlook a subtle dependency in a complex refactor. We need automated, continuous verification.
Introducing ArchUnit: Architectural Unit Testing
ArchUnit is a powerful library for the Java platform that allows you to write "architectural unit tests." These tests are regular JUnit tests that verify your code structure. If a rule is violated, the build fails, preventing the bad code from being merged.
Practical Implementation: Defining Boundaries
Let’s assume we have a modular monolith with two modules: order-service and inventory-service. We want to ensure that order-service can only access inventory-service through the com.myapp.inventory.api package, not through its internal implementation classes.
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.lang.ArchRule;
import org.junit.jupiter.api.Test;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;
public class ArchitectureTest {
private final JavaClasses classes = new ClassFileImporter().importPackages("com.myapp");
@Test
public void order_service_should_not_access_inventory_implementation() {
ArchRule rule = noClasses().that().resideInAPackage("com.myapp.order..")
.should().dependOnClassesThat().resideInAPackage("com.myapp.inventory.internal..");
rule.check(classes);
}
@Test
public void modules_should_only_interact_via_api_packages() {
ArchRule rule = slices().matching("com.myapp.(*)..")
.should().notDependOnEachOther()
.allowAccessBetween("com.myapp.order..", "com.myapp.inventory.api..");
rule.check(classes);
}
}
Integrating with CI/CD and Build Tools
For these checks to be effective, they must be part of the standard build process. In a Maven or Gradle project, ArchUnit tests are executed during the test phase. If a developer attempts to add a direct dependency from an order service class to an inventory internal class, the build will fail immediately.
This immediate feedback loop is crucial. It shifts architectural compliance from a post-hoc audit to a real-time development constraint. Furthermore, you can extend this approach by using ArchUnit’s FreezingArchRule to manage legacy code. If you have existing violations, you can "freeze" them, allowing the build to pass while preventing new violations. This makes refactoring large legacy codebases feasible.
Going Further: Enforcing Layer Dependencies
Beyond module boundaries, you can enforce layer separation within modules. For example, ensuring that Controllers do not directly access Repositories, forcing them to go through Service layers. ArchUnit makes this straightforward:
@Test
public void controllers_should_not_access_repositories() {
ArchRule rule = noClasses().that().resideInAPackage("..controller..")
.should().dependOnClassesThat().resideInAPackage("..repository..");
rule.check(classes);
}
Conclusion
Modular monoliths offer the best of both worlds: the deployability of a single artifact and the structural clarity of microservices. However, this clarity is not self-maintaining. By integrating ArchUnit into your build pipeline, you create an automated gatekeeper that preserves your architectural intent. It transforms good architecture from a goal to be pursued into a constraint to be satisfied, ensuring your system remains robust, testable, and ready for future evolution.