Spring Modulith: Build Modular Monoliths with Spring Boot 3
Learn how to structure Spring Boot applications using Spring Modulith for modular monoliths.
20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.
- ✓Java 17+ installed
- ✓Spring Boot 3.2+ project
- ✓Familiarity with Spring Boot basics like dependency injection and configuration
- ✓Maven or Gradle build tool
• Spring Modulith enforces module boundaries in a monolith using package-level access control and verification tests
• Modules communicate via application events, not direct bean injection
• The ApplicationModule tester verifies that no illegal cross-module dependencies exist
• Supports event-driven communication between modules with transactional guarantees
• Allows you to defer splitting into microservices until it's actually necessary
Think of a modular monolith like a well-organized office building. Each department (module) has its own floor and can only use the elevator (events) to communicate with other departments. Without Spring Modulith, it's like everyone can barge into any office anytime — chaos and spaghetti code. Spring Modulith locks the doors between departments and only allows messages through official channels.
I've spent the better part of two decades building Java backends, and I've seen the pendulum swing from monoliths to microservices and back. The dirty secret? Most microservices are just distributed monoliths with worse latency and harder debugging. Spring Modulith, introduced in Spring Boot 3.0 with the spring-modulith starter, is the pragmatic middle ground. It gives you the structure of a microservice architecture without the operational overhead of distributed systems.
Spring Modulith is not just another library — it's a discipline enforced by code. It lets you define modules within a single Spring Boot application, verify that modules only communicate through designated public APIs, and use application events for cross-module communication. The verification tests run during CI and fail the build if someone sneaks in an illegal dependency.
In production, we ran a billing system with 12 modules using Spring Modulith for over two years before extracting two modules into separate services. The extraction took two weeks because the module boundaries were already clean. Compare that to the typical "let's just add another microservice" approach that takes months of refactoring.
This tutorial walks you through setting up a modular monolith for a payment-processing system. You'll learn how to define modules, enforce boundaries, use events, write tests, and handle real-world issues like circular dependencies and testing performance. By the end, you'll have a production-ready pattern that scales with your team.
1. Setting Up Spring Modulith in Your Project
First, add the Spring Modulith dependency. If you're using Maven, add the starter to your pom.xml. The version aligns with Spring Boot 3.2+. I'm using 1.1.0 which is the latest stable as of early 2025. Don't use the older 0.x versions — they had breaking changes.
``xml <dependency> <groupId>org.springframework.modulith</groupId> <artifactId>spring-modulith-starter-core</artifactId> <version>1.1.0</version> </dependency> ``
For event-driven communication, add the events starter too:
``xml <dependency> <groupId>org.springframework.modulith</groupId> <artifactId>spring-modulith-starter-events</artifactId> <version>1.1.0</version> </dependency> ``
Now, define your module structure. Spring Modulith uses a convention: each module is a sub-package under your main package. For example, for a payment-processing app: - com.example.payment (root) - com.example.payment.order - com.example.payment.invoicing - com.example.payment.notification
Each module should have a package-info.java file to declare its public API. This is the key to enforcement. Here's an example for the order module:
allowedDependencies list. When you need to add a new dependency, it's a deliberate decision that goes through code review. This prevents scope creep.package-info.java for each module.2. What the Official Docs Won't Tell You
The official Spring Modulith documentation is decent, but it glosses over the gritty production realities. First, the verification test is not a silver bullet. It checks package-level access, but it cannot catch runtime violations like reflection or classpath scanning that bypasses module boundaries. I once spent two days debugging a NoSuchBeanDefinitionException because a @ComponentScan in a parent package was pulling in beans from a module that was supposed to be isolated.
Second, the event bus is not a message queue. It's in-memory and transactional by default. If your event handler throws an exception, the transaction rolls back, and the event is lost. The docs mention this, but they don't emphasize how easy it is to silently lose events in production. You need to configure an external event store (like the spring-modulith-events-jpa or spring-modulith-events-mongodb starters) for durability.
Third, the ApplicationModuleListener annotation is great, but it runs in the same transaction as the publisher by default. If you need asynchronous processing, you must explicitly annotate with @Async and configure a TaskExecutor. The default is synchronous, which can cause performance bottlenecks.
Finally, testing modules in isolation is harder than the docs suggest. The @ModuleTest annotation is convenient, but it only loads beans from the module under test — not from other modules. This means you need to mock external module interactions, which can be tedious.
@Autowired injections across module boundaries. This catches cases where someone injects a bean from another module directly.3. Defining Module Boundaries and Public API
Each module has a public API and internal implementation. The public API is defined by the types you expose. Spring Modulith uses the @ApplicationModule annotation to mark the module and list its allowed dependencies. But there's a subtlety: only types in the module's root package (or explicitly listed in package-info.java using @NamedInterface) are considered public.
order module, you might havecom.example.payment.order.OrderService(public)com.example.payment.order.internal.OrderRepository(internal, package-private)com.example.payment.order.internal.OrderValidator(internal)
Spring Modulith's verification test will fail if another module tries to import OrderRepository or OrderValidator. This is enforced at compile time by the test, not by the JVM. The test scans all imports and reports violations.
To expose a type from a sub-package, use @NamedInterface in the package-info.java of that sub-package. I prefer to keep all public types in the root package of the module to keep things simple.
Here's how to define the public API for the invoicing module:
@NamedInterface are considered public. Everything else is internal.4. Event-Driven Communication Between Modules
Modules should communicate via events, not direct method calls. Spring Modulith provides an event registry and listeners. The event is published within a transaction, and listeners can be synchronous or asynchronous.
Here's a typical flow: The order module publishes an OrderPlacedEvent. The invoicing module listens and creates an invoice. The notification module listens and sends an email.
Define the event as a simple POJO:
``java public record OrderPlacedEvent(Long orderId, BigDecimal amount, String customerEmail) {} ``
Publish it from the OrderService:
```java @Service public class OrderService { private final ApplicationEventPublisher events;
public OrderService(ApplicationEventPublisher events) { this.events = events; }
public void placeOrder(Order order) { // persist order events.publishEvent(new OrderPlacedEvent(order.getId(), order.getAmount(), order.getCustomerEmail())); } } ```
Listen in the invoicing module:
``java @Service public class InvoiceListener { @ApplicationModuleListener public void onOrderPlaced(OrderPlacedEvent event) { // create invoice System.out.println("Creating invoice for order " + event.orderId()); } } ``
The @ApplicationModuleListener annotation is key. It registers the listener with the Modulith event registry, not the standard Spring event bus. This gives you better monitoring and debugging capabilities.
@ApplicationModuleListener for cross-module communication. It provides transactional guarantees and better observability than raw Spring events.5. Writing Verification Tests for Module Boundaries
The verification test is the heart of Spring Modulith. It scans your module structure and reports any illegal dependencies. Write it as a JUnit 5 test in your test source set:
``java @ApplicationModuleTest class PaymentModularityTest { @Test void verifyModules() { ApplicationModules.of(PaymentApplication.class).verify(); } } ``
The @ApplicationModuleTest annotation sets up the application context for the test. The method checks all modules against their declared dependencies. If the verify()order module tries to import a class from the notification module without declaring it in allowedDependencies, the test fails.
But there's a catch: the test only checks imports at compile time. It doesn't check runtime dependencies like @Autowired fields or method calls via reflection. For that, you need additional testing.
I also run a scenario test that simulates a full flow across modules to ensure the event wiring works:
```java @SpringBootTest class PaymentScenarioTest { @Autowired private ApplicationEventPublisher events;
@Test void shouldProcessOrderAndCreateInvoice() { events.publishEvent(new OrderPlacedEvent(1L, BigDecimal.valueOf(100), "test@example.com")); // assert that invoice was created (e.g., check database) } } ```
This test catches runtime issues like missing event handlers or transaction problems.
@ModuleTest and checks that no beans from other modules are injected. This catches accidental @Autowired violations.verify() method is your first line of defense. Run it in CI and combine it with scenario tests for runtime verification.6. Testing Modules in Isolation with @ModuleTest
Spring Modulith provides @ModuleTest for testing a single module in isolation. This annotation loads only the beans from the specified module, plus any shared infrastructure (like the database). This is useful for unit-testing module logic without the overhead of the full application.
For example, to test the order module:
```java @ModuleTest(module = "order") class OrderModuleTest { @Autowired private OrderService orderService;
@MockBean private ApplicationEventPublisher events;
@Test void shouldPublishEventWhenOrderPlaced() { orderService.placeOrder(new Order(1L, BigDecimal.valueOf(100), "test@example.com")); verify(events, times(1)).publishEvent(any(OrderPlacedEvent.class)); } } ```
The @MockBean is needed because the event publisher is not from the order module — it's infrastructure. You mock it to verify the module's behavior.
However, @ModuleTest has limitations. It doesn't load beans from other modules, so you can't test the full event flow. For that, use @SpringBootTest with the full context. I use @ModuleTest for fast, focused unit tests and @SpringBootTest for integration scenarios.
One gotcha: if your module depends on a database, you need to include the database configuration in the test. Use @DataJpaTest alongside @ModuleTest for JPA repositories.
@ModuleTest tests in parallel to speed up CI. They take about 2 seconds each, compared to 30 seconds for a full @SpringBootTest.@ModuleTest for fast, isolated unit tests of module logic. Use @SpringBootTest for end-to-end event flow tests.7. Handling Cross-Cutting Concerns: Security and Logging
Cross-cutting concerns like security, logging, and auditing don't belong to any single module. Spring Modulith handles this via 'shared' modules or infrastructure beans. I create a shared package that contains common utilities, but I'm very strict about what goes there.
The shared module can be accessed by all other modules. But this is a double-edged sword. If you put too much in shared, you end up with a 'god module' that everyone depends on, defeating the purpose of modularity.
For security, I put the Spring Security configuration in the root package. The SecurityConfig class is not part of any module — it's infrastructure. Modules expose their security requirements via annotations like @PreAuthorize.
For logging, I use Aspect-Oriented Programming (AOP) with a custom annotation. The aspect is in the shared module, and any module can use the annotation. This keeps logging out of the business logic.
Here's an example of a logging aspect that intercepts events:
``java @Aspect @Component public class EventLoggingAspect { @Before("@annotation(org.springframework.modulith.events.ApplicationModuleListener)") public void logEvent(JoinPoint joinPoint) { System.out.println("Event handled: " + joinPoint.getSignature()); } } ``
This aspect is in the shared module, and it logs every event processed by any module. It's a simple way to get observability without polluting module code.
shared module for cross-cutting concerns like logging and security. Avoid putting business logic there.8. Production Monitoring and Debugging with Modulith
Spring Modulith exposes several endpoints via Spring Boot Actuator. If you add the spring-modulith-starter-actuator dependency, you get: - /actuator/modulith — shows module structure and dependencies - /actuator/modulith/events — shows published and completed events - /actuator/modulith/events/{id} — details of a specific event
These endpoints are invaluable in production. When an event goes missing (which happens more often than you'd think), you can check the event registry to see if it was published, completed, or failed.
To enable persistence of events, add the JPA event store:
``xml <dependency> <groupId>org.springframework.modulith</groupId> <artifactId>spring-modulith-events-jpa</artifactId> <version>1.1.0</version> </dependency> ``
This stores events in a database table, so they survive application restarts. You can also add a scheduled task to retry failed events:
``java @Component public class EventRetryJob { @Scheduled(fixedDelay = 60000) // every minute public void retryFailedEvents() { // use EventPublicationRegistry to find and retry failed events } } ``
I also recommend adding custom health indicators that check if the event backlog is growing. A sudden spike in unprocessed events is often the first sign of a problem.
The Day a Cross-Module Import Brought Down Production Billing
PaymentCompletedEvent that the invoicing module listens to, and added the verifyModules() test to the CI pipeline with a build breaker.- Always run ApplicationModule verification in CI, not just locally.
- Direct cross-module bean injection is a code smell — use events.
- Document the public API of each module explicitly.
@Async.@NamedInterface). Check that the module is listed in allowedDependencies.mvn test -Dtest=PaymentModularityTestgrep -r 'import com.example.payment.invoicing' src/main/java/com/example/payment/order/allowedDependencies in package-info.java| File | Command / Code | Purpose |
|---|---|---|
| src | @org.springframework.modulith.ApplicationModule( | 1. Setting Up Spring Modulith in Your Project |
| Example.java | @SpringBootApplication | 2. What the Official Docs Won't Tell You |
| src | @org.springframework.modulith.ApplicationModule( | 3. Defining Module Boundaries and Public API |
| OrderPlacedEvent.java | public record OrderPlacedEvent(Long orderId, BigDecimal amount, String customerE... | 4. Event-Driven Communication Between Modules |
| PaymentModularityTest.java | @ApplicationModuleTest | 5. Writing Verification Tests for Module Boundaries |
| OrderModuleTest.java | @ModuleTest(module = "order") | 6. Testing Modules in Isolation with @ModuleTest |
| EventLoggingAspect.java | @Aspect | 7. Handling Cross-Cutting Concerns |
| application.properties | management.endpoints.web.exposure.include=modulith,health,info | 8. Production Monitoring and Debugging with Modulith |
Key takeaways
Interview Questions on This Topic
What is Spring Modulith and how does it enforce module boundaries?
@ApplicationModule annotation on the package-info.java file, which declares allowed dependencies. A verification test scans all imports and fails if a module accesses types from an undeclared module.Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.
That's Spring Boot. Mark it forged?
6 min read · try the examples if you haven't