Modular Monoliths with Spring Modulith

Keep one deployable app but split it into verified modules with Spring Modulith: boundary checks in a test, events between modules, per-module tests.

Syntaxcom.example.shop // @SpringBootApplication orders/ // module: public types are its API internal/ // internal to the module inventory/ // test ApplicationModules.of(ShopApplication.class).verify();

A modular monolith is one application, one deployment and usually one database, divided internally into modules with clear boundaries. You get most of the design benefits of microservices without network calls, distributed transactions or a dozen pipelines. If a module later needs to scale on its own, a clean boundary makes it far easier to extract.

Package by feature gets you most of the way, but nothing stops boundaries eroding: someone makes a repository public "just this once", or orders and billing start calling each other in a cycle. Spring Modulith is a separate Spring project that turns those conventions into checks.

How it sees your code

  • Each direct sub-package of the package holding your @SpringBootApplication class is an application module: com.example.shop.orders, com.example.shop.catalog.
  • Public types in a module's top-level package are its API. Sub-packages such as orders.internal are internal, even if their types are public. That solves the problem plain Java has, where splitting a feature into sub-packages forces types public.
  • A test calling ApplicationModules.of(ShopApplication.class).verify() fails the build when one module uses another's internals or when modules depend on each other in a cycle.

Talking between modules

Calling another module's public service is allowed, but every such call is a compile-time dependency. For "something happened, others may care", publish an application event instead. orders publishes OrderPlaced; shipping and loyalty listen. orders no longer knows they exist, and adding a listener does not change it.

Modulith adds @ApplicationModuleListener (in spring-modulith-events-api, which the event starters bring, not the core starter). It is meta-annotated with @Async, @TransactionalEventListener (after commit by default) and @Transactional(propagation = REQUIRES_NEW): the usual safe shape for a cross-module reaction. With the JPA or JDBC event starter it also keeps an event publication registry: each event is stored, once per listener, in the same transaction as the business change and marked complete when that listener succeeds, so failed or interrupted deliveries can be found and resubmitted instead of silently lost. Setting spring.modulith.events.republish-outstanding-events-on-restart=true resubmits them at startup; the starter also switches on async processing, so no @EnableAsync of your own is needed.

Testing and documentation

@ApplicationModuleTest starts a Spring context containing only the module of the test's package. The default bootstrap mode is STANDALONE; DIRECT_DEPENDENCIES and ALL_DEPENDENCIES pull in the modules it uses. Tests get faster and hidden dependencies show up. A Scenario parameter lets a test publish an event or call a method (stimulate), then wait for an event of a given type (andWaitForEventOfType) or a state change (andWaitForStateChange) before verifying. The Documenter class writes PlantUML component diagrams (components.puml for the whole system, module-<name>.puml per module) and Asciidoc module canvases into target/spring-modulith-docs under Maven or build/spring-modulith-docs under Gradle.

Adopting it

Import spring-modulith-bom (the 2.1 line pairs with Spring Boot 4.1; this course uses 2.1.1), add spring-modulith-starter-core and spring-modulith-starter-test, write the verification test, and fix what it reports. In an older codebase the first run usually shows cycles; break them by replacing a direct call with an event or by moving a type to the module that owns it. Verification and documentation run only in tests, and the core starter does little at runtime, so it is low risk to add. A small service with three packages does not need it.

Example

Example · java
import java.time.Clock;
import java.time.Instant;
import java.util.List;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionalEventListener;

// --- module com.example.shop.orders (its API: OrderPlaced, OrderService) ---

record OrderPlaced(long orderId, String customerId, List<String> skus, Instant placedAt) {
}

interface OrderStore {
    long insert(String customerId, List<String> skus);
}

@Service
public class OrderService {

    private final OrderStore store;
    private final ApplicationEventPublisher events;
    private final Clock clock;   // injected, so tests can fix placedAt

    OrderService(OrderStore store, ApplicationEventPublisher events, Clock clock) {
        this.store = store;
        this.events = events;
        this.clock = clock;
    }

    @Transactional
    public long place(String customerId, List<String> skus) {
        long id = store.insert(customerId, skus);
        // orders does not know who listens: no dependency on inventory.
        events.publishEvent(new OrderPlaced(id, customerId, skus, clock.instant()));
        return id;
    }
}

// --- module com.example.shop.inventory (depends only on the event type) ---

interface StockLedger {
    void reserve(long orderId, List<String> skus);
}

@Component
class ReserveStockOnOrderPlaced {

    private final StockLedger stock;

    ReserveStockOnOrderPlaced(StockLedger stock) {
        this.stock = stock;
    }

    // The plain Spring shape that @ApplicationModuleListener packages up:
    // after commit, asynchronous, in its own transaction.
    // Written by hand like this, @Async needs @EnableAsync on a configuration class.
    @Async
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    @TransactionalEventListener
    void on(OrderPlaced event) {
        stock.reserve(event.orderId(), event.skus());
    }
}

