What a module is in a Java monolith, and why boundaries need a failing test
In a Java monolith, a module is a top-level package that owns one business capability and decides which of its classes other code may touch. With a package-by-feature layout, com.example.orders, com.example.billing and com.example.inventory each split into an api package (interfaces, DTOs and events meant for callers) and one or more internal packages (persistence, services and mappers that belong to that module alone).
com.example.orders com.example.billing
+------------------+ +------------------+
| api <-----+--OK----+ internal |
| internal | | api |
+--------+---------+ +--------+---------+
| |
| OK X FORBIDDEN
v v
com.example.inventory
+------------------+
| api | <- other modules may depend here
| internal | <- only inventory itself may depend here
+------------------+
The convention has to be consistent because a rule can only match what it can name. If one module calls its private code impl, another core, and a third leaves it in the root package, no single rule like "nothing outside a module may depend on ..internal.." covers all three. The rule ends up as a list of special cases, and someone has to keep updating it.
Boundaries protected only by a wiki page or code review erode one reasonable shortcut at a time. Java's public modifier doesn't distinguish between "public to my module" and "public to everyone", and IDE auto-import will reach into billing.internal without a warning. Reviewers rarely catch a single import line in a large diff. A test that fails the build catches it every time.
This matters most for teams whose monolith is still growing, where many contributors work in the same codebase and modules are expected to stay separable, whether for extraction later or for independent ownership.
com.example.orders com.example.billing
+----------------------+ +----------------------+
| api | +------>| api |
| interfaces, DTOs | | OK | interfaces, DTOs |
+----------------------+ | +----------------------+
| internal |------+ | internal |
| services, repos |---X--------->| services, repos |
+----------------------+ FORBIDDEN +----------------------+
| |
| OK X FORBIDDEN
v |
com.example.inventory |
+----------------------+ |
| api | |
| interfaces, DTOs | |
+----------------------+ |
| internal |<------------------------+
| services, repos |
+----------------------+
Legend: ----> allowed: target is another module's api package
--X-> forbidden: target is another module's internal package
In a package-by-feature monolith, code in one module may depend on another module's api package, but it must never reach into another module's internal packages.
How ArchUnit works: setting up architecture tests with JUnit 5
ArchUnit does not read your source files. It imports compiled bytecode from the classpath and builds an in-memory graph of classes, members, and the dependencies between them: field types, method parameters, return types, method calls, annotations, inheritance. Rules are then evaluated against that graph. A rule therefore checks what the compiler actually emitted, so an import that is declared but never used will not show up as a dependency. No application context starts and no code executes, which keeps these tests in the same speed range as ordinary unit tests.
Setup is a single test-scoped dependency, the archunit-junit5 artifact from the com.tngtech.archunit group. With it in place, annotate a test class with @AnalyzeClasses and declare rules as static final ArchRule fields marked @ArchTest. Point packages at the application's root package rather than listing individual modules. One root scope means every rule sees the whole graph, so cross-module dependencies cannot fall outside the import. It also lets ArchUnit import the classes once and cache the result for every test class that uses the same configuration.
Add importOptions = ImportOption.DoNotIncludeTests.class. Test code legitimately reaches across module boundaries through fixtures, builders, and integration setup. If you import it, you get violations nobody intends to fix. Excluding it also shrinks the graph. ImportOption.DoNotIncludeJars does the same for third-party libraries when your rules only concern your own code.
Keep every architecture rule in one dedicated test class, or in one package if the rules grow. A single location gives reviewers one place to see the intended structure.
package com.example.shop.architecture;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
// Requires the test-scoped dependency com.tngtech.archunit:archunit-junit5.
// Scope the import to the application's root package so every rule sees the
// whole class graph. Classes are imported once and the result is cached for
// all test classes that use the same @AnalyzeClasses configuration.
@AnalyzeClasses(
packages = "com.example.shop",
importOptions = {
// Test fixtures cross module boundaries on purpose; skip them.
ImportOption.DoNotIncludeTests.class,
// These rules only concern our own code, not third-party libraries.
ImportOption.DoNotIncludeJars.class
})
class ModuleDependencyRulesTest {
// Billing may use Shipping's public API, but never its internals.
@ArchTest
static final ArchRule billing_does_not_use_shipping_internals =
noClasses().that().resideInAPackage("com.example.shop.billing..")
.should().dependOnClassesThat()
.resideInAPackage("com.example.shop.shipping.internal..");
// Shipping must not depend on Billing at all.
@ArchTest
static final ArchRule shipping_does_not_depend_on_billing =
noClasses().that().resideInAPackage("com.example.shop.shipping..")
.should().dependOnClassesThat()
.resideInAPackage("com.example.shop.billing..");
}
A dedicated JUnit 5 architecture test class that imports production bytecode once from the root package, excludes test classes and JARs, and declares module dependency rules as @ArchTest fields.
Writing module dependency rules: internals, slices, and cycles
The first rule protects a module's internals. Suppose orders keeps its implementation in com.example.orders.internal. Using the fluent API, you state that no classes outside com.example.orders.. may depend on classes that reside in com.example.orders.internal... That is noClasses().that().resideOutsideOfPackage(...) followed by .should().dependOnClassesThat().resideInAPackage(...). Write the rule this way because the dependency is only wrong when it crosses the module boundary. Inside orders, the internal package is meant to be used freely.
When the rule fails, ArchUnit lists every violation on its own line. Each line names the source class and member, the target it touches, and a (SourceFile.java:line) location. Read the first class name and the location to find the offending call. The rule's description at the top tells you which boundary was crossed.
Writing one rule per module does not scale. slices().matching("com.example.(*)..") groups classes by the package segment captured by (*), so com.example.orders.api and com.example.orders.internal both belong to the slice orders. Use .should().beFreeOfCycles() to reject any dependency loop between modules, including indirect ones. Use .should().notDependOnEachOther() for modules that must stay fully independent, such as separate integrations that share nothing.
Rules can also pass when they should not. Add a test that imports a deliberately violating fixture class and asserts that the rule throws an AssertionError. Also keep ArchUnit's empty-should check enabled (allowEmptyShould(false)). If a package rename leaves a rule matching no classes, the rule then fails instead of passing silently.
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.core.importer.ImportOption;
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;
import static org.junit.jupiter.api.Assertions.assertThrows;
class ModuleBoundaryTest {
// Production classes only; test fixtures must not count against the real rules
static final JavaClasses PROD = new ClassFileImporter()
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
.importPackages("com.example");
// Only a call that crosses the module boundary is a violation
static final ArchRule ORDERS_INTERNAL_IS_PRIVATE = noClasses()
.that().resideOutsideOfPackage("com.example.orders..")
.should().dependOnClassesThat().resideInAPackage("com.example.orders.internal..")
.allowEmptyShould(false); // fail, not pass, if a rename leaves the rule matching nothing
@Test void ordersInternalsStayInsideOrders() { ORDERS_INTERNAL_IS_PRIVATE.check(PROD); }
// (*) captures the first segment after com.example, so orders.api and orders.internal form one slice
@Test void modulesHaveNoCycles() {
slices().matching("com.example.(*)..").should().beFreeOfCycles().check(PROD);
}
// Integrations share nothing, so any dependency between them is wrong
@Test void integrationsAreIndependent() {
slices().matching("com.example.integrations.(*)..").should().notDependOnEachOther().check(PROD);
}
// Planted violation: BillingFixture (test sources, com.example.billing) calls orders.internal
@Test void ruleCatchesPlantedViolation() {
JavaClasses fixture = new ClassFileImporter().importClasses(com.example.billing.BillingFixture.class);
assertThrows(AssertionError.class, () -> ORDERS_INTERNAL_IS_PRIVATE.check(fixture));
}
}
A JUnit 5 test class that guards the orders internal package and checks module slices for cycles and independence. It also proves the boundary rule fires on a planted violation and fails instead of passing when it matches no classes.
Declaring allowed dependencies and adding targeted coupling rules
Direction is the first thing to pin down. layeredArchitecture() lets you name layers by package pattern and state who may call whom: whereLayer("Web").mayNotBeAccessedByAnyLayer(), whereLayer("Application").mayOnlyBeAccessedByLayers("Web"), and so on down to domain. If your code puts ports and adapters around the domain, onionArchitecture() expresses the same idea with domain models, domain services, application services and adapters. Add consideringOnlyDependenciesInLayers() so dependencies on the JDK or the framework don't count as violations.
The same mechanism works across modules. Treat each top-level module package as a layer and declare its permitted callers explicitly, for example that billing may be accessed only by orders. The dependency graph then lives in a reviewed file. Adding a new edge requires a pull request that changes the test, so the change is visible to reviewers and can be argued about.
On top of that, a few targeted rules catch the leaks that slice checks miss:
- No class outside a module may depend on its JPA entities or repositories,
.because("another module's tables are its private storage; go through its API"). @Controllerclasses reside only in..web.., and@Transactionalmethods are declared only in..application.., because transaction boundaries scattered across layers make rollback behaviour unpredictable.- Public methods in
..api..packages must not take or return types from..internal... Otherwise the module's internals become part of its contract.
Every rule should carry a .because(...). A failure message that states the reason teaches the engineer who hit it. A bare rule just invites them to suppress it. Keep the set small. Five rules that everyone understands will survive refactors. Thirty strict ones tend to collect exceptions until nobody trusts them.
@AnalyzeClasses(packages = "com.example.shop", importOptions = ImportOption.DoNotIncludeTests.class)
class ArchitectureRulesTest {
// Direction inside each module: web -> application -> domain.
@ArchTest
static final ArchRule layers = layeredArchitecture()
.consideringOnlyDependenciesInLayers() // JDK/framework deps are not violations
.layer("Web").definedBy("..web..")
.layer("Application").definedBy("..application..")
.layer("Domain").definedBy("..domain..")
.whereLayer("Web").mayNotBeAccessedByAnyLayer()
.whereLayer("Application").mayOnlyBeAccessedByLayers("Web")
.whereLayer("Domain").mayOnlyBeAccessedByLayers("Application")
.because("dependencies point inward so the domain stays framework-free");
// The module graph, written down: adding an edge means changing this test in a PR.
@ArchTest
static final ArchRule modules = layeredArchitecture()
.consideringOnlyDependenciesInLayers()
.layer("Orders").definedBy("com.example.shop.orders..")
.layer("Billing").definedBy("com.example.shop.billing..")
.whereLayer("Billing").mayOnlyBeAccessedByLayers("Orders")
.because("only checkout needs billing; other callers must be agreed in review");
@ArchTest
static final ArchRule billingStorageIsPrivate = noClasses()
.that().resideOutsideOfPackage("com.example.shop.billing..")
.should().dependOnClassesThat().resideInAPackage("com.example.shop.billing.internal.persistence..")
.because("another module's tables are its private storage; go through its API");
@ArchTest
static final ArchRule controllersInWeb = classes()
.that().areMetaAnnotatedWith(Controller.class) // also matches @RestController
.should().resideInAPackage("..web..")
.because("HTTP handling belongs in the web layer only");
@ArchTest
static final ArchRule transactionsInApplication = methods()
.that().areAnnotatedWith(Transactional.class)
.should().beDeclaredInClassesThat().resideInAPackage("..application..")
.because("scattered transaction boundaries make rollback behaviour unpredictable");
}
A JUnit 5 ArchUnit test that pins layer direction, records which module may call billing, and adds a few targeted rules, each with a .because(...) reason that appears in the failure message.
Adopting rules on a legacy codebase with FreezingArchRule
Requiring a full cleanup before the first rule can merge usually means the rules never ship. FreezingArchRule avoids that. You wrap an existing rule with FreezingArchRule.freeze(...). On the first run, ArchUnit records every current violation in a violation store, which is a set of plain text files whose location you set in archunit.properties. Commit that directory to the repository. After that, the frozen rule fails only on violations that are not in the store. The baseline is accepted, and anything new breaks the build.
The store also shrinks over time. When someone fixes a recorded violation, the next run removes it from the store, and the diff shows the debt going down. Matching ignores line numbers, so unrelated edits in the same file do not create false failures.
Changes to the store should go through pull-request review, like any other code change. Otherwise anyone can regenerate the store and silently accept a new cross-module dependency. Assign an owner to each store file, and review the remaining count on a regular schedule. Without an owner, the store turns into a permanent amnesty list. Expect some friction as well. When many people work in affected code, the store files produce merge conflicts and noisy diffs. Splitting stores per rule makes these easier to resolve.
The frozen rules need no special pipeline. They are ordinary JUnit tests and run in CI with every other unit test. Treat any change to a rule, such as loosening it, removing it, or re-freezing it, as an architecture decision with a written reason. A rule change made only to turn a red build green defeats the purpose of having the rule.
Alternatives: plain ArchUnit versus Spring Modulith
The weakest option is no test at all: boundaries recorded in a README or a wiki diagram and enforced only by code review. That works while the team is small and the reviewers remember the rules. It stops working when a reviewer misses one import in a large diff, and nothing flags the violation afterwards. Plain ArchUnit and Spring Modulith both replace that human check with a build failure. They differ in how much of the model you define yourself.
Spring Modulith builds on ArchUnit and makes the decisions for you. Each top-level package directly under the application's main package is a module. Subpackages are treated as internal by default. Verification fails if one module reaches into another's internals or if the modules form a cycle. Modulith also generates module documentation and diagrams from the same model. For integration between modules it supports Spring application events, so modules can communicate without calling each other's beans directly.
| Concern | Plain ArchUnit | Spring Modulith |
|---|---|---|
| Module definition | Whatever you encode in rules | Top-level packages under the main package |
| Framework requirement | Any JVM code | Spring Boot application |
| Rule authoring | You write and maintain every rule | Internals and cycle checks out of the box |
| Fine-grained rules | Arbitrary predicates on classes, methods, annotations | Possible, but you drop down to ArchUnit for them |
| Extras | None | Documentation generation, event-based integration |
Plain ArchUnit fits better in three cases:
- The code is not a Spring application.
- Your package layout does not follow Modulith's convention.
- You need targeted rules, such as "only the billing adapter may use the payment SDK."
Modulith saves effort in a Spring Boot codebase whose packages already match its model, because you get the core checks without writing them. If your existing layout doesn't match, adopting Modulith means restructuring packages first. Choosing plain ArchUnit avoids that restructuring, but every rule becomes code that you own.
When to avoid it, limits, and impact on cost and engineering time
ArchUnit reads compiled bytecode, so it only sees dependencies the compiler recorded. A green build does not prove two modules are independent.
| Coupling path | Seen by ArchUnit? |
|---|---|
| Imports, field types, method calls, inheritance | Yes |
| Reflection and string-based class or bean lookups | No |
| Two modules writing the same database table | No |
| Shared configuration keys | No |
| Runtime messaging (topics, event payload contracts) | No |
The tests also enforce only the boundaries you have already chosen. They cannot tell you where the right boundaries are. If you encode a bad split, the rules make it more expensive to fix later. Overly strict rules cause a second problem: developers move classes into a shared or common package to get the build passing. Review growth in those packages as carefully as a failing rule.
Cost and savings
Importing a large classpath adds build time, and the amount depends on codebase size. Measure it on your own build. Keep the cost down by scoping imports to your production packages, excluding test classes, and reusing the same @AnalyzeClasses locations so ArchUnit's import cache is shared between test classes.
Illustrative estimate (assumed numbers, not a benchmark): Net monthly hours = (V × (F_late − F_build)) − (B × t / 3600)
- Assume V = 4 violations per month.
- Assume fixing one late, in review or during extraction, takes F_late = 3 h, and fixing it in the build takes F_build = 0.5 h.
- Assume B = 2,000 builds per month, each adding t = 10 s.
That gives 10 h saved minus about 5.6 h of build time, a net gain of roughly 4.4 h per month.
Skip or delay these rules for small codebases, short-lived projects, or domains where the boundaries are still unsettled.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to archunit modular monolith, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.