Getting started with Modulith Rules
Add one test-scope dependency, declare your modules, and let ArchUnit fail the build when a boundary is crossed. This page takes you from an empty pom.xml to a passing architecture test, then through the configuration and the defaults worth knowing about.
Requirements
These are the versions the library is built and tested against. The core module uses only long-stable ArchUnit APIs, so older ArchUnit and JUnit 5 lines are likely to work — they are not covered by CI, so verify before relying on them.
| Dependency | Version |
|---|---|
| Java | 17 or newer (CI builds on 17 and 21) |
| ArchUnit | 1.4.2 |
| JUnit | 6.1.2 (JUnit Platform) |
| Spring Boot | 4.1.0 — optional, only for modulith-rules-spring |
Spring dependencies in modulith-rules-spring are provided scope: you bring your own Spring version, and nothing Spring-related is added to your runtime classpath.
Installing
Modulith Rules is published to Maven Central under the io.modularmonolith group id; the current release is v0.2.0. Add the core module in test scope.
<dependency>
<groupId>io.modularmonolith</groupId>
<artifactId>modulith-rules-core</artifactId>
<version>0.2.0</version>
<scope>test</scope>
</dependency>
Using both modules
Add modulith-rules-spring for the Spring-specific rules. If you use both, import the BOM once and drop the versions from the individual dependencies.
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.modularmonolith</groupId>
<artifactId>modulith-rules-bom</artifactId>
<version>0.2.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
Working against unreleased changes
The sources on main carry the next development version, 0.2.0-SNAPSHOT. Build from source and depend on it.
git clone https://github.com/modular-monolith/modular-monolith-rules.git cd modular-monolith-rules mvn clean install
Quick start
The fastest path is convention-based: each module's base package is derived as <rootPackage>.<moduleName>.
@AnalyzeClasses(packages = "com.example")
class ArchitectureTest {
@ArchTest
static final List<ArchRule> rules =
ModulithRules.forPackage("com.example", "ordering", "payments", "inventory")
.allRules();
}
allRules() is deliberately narrow. It returns exactly three rules: allowed-dependency enforcement, internal-package protection, and cycle detection. Use allCoreRules() for the full core set, which adds API-only cross-module access and communication contract enforcement; both added rules skip anything you have not declared, so the list is safe on any configuration. Spring rules are separate either way — see applying rules.
Configuration
Three ways to describe your modules. Pick one; they produce the same rule set.
A. Fluent Java API
ModuleDefinition.builder(name) requires a basePackage. Omitting it throws IllegalStateException, so set it on every module you build by hand.
private static final ModulithRuleSet RULE_SET = ModulithRuleSet
.forRootPackage("com.example")
.module(ModuleDefinition.builder("ordering")
.basePackage("com.example.ordering") // required
.apiPackages(".api.")
.internalPackages(".internal.", ".infrastructure.")
.allowedDependencies("inventory", "payments")
.communicatesWith("notifications", CommunicationType.ASYNCHRONOUS)
.build())
.module(ModuleDefinition.builder("payments")
.basePackage("com.example.payments")
.apiPackages(".api.")
.internalPackages(".internal.")
.build())
.build();
The builder also offers shorthands that fill in the base package for you, which is why the quick-start example needs no basePackage.
ModulithRuleSet.forRootPackage("com.example")
.modules("ordering", "payments") // com.example.ordering, ...
.module("billing") // com.example.billing
.module("legacy", "com.acme.legacy") // explicit base package
.build();
B. YAML
Create src/test/resources/modulith-rules.yml, then load it with ModulithConfigLoader.loadFromClasspath(). Other entry points: loadFromClasspath(fileName), loadFromFile(path) and loadFromString(yaml).
root-package: com.example
modules:
ordering:
api-packages:
- .api.
internal-packages:
- .internal.
- .infrastructure.
allowed-dependencies:
- inventory
- payments
communication:
notifications: ASYNCHRONOUS
payments:
api-packages:
- .api.
@ArchTest
static final List<ArchRule> rules =
ModulithRules
.of(ModulithConfigLoader.loadFromClasspath())
.allRules();
The YAML parser is hand-written and covers only the subset shown above: no anchors, flow style, multi-line scalars or multi-document files. Indentation is significant and must be exactly two spaces per level — module names at 2, properties at 4, list items and communication entries at 6. Unknown keys are silently ignored, so check spelling if a setting seems to have no effect. base-package defaults to <root-package>.<moduleName>, and a missing root-package throws IllegalStateException.
C. Convention-based
Derives each base package as com.example.<moduleName>, with no API or internal declarations — which means the API rule has nothing to enforce and internal detection falls back to convention.
ModulithRules.forPackage("com.example", "ordering", "payments", "inventory")
.allRules();
Applying rules
Rules are reached through factories on ModulithRules, and checked against a set of imported classes.
ModulithRules rules = ModulithRules.of(RULE_SET); rules.boundaryRules().internalsShouldNotBeAccessedFromOutside().check(classes); rules.cycleRules().noModuleCycles().check(classes); rules.communicationRules().allCommunicationContractsRespected().check(classes);
Spring rules live in their own factory, built from the same rule set with the same of(...) style.
SpringModulithRules spring = SpringModulithRules.of(RULE_SET); spring.repositoriesShouldBeModuleInternal().check(classes); spring.allRules().forEach(rule -> rule.check(classes));
Behaviour worth knowing
These defaults are deliberate, but they surprise people who expect every rule to fail closed. Each one causes a rule to skip rather than fail.
allowedDependencies means unrestrictedA module that declares no allowed dependencies is not checked by modulesOnlyDependOnAllowedModules(). Declaring dependencies is what opts a module in.
apiPackagesIf the target module never declared an API package there is nothing to enforce against. noDirectInjectionOfInternalBeans() behaves the same way.
Rules only reason about classes inside a registered module's base package, so a dependency on an unregistered package is never a violation.
With no internalPackages configured, .internal. and .infrastructure. are treated as internal.
Under an ASYNCHRONOUS contract, calls to a class whose simple name ends in Event are allowed, so reading event.orderId() inside a listener is not reported. A NONE contract still forbids the dependency outright.
Registering the same name twice replaces the earlier definition, since modules are keyed by name.
Violation messages
Every message names the offending element and the target package, then suggests a concrete fix. These are real messages produced by the test suite.
Module 'ordering': class com.example.ordering.internal.OrderServiceImpl depends on com.example.inventory.api.InventoryService in module 'inventory', but 'inventory' is not in the allowed dependencies [payments]. Fix: add 'inventory' to the allowedDependencies of module 'ordering', or remove the dependency from com.example.ordering.internal.OrderServiceImpl
Circular module dependency detected: cycleB -> cycleA -> cycleB. Fix: break the cycle by moving the shared types into the api package of one of these modules, or extract them into a separate module that both can depend on
OrderServiceImpl.placeOrder is @Transactional and calls ...payments.api.PaymentService in module 'payments', so the transaction spans both modules. Fix: move the call out of the transactional method, or publish an event that module 'payments' handles after the transaction commits
Building and testing
mvn clean verify # full build: 53 tests across all three modules mvn test # tests only
Running a single test class needs two flags that are easy to miss. -am is required because modulith-rules-spring depends on modulith-rules-core, which is not installed in your local repository during a partial build; and because -am also builds core, where that test class does not exist, surefire needs the second flag to tolerate the empty match.
mvn -pl modulith-rules-spring -am test \
-Dtest=SpringModulithRulesTest \
-Dsurefire.failIfNoSpecifiedTests=false
To inspect a violation message yourself, write a throwaway test that catches the error instead of asserting on it, and read modulith-rules-<module>/target/surefire-reports/<class>.txt — surefire buffers stdout rather than printing it to the console.
Repository layout
| Artifact | Purpose |
|---|---|
modulith-rules-bom | Bill of materials: import once to align the versions of all modulith-rules artifacts. |
modulith-rules-core | Boundary, cycle and communication rules. Fluent API and YAML loader. No Spring dependency. |
modulith-rules-spring | Spring Boot rules for controllers, repositories, transactions, events and injection. |
modulith-rules-example | Working example project. Doubles as an end-to-end test of the published rules. |
Test fixtures live under io.modulith.rules.testfixtures and io.modulith.rules.testfixtures.spring. Each fixture module mirrors the api / internal split the rules expect, with deliberately violating and deliberately clean arrangements, so both branches of every rule are exercised.