Spring Modulith: Testing and Documentation
Objective
A @SpringBootTest starts the whole application: every module, every bean, every datasource, for a test that is about one module. It is slow, and it hides the dependencies between modules, because whatever a module needs from another one is simply there. Spring Modulith turns the module model it builds for verification (see spring-modulith-application-modules-and-verification) into two more tools. @ApplicationModuleTest starts only one module (optionally with its dependencies), so a test fails loudly when the module reaches for a bean it should not need, and Scenario / AssertablePublishedEvents test the event-driven collaboration between modules, including asynchronous @ApplicationModuleListeners. Documenter writes the same model out as PlantUML diagrams and a per-module "canvas", so architecture documentation is generated by the build instead of maintained by hand. This concept covers Spring Modulith 2.x on Spring Boot 4, plus the runtime observability support (spring-modulith-starter-insight).
Use Cases
- Fast integration tests per module. Test the
publishingmodule with its real beans and database access, without startingnotification,billingand the rest. - Making cross-module dependencies visible in tests. A module that needs three mocks to start is telling you something about its coupling.
- Testing event-driven flows without
Thread.sleep. Publish an event, wait for the resulting event or state change, verify it, with a timeout. - Architecture documentation that cannot drift. C4 component diagrams and module canvases regenerated on every build, checked into docs or published from CI.
- Runtime insight per module. An actuator endpoint describing the module structure and traces with one span per module invocation.
Deep Dive
Setup
plaintext<dependency> <groupId>org.springframework.modulith</groupId> <artifactId>spring-modulith-starter-test</artifactId> <scope>test</scope> </dependency>
The test starter brings spring-modulith-test (module tests, Scenario, published events) and spring-modulith-docs (Documenter). Versions come from spring-modulith-bom.
@ApplicationModuleTest
javapackage com.kurz.moduletests.publishing; // the module's package
@ApplicationModuleTest
class PublishingModuleTests {
@Autowired PublishingService publishing;
@Autowired ApplicationContext context;
@Test
void bootstrapsOnlyThisModule() {
assertThat(context.getBeanNamesForType(PublishingService.class)).hasSize(1);
assertThat(context.containsBean("digestService")).isFalse(); // notification's bean
}
}The test class must live in the module's package (or a subpackage of it): Spring Modulith derives the module under test from the test's package, then restricts component scanning and auto-configuration packages to it. Everything else about the test is a normal Spring Boot test: @Autowired, @MockitoBean, @Sql, Testcontainers.
The bootstrap mode controls how much of the application starts:
BootstrapMode |
Starts |
|---|---|
STANDALONE (default) |
only the module under test (plus shared modules declared in @Modulithic) |
DIRECT_DEPENDENCIES |
the module and the modules it depends on directly |
ALL_DEPENDENCIES |
the module and its whole dependency tree |
java@ApplicationModuleTest(mode = ApplicationModuleTest.BootstrapMode.DIRECT_DEPENDENCIES)extraIncludes adds specific other modules by name when a mode is too coarse.
Missing beans are a design signal
In STANDALONE mode, a module that injects a bean from another module fails to start:
plaintextError creating bean with name 'digestService': Unsatisfied dependency expressed through constructor parameter 0: No qualifying bean of type 'com.kurz.moduletests.publishing.ContentCatalog' available
notification.DigestService uses publishing.ContentCatalog. Two honest answers:
java@ApplicationModuleTest
class NotificationModuleTests {
@MockitoBean ContentCatalog catalog; // 1. keep the test to this module, stub the API
@Autowired DigestService digest;
@Test
void digestUsesCatalog() {
when(catalog.recentTitles(3)).thenReturn(List.of("C", "B", "A"));
assertThat(digest.weeklyDigest()).isEqualTo("This week: C, B, A");
}
}or mode = DIRECT_DEPENDENCIES (2. start publishing too, with its database needs). Each mock is a dependency you can see; a module whose tests need many of them is more coupled than its package structure suggests. Modules that only react to events need no mocks at all.
Asserting published events
java@Test
void publishesEvent(AssertablePublishedEvents events) {
publishing.publish("Modular monoliths");
assertThat(events)
.contains(ContentPublished.class)
.matching(ContentPublished::title, "Modular monoliths");
}PublishedEvents (and its AssertJ-enabled variant AssertablePublishedEvents) is injected as a test method parameter and records every application event published while the test runs. ofType(...) / matching(...) filter them. Since 2.1 it sees events published from any thread by default (spring.modulith.test.thread-bound-published-events=true restores the older thread-bound behaviour).
Scenario: stimulus, wait, verify
Testing an @ApplicationModuleListener by hand means publishing inside a transaction that really commits, then polling until the asynchronous listener has done its work. Scenario wraps both:
java@Test
void notifiesSubscribers(Scenario scenario) {
scenario.publish(new ContentPublished(42, "Modular monoliths"))
.andWaitForEventOfType(NotificationSent.class)
.matching(sent -> sent.title().equals("Modular monoliths"))
.toArriveAndVerify(sent -> assertThat(sent.recipients()).isEqualTo(2));
}- Stimulus:
scenario.publish(event)publishes an event,scenario.stimulate(() -> service.doSomething())invokes a bean. Both run in a transaction, soAFTER_COMMITlisteners fire. - Wait for:
andWaitForEventOfType(Type.class)(withmatching,matchingMapped) orandWaitForStateChange(() -> repository.findById(id))for side effects that are not events. - Verify:
toArrive(),toArriveAndVerify(event -> ...),andVerify(result -> ...).
Waiting uses Awaitility; .customize(conditions -> conditions.atMost(Duration.ofSeconds(2))) changes the timeout for one scenario, a ScenarioCustomizer for a whole test class. For the module under test, the stimulus is often an event from another module: the test does not need that module to exist, only its event type.
Other test support
@ModuleSlicingcombined with Boot slices (@DataJpaTest,@DataJdbcTest,@WebMvcTest) limits the slice to one module (horizontal and vertical slicing; improved in 2.1).spring-modulith-junitcan skip test classes of modules not affected by the changes in the current branch (spring.modulith.test.reference-commit), useful to speed up CI on large monoliths. It backs off when build files or classpath resources change.
Documenter: diagrams and canvases from code
javaclass DocumentationTests {
@Test
void writeDocumentation() {
var modules = ApplicationModules.of(Application.class);
new Documenter(modules).writeDocumentation();
}
}writeDocumentation() is the shorthand for all four methods below, and writes to target/spring-modulith-docs (build/... with Gradle):
| Method | Output |
|---|---|
writeModulesAsPlantUml() |
components.puml: C4 component diagram of all modules and their dependencies |
writeIndividualModulesAsPlantUml() |
module-<name>.puml: one module with its direct neighbours |
writeModuleCanvases() |
module-<name>.adoc: the Application Module Canvas |
writeAggregatingDocument() |
all-docs.adoc: includes everything above |
DiagramOptions.defaults().withStyle(DiagramStyle.UML) switches from C4 to UML component notation. The canvas is a table per module, generated from the bytecode:
plaintextBase package com.kurz.moduletests.notification Spring components Services: c.k.m.n.DigestService Bean references c.k.m.p.ContentCatalog (in Publishing) Events listened to c.k.m.p.ContentPublished (async)
It also lists aggregate roots, published events (with the methods that publish them), configuration properties (with spring-boot-configuration-processor) and jMolecules stereotypes when present. The .adoc files are meant to be included in an Asciidoctor site; the .puml files render with any PlantUML tool.
Runtime insight
plaintext<dependency> <groupId>org.springframework.modulith</groupId> <artifactId>spring-modulith-starter-insight</artifactId> <scope>runtime</scope> </dependency>
The insight starter adds the actuator and observability modules:
/actuator/modulith(endpoint idmodulith, expose it viamanagement.endpoints.web.exposure.include) returns the module structure as JSON: modules, base packages, dependencies and their types.- Observability: calls into a module's API are wrapped in Micrometer observations, so a distributed trace (see
distributed-tracing-and-observabilityin System Design) shows one span per module crossed, and module-level metrics become possible.
Moments
spring-modulith-moments (included in the core starter) publishes passage-of-time events: HourHasPassed, DayHasPassed, WeekHasPassed, MonthHasPassed, QuarterHasPassed, YearHasPassed. A billing module listens to MonthHasPassed instead of owning a cron expression. Granularity, zone and locale are configurable (spring.modulith.moments.*), and spring.modulith.moments.enable-time-machine=true exposes a TimeMachine bean that shifts time forward in tests.
Trade-offs
- Module tests are faster only if modules are really independent. A module whose beans inject three other modules' services needs three mocks in
STANDALONE, or starts half the application inDIRECT_DEPENDENCIES. The test tool does not fix coupling; it exposes it. That is useful feedback, but the speed gain depends on the design. - Mocking another module's API can drift from reality. A
@MockitoBean ContentCatalogreturns whatever you stub. Ifpublishingchanges the semantics ofrecentTitles, thenotificationtest keeps passing. Keep at least one broader test (a@SpringBootTestorDIRECT_DEPENDENCIES) for the most important cross-module flows:java@ApplicationModuleTest(mode = BootstrapMode.DIRECT_DEPENDENCIES) // real publishing, fewer mocks - Test location is part of the contract.
@ApplicationModuleTestinfers the module from the test's package. A test class placed in the root package, or moved during a refactoring, silently tests a different (or no) module. The convention is simple but must be followed. - Asynchronous tests need timeouts, and timeouts are flaky under load.
ScenarioremovesThread.sleep, not the waiting. On a slow CI agent the default timeout may be too short, and a genuinely broken listener only fails after the full timeout. Tune timeouts per scenario rather than globally making them long. - Generated documentation is only as good as the code's names. The canvas lists what the bytecode shows: stereotypes, bean references, event types. It will not explain why a dependency exists, and a module full of generic
Helperbeans produces a useless canvas. Pair it with a few hand-written architecture decision records. - Observability per module has a cost. Wrapping module API calls in observations adds spans and overhead on every call. Sample traces in production and enable it where the insight is worth it.