io.modularmonolith · test-scope library

Enforce module boundaries at test time.

Modulith Rules is a set of pre-built ArchUnit rules for modular monoliths. It runs in your test phase, so nothing is added to the production classpath and the core module has no framework dependency.

Read the getting started guide Source on GitHub Run the example
Current release
Versionv0.2.0 Group idio.modularmonolith Java17 or newer ArchUnit1.4.2 JUnit6.1.2 Spring Boot4.1.0 (optional) LicenseApache 2.0
01 · Scope
Test-time only

Rules run during mvn test. Nothing ships to production, and the core module has no Spring dependency.

02 · Coverage
18 rules, four families

Boundaries, cycles and communication contracts in the core module; controllers, repositories, transactions, events and injection in the Spring module.

03 · Configuration
Fluent, YAML or convention

Declare modules in Java, in modulith-rules.yml, or let each base package be derived as <root>.<module>.

Add the dependency

Core module, test scope. Add modulith-rules-spring for the Spring rules, or import the BOM to align versions.

<dependency>
    <groupId>io.modularmonolith</groupId>
    <artifactId>modulith-rules-core</artifactId>
    <version>0.2.0</version>
    <scope>test</scope>
</dependency>

Write one test

The convention-based path: each module's base package is derived from the root package and the module name.

@AnalyzeClasses(packages = "com.example")
class ArchitectureTest {

    @ArchTest
    static final List<ArchRule> rules =
        ModulithRules.forPackage("com.example",
                "ordering", "payments", "inventory")
            .allRules();
}

What this test checks

allRules() applies three checks: allowed dependencies, internal-package protection and cycle detection.

An empty allowedDependencies setting leaves a module’s dependencies unrestricted.

API-only access requires configured API packages, and is checked by allCoreRules().

Classes outside registered modules are ignored.

A failing build, then the fix

An ordering class reaches into payments.internal. The test above fails the build; routing the call through payments.api makes it pass.

01 · The violation
package com.example.ordering.internal;

import com.example.payments.internal.PaymentServiceImpl;

class OrderServiceImpl {
    private final PaymentServiceImpl payments;
    // ...
}
02 · The test that detects it
@ArchTest
static final List<ArchRule> rules =
    ModulithRules.forPackage("com.example",
            "ordering", "payments", "inventory")
        .allRules();
03 · The failure
Class com.example.ordering.internal.OrderServiceImpl in module 'ordering' accesses
com.example.payments.internal.PaymentServiceImpl which is in the internal package of
module 'payments'. Fix: expose a service interface in the module's api package, for
example com.example.payments.api, or move com.example.ordering.internal.OrderServiceImpl
into module 'payments' if it belongs there
04 · The fix, through payments.api
package com.example.ordering.internal;

import com.example.payments.api.PaymentService;

class OrderServiceImpl {
    private final PaymentService payments;
    // ...
}

The rule catalogue

See the violation messages →

allRules() returns three rules: allowed dependencies, internal protection, cycle detection. allCoreRules() adds API-only access and communication contracts. Both of the added rules skip modules that have not declared the relevant configuration, so the longer list is safe to start with.

Boundary rules

7 rules · core
modulesOnlyDependOnAllowedModules()
Only declared dependencies are used
internalsShouldNotBeAccessedFromOutside()
Internal packages stay internal
crossModuleAccessOnlyThroughApi()
Cross-module calls go through API packages
moduleShouldHaveNoDependencies(name)
A module has zero outgoing dependencies
moduleShouldOnlyBeAccessedBy(name, ...)
Only the listed modules may reach it
onlyAllowedDependenciesAreUsed()
Alias for the allowed-dependency rule
noModuleAccessesInternalsOfOthers()
Alias for the internal-protection rule

Cycle rules

3 rules · core
noModuleCycles()
No circular dependencies between modules
moduleHasNoCycles(name)
One module takes part in no cycle
noCircularDependenciesBetweenModules()
Alias for noModuleCycles()
Not a rule, but useful: CycleRules.detectCycles(graph) is public and static, so you can run the DFS against a hand-built graph without importing any classes.

Communication rules

3 rules · directional contracts
asyncModulesShouldNotCallDirectly()
An ASYNCHRONOUS contract forbids direct calls. A static check: it does not prove runtime delivery is asynchronous or reliable
noCommModulesShouldNotInteract()
A NONE contract forbids any dependency
allCommunicationContractsRespected()
Both of the above, in one rule

Spring rules

