Handbook

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.

DependencyVersion
Java17 or newer (CI builds on 17 and 21)
ArchUnit1.4.2
JUnit6.1.2 (JUnit Platform)
Spring Boot4.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();
Note

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));

See the full rule catalogue →

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.

Empty allowedDependencies means unrestricted

A module that declares no allowed dependencies is not checked by modulesOnlyDependOnAllowedModules(). Declaring dependencies is what opts a module in.

API rules skip modules with no apiPackages

If the target module never declared an API package there is nothing to enforce against. noDirectInjectionOfInternalBeans() behaves the same way.

Classes outside every declared module are ignored

Rules only reason about classes inside a registered module's base package, so a dependency on an unregistered package is never a violation.

Internal packages fall back to convention

With no internalPackages configured, .internal. and .infrastructure. are treated as internal.

Event accessors are exempt from async contracts

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.

Module names must be unique

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

ArtifactPurpose
modulith-rules-bomBill of materials: import once to align the versions of all modulith-rules artifacts.
modulith-rules-coreBoundary, cycle and communication rules. Fluent API and YAML loader. No Spring dependency.
modulith-rules-springSpring Boot rules for controllers, repositories, transactions, events and injection.
modulith-rules-exampleWorking 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.

Clone the repository Compared to Spring Modulith