When to use it

  • A growing monolith adds a Modulith verification test that fails the build when the billing module reaches into orders' internal repository.
  • An e-commerce app replaces direct calls from orders to shipping, email and loyalty with an OrderPlaced event so orders depends on none of them.
  • A team uses the event publication registry so stock reservations interrupted by a restart are resubmitted instead of silently lost.

More examples

Adding Spring Modulith to pom.xml

The BOM keeps all Modulith artefacts on one version, so the starters need no version of their own. Spring Modulith 2.1 is the line built for Spring Boot 4.1; selecting Spring Modulith on start.spring.io adds the same import for you. The core starter does not include the events API, so <code>@ApplicationModuleListener</code> arrives with an event starter: the JPA one brings it together with the event publication registry, a Jackson serialiser for stored events and the auto-configuration that enables async processing. For verification and documentation alone, the core and test starters are enough.

Example · xml
<properties>
    <!-- The 2.1 line pairs with Spring Boot 4.1. -->
    <spring-modulith.version>2.1.1</spring-modulith.version>
</properties>

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.modulith</groupId>
            <artifactId>spring-modulith-bom</artifactId>
            <version>${spring-modulith.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.springframework.modulith</groupId>
        <artifactId>spring-modulith-starter-core</artifactId>
    </dependency>
    <!-- Event publication registry backed by your JPA database -->
    <dependency>
        <groupId>org.springframework.modulith</groupId>
        <artifactId>spring-modulith-starter-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.modulith</groupId>
        <artifactId>spring-modulith-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

The verification and documentation test

This is a plain JUnit test, so it runs in the normal build and blocks a pull request that breaks a boundary. It does not start a Spring context: <code>ApplicationModules.of</code> analyses the compiled classes, treating each direct sub-package of the application class's package as a module. <code>writeDocumentation()</code> writes <code>components.puml</code>, one <code>module-&lt;name&gt;.puml</code> per module and an Asciidoc canvas per module into the build output, which is useful for reviews and onboarding.

Example · java
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.modulith.core.ApplicationModules;
import org.springframework.modulith.docs.Documenter;

// Stands in for your real application class in the root package.
@SpringBootApplication
class ShopApplication {
}

class ModularityTests {

    ApplicationModules modules = ApplicationModules.of(ShopApplication.class);

    @Test
    void verifiesModularStructure() {
        modules.verify();   // fails on cycles and on access to another module's internals
    }

    @Test
    void writesDocumentation() {
        // PlantUML diagrams and module canvases under target/spring-modulith-docs
        new Documenter(modules).writeDocumentation();
    }
}

A module listener and a module test

With <code>Order</code> and <code>OrderRepository</code> in <code>orders.internal</code>, they can be <code>public</code> for convenience inside <code>orders</code>, yet the verification test rejects any use from <code>inventory</code>. <code>@ApplicationModuleListener</code> replaces the three annotations from the main example with one. The module test publishes the event, then polls the supplier until the predicate holds, so it waits for the asynchronous listener without sleeping and without starting the orders module. The event uses a fixed instant rather than <code>Instant.now()</code>, so the test is repeatable.

Example · java
import static org.assertj.core.api.Assertions.assertThat;

import java.time.Instant;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import org.junit.jupiter.api.Test;
import org.springframework.stereotype.Component;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.modulith.test.ApplicationModuleTest;
import org.springframework.modulith.test.Scenario;

// In a real project: orders/OrderPlaced.java (orders' API) and inventory/*.java.
record OrderPlaced(long orderId, String customerId, List<String> skus, Instant placedAt) {
}

@Component
class StockLedger {

    private final Set<Long> reserved = ConcurrentHashMap.newKeySet();

    void reserve(long orderId, List<String> skus) {
        reserved.add(orderId);
    }

    boolean isReservedFor(long orderId) {
        return reserved.contains(orderId);
    }
}

@Component
class InventoryListener {

    private final StockLedger stock;

    InventoryListener(StockLedger stock) {
        this.stock = stock;
    }

    // Async, after the order's transaction commits, in a new transaction.
    @ApplicationModuleListener
    void on(OrderPlaced event) {
        stock.reserve(event.orderId(), event.skus());
    }
}

// src/test/java/com/example/shop/inventory/InventoryModuleTests.java
@ApplicationModuleTest   // starts only the inventory module
class InventoryModuleTests {

    private static final Instant PLACED_AT = Instant.parse("2026-03-01T10:00:00Z");

    @Test
    void reservesStockWhenOrderPlaced(Scenario scenario, StockLedger stock) {
        scenario.publish(new OrderPlaced(42L, "c-1", List.of("SKU-1"), PLACED_AT))
                .andWaitForStateChange(() -> stock.isReservedFor(42L), Boolean::booleanValue)
                .andVerify(reserved -> assertThat(reserved).isTrue());
    }
}

Discussion

  • Be the first to comment on this lesson.