Spring Boot Tutorial
Modular Monoliths with Spring Modulith
Many teams jump to microservices to escape a "big ball of mud" monolith, only to discover that the mud was an architecture problem, not a deployment problem. A modular monolith keeps one deployable application but organises it into well-separated modules with explicit APIs — giving most of the maintainability benefits of microservices without the distributed-systems cost, and leaving the door open to extract a module into a service later.
Spring Modulith makes this practical: it detects modules from your package structure, verifies that modules only use each other's public APIs, lets modules communicate through events with a reliable publication registry, tests modules in isolation, and generates architecture documentation. This lesson walks through all of these with Spring Modulith 2 on Spring Boot 4.
Modules from Packages
Each direct sub-package of your main application package is an application module: com.webnest.shop.catalog, com.webnest.shop.orders, com.webnest.shop.payments. Types in a module's top-level package form its public API; types in sub-packages (orders.internal) are internal and must not be used by other modules. ApplicationModules.of(App.class).verify() fails a test if any module reaches into another's internals or if modules form cycles.
Communicating with Events
Instead of calling each other's services directly, modules publish domain events and react with @ApplicationModuleListener — an asynchronous, transactional event listener that runs after the publishing transaction commits, in its own transaction. This keeps modules loosely coupled. With the event publication registry (for example spring-modulith-starter-jpa), events are stored in the database with the business transaction and re-delivered if a listener fails, so no event is lost on a crash.
Externalising Events
Annotate an event with @Externalized("orders.placed") and add a Modulith externalisation module (Kafka, RabbitMQ, JMS, SQS...). Spring Modulith publishes the event to the broker after commit, via the registry — effectively a built-in transactional outbox. This is how a module starts integrating with other services, and the first step when extracting it into a microservice.
Testing and Documentation
@ApplicationModuleTest bootstraps only one module (and optionally its dependencies), so tests stay fast and prove the module works on its own. The Scenario API asserts on published events. Documenter generates C4/PlantUML component diagrams and a module canvas from the actual code, so architecture documentation never goes stale.
Examples
Dependencies and module structure
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-bom</artifactId>
<version>${spring-modulith.version}</version> <!-- the 2.x line for Spring Boot 4 -->
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-core</artifactId>
</dependency>
<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>
com.webnest.shop
├── ShopApplication.java
├── catalog/ <- module "catalog"
│ ├── CatalogService.java (public API)
│ └── internal/… (hidden from other modules)
├── orders/
│ ├── OrderService.java
│ ├── OrderPlaced.java (public event)
│ └── internal/…
└── notifications/
└── internal/…
(Set spring-modulith.version to the current 2.x release listed on the Spring Modulith project page; start.spring.io adds it for you when you select Spring Modulith.)
Verifying module boundaries
class ModularityTests {
ApplicationModules modules = ApplicationModules.of(ShopApplication.class);
@Test
void verifiesModularStructure() {
modules.forEach(System.out::println);
modules.verify();
}
@Test
void writesDocumentation() {
new Documenter(modules).writeDocumentation(); // PlantUML diagrams + module canvases in target/spring-modulith-docs
}
}
// A violation: notifications reaching into orders' internals
package com.webnest.shop.notifications.internal;
import com.webnest.shop.orders.internal.OrderEntity; // not allowed
# Catalog > Logical name: catalog > Base package: com.webnest.shop.catalog
# Orders > Logical name: orders > Depends on: catalog
# Notifications > ...
org.springframework.modulith.core.Violations:
- Module 'notifications' depends on non-exposed type com.webnest.shop.orders.internal.OrderEntity within module 'orders'!
Reliable events between modules and externalisation to Kafka
// orders module — public event
@Externalized("orders.placed::#{orderId()}") // also published to Kafka, keyed by orderId
public record OrderPlaced(String orderId, String email, BigDecimal total) {}
@Service
public class OrderService {
private final ApplicationEventPublisher events;
private final OrderRepository orders;
public OrderService(ApplicationEventPublisher events, OrderRepository orders) {
this.events = events;
this.orders = orders;
}
@Transactional
public void place(PlaceOrder cmd) {
Order order = orders.save(Order.from(cmd));
events.publishEvent(new OrderPlaced(order.getNumber(), cmd.email(), order.getTotal()));
}
}
// notifications module — reacts asynchronously after commit, in its own transaction
@Component
class OrderNotifications {
@ApplicationModuleListener
void on(OrderPlaced event) {
mail.sendConfirmation(event.email(), event.orderId());
}
}
# application.yml
spring:
modulith:
events:
externalization:
enabled: true
republish-outstanding-events-on-restart: true
select event_type, completion_date from event_publication;
com.webnest.shop.orders.OrderPlaced | 2026-09-27 10:12:04 (listener completed)
com.webnest.shop.orders.OrderPlaced | null (listener failed -> retried on restart)
Kafka topic orders.placed <- {"orderId":"WN-10231",...}
Testing one module in isolation with the Scenario API
@ApplicationModuleTest
class OrdersModuleTests {
@Autowired OrderService orders;
@Test
void publishesOrderPlaced(Scenario scenario) {
scenario.stimulate(() -> orders.place(new PlaceOrder("asha@webnest.in", List.of(/* ... */))))
.andWaitForEventOfType(OrderPlaced.class)
.matching(e -> e.email().equals("asha@webnest.in"))
.toArrive();
}
}
Bootstrapping @ApplicationModuleTest for Orders in mode STANDALONE (class com.webnest.shop.orders...)
OrdersModuleTests > publishesOrderPlaced() PASSED
Common Mistakes
- Organising packages by layer (controllers, services, repositories) instead of by business module, so Modulith sees one giant module.
- Making everything public in a module's top-level package, which removes the encapsulation Modulith checks.
- Calling other modules' services synchronously everywhere instead of using events for side effects.
- Using plain @EventListener for cross-module side effects without the publication registry, losing events on crashes.
- Skipping the verify() test, so boundary violations creep in unnoticed.
Key Points to Remember
- Spring Modulith treats each direct sub-package as a module with a public API and hidden internals.
- ApplicationModules.verify() enforces boundaries and detects cycles in a test.
- @ApplicationModuleListener + the event publication registry give reliable, decoupled module communication.
- @Externalized publishes events to brokers — a built-in outbox and a path to microservices.
- @ApplicationModuleTest and Documenter provide isolated tests and living architecture docs.
Practice the examples
Change an input, predict the result, then compare it with the output. Explain why the result changes.
Use your local JDK or project IDE for these examples. Codelab currently runs Python and HTML/CSS/JavaScript; framework examples may need project dependencies.