5 rules · modulith-rules-spring
controllersShouldNotCrossModuleBoundaries()
Controllers do not depend on other modules' controllers
repositoriesShouldBeModuleInternal()
Repositories are reached from their own module only
transactionalMethodsShouldNotSpanModules()
Transactions do not cross a boundary
eventClassesShouldBeInApiPackages()
Shared events live in API packages
noDirectInjectionOfInternalBeans()
No cross-module injection past the public API

Export the module graph

dependencyGraph().toMermaid(classes) renders the module graph as a Mermaid flowchart string, ready to paste into any Markdown file. Every registered module appears, including isolated ones.

Solid arrows are observed dependencies; an arrow renders dashed when the source module declares an ASYNCHRONOUS contract. Dashed arrows show declared contracts, not verified runtime messaging. Nodes sort by name and edges by source then target, so committing the diagram produces a clean diff.

flowchart LR
    inventory
    notifications
    ordering
    payments
    notifications -.-> ordering
    ordering --> inventory

How it differs from Spring Modulith

Modulith Rules is a focused architecture-testing library built on ArchUnit. Spring Modulith is a broader toolkit for building modular Spring Boot applications. Both support architecture verification during tests.

Spring Modulith already provides built-in checks for module cycles, access to internal packages, and explicitly allowed dependencies through ApplicationModules.verify(). It also provides module integration testing, generated documentation, and optional runtime features such as event publication infrastructure and observability.

Modulith Rules focuses on configurable architecture policies: module boundaries, dependency cycles, directional communication contracts, and opinionated Spring-specific checks. Its core module has no Spring dependency.

Concern Modulith Rules Spring Modulith
Primary focusConfigurable ArchUnit rules for modular monolithsModularity toolkit for Spring Boot applications
Architecture verificationRuns during testsRuns during tests; optional startup verification is also available
Production classpathNothing added when used in test scopeVerification can be used in test scope; runtime features require corresponding production dependencies
Framework dependencyFramework-independent core; optional Spring rulesDesigned for Spring Boot applications
Module configurationFluent Java API, YAML, or package conventionsPackage conventions, annotations, and customization APIs
Built-in structural checksAllowed dependencies, internal-package protection, API-only access, and cyclesModule cycles, internal access, and explicitly allowed dependencies
Additional architecture policiesDirectional communication contracts and Spring-specific rules for controllers, repositories, transactions, events, and injectionExtensible verification with support for additional checks
Module integration testingNot providedSupported
Runtime event publication and observabilityNot providedAvailable through optional modules

The two can be used together when Modulith Rules provides additional policies you need. If Spring Modulith already covers your requirements, adding another library may be unnecessary. See the Spring Modulith documentation for its verification model and available features.

Getting started Configuration reference GitHub

Common questions

Short answers to what people ask before adopting the library.

What is a modular monolith?

A single deployable application whose code is divided into modules with explicit boundaries. Each module owns its data and exposes a narrow API; other modules go through that API rather than reaching into internal classes. You get the independence of service boundaries without distributed transactions, network failure modes or separate deployment pipelines.

How do you enforce module boundaries in Java?

With an architecture test that fails the build when a boundary is crossed. Modulith Rules provides that test: declare your modules, then check the rules against your compiled classes with ArchUnit. Package-private visibility and Java modules help, but neither expresses allowed dependencies between modules or detects cycles.

Should I use Modulith Rules or Spring Modulith?

Choose Modulith Rules when you want configurable ArchUnit policies with a framework-independent core, Java or YAML configuration, and its specific communication-contract or Spring architecture checks. Choose Spring Modulith when you want a broader Spring Boot modularity toolkit, including structural verification, module integration testing, documentation and optional runtime features. Both support architecture verification during tests and can be used together when additional policies are useful.

Does it work without Spring?

Yes. modulith-rules-core carries the boundary, cycle and communication rules and has no Spring dependency. The Spring rules live in a separate artifact you add only if you want them, and its Spring dependencies are provided scope.

Will it slow down my build?

The cost is ArchUnit's class import, which happens once per test class and is cached across rules in the same @AnalyzeClasses run. On a codebase of a few thousand classes the whole rule set adds a few seconds to the test phase.

How do I adopt it on an existing codebase?

Start with cycle detection alone, which needs no configuration beyond module names. Then declare allowedDependencies one module at a time: a module with none declared is left unrestricted, so you can tighten boundaries incrementally without a red build on day one.

Report an issue Contribute a rule