← Spring Concepts

Spring Modulith: Testing and Documentation

Published on 2026-09-30·v1.0
🔬 Open Lab

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 publishing module with its real beans and database access, without starting notification, billing and 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

java
package 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:

plaintext
Error 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, so AFTER_COMMIT listeners fire.
  • Wait for: andWaitForEventOfType(Type.class) (with matching, matchingMapped) or andWaitForStateChange(() -> 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

  • @ModuleSlicing combined with Boot slices (@DataJpaTest, @DataJdbcTest, @WebMvcTest) limits the slice to one module (horizontal and vertical slicing; improved in 2.1).
  • spring-modulith-junit can 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

java
class 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:

plaintext
Base 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 id modulith, expose it via management.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-observability in 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 in DIRECT_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 ContentCatalog returns whatever you stub. If publishing changes the semantics of recentTitles, the notification test keeps passing. Keep at least one broader test (a @SpringBootTest or DIRECT_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. @ApplicationModuleTest infers 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. Scenario removes Thread.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 Helper beans 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.

Documentation Links