Home Java Spring Modulith: Build Modular Monoliths with Spring Boot 3
Advanced 6 min · July 14, 2026

Spring Modulith: Build Modular Monoliths with Spring Boot 3

Learn how to structure Spring Boot applications using Spring Modulith for modular monoliths.

N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.

Follow
Production
production tested
July 15, 2026
last updated
2,398
articles · all by Naren
Before you start⏱ 20-25 min read
  • Java 17+ installed
  • Spring Boot 3.2+ project
  • Familiarity with Spring Boot basics like dependency injection and configuration
  • Maven or Gradle build tool
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer

• 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

✦ Definition~90s read
What is Spring Modulith?

Spring Modulith is a framework that helps you build well-structured monoliths by enforcing module boundaries at compile time and providing an event-driven communication mechanism between modules.

Think of a modular monolith like a well-organized office building.
Plain-English First

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> ``

``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:

src/main/java/com/example/payment/order/package-info.javaJAVA
1
2
3
4
5
@org.springframework.modulith.ApplicationModule(
    displayName = "Order Management",
    allowedDependencies = {"invoicing", "notification"}
)
package com.example.payment.order;
Output
No direct output. This annotation tells Spring Modulith that the 'order' module can only depend on 'invoicing' and 'notification' modules.
⚠ Don't Skip package-info.java
📊 Production Insight
In production, we version the allowedDependencies list. When you need to add a new dependency, it's a deliberate decision that goes through code review. This prevents scope creep.
🎯 Key Takeaway
Spring Modulith uses package-level annotations to define module boundaries. Always create a 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.

Example.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// DON'T do this - it bypasses module boundaries
@SpringBootApplication
@ComponentScan(basePackages = "com.example.payment.*")  // pulls everything
public class PaymentApplication {
    public static void main(String[] args) {
        SpringApplication.run(PaymentApplication.class, args);
    }
}

// DO this - let Spring Modulith manage scanning
@SpringBootApplication
public class PaymentApplication {
    public static void main(String[] args) {
        SpringApplication.run(PaymentApplication.class, args);
    }
}
Output
Without explicit @ComponentScan, Spring Boot scans the root package and respects module boundaries.
⚠ Avoid @ComponentScan with Wildcards
📊 Production Insight
We added a custom ArchUnit test that also checks for @Autowired injections across module boundaries. This catches cases where someone injects a bean from another module directly.
🎯 Key Takeaway
The verification test checks compile-time boundaries only. For runtime enforcement, avoid reflection and wildcard component scans.

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.

For example, in the order module, you might have
  • com.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.

src/main/java/com/example/payment/invoicing/package-info.javaJAVA
1
2
3
4
5
6
@org.springframework.modulith.ApplicationModule(
    displayName = "Invoicing",
    allowedDependencies = {"notification"}
)
@org.springframework.modulith.NamedInterface("invoicing")
package com.example.payment.invoicing;
Output
This makes the 'invoicing' module visible to other modules that depend on it. The `@NamedInterface` annotation is optional but recommended for documentation.
💡Use Package-Private by Default
📊 Production Insight
We use a custom Gradle plugin that runs the verification test before any commit. It's integrated with our CI pipeline and blocks merges if violations are found. This catches issues early.
🎯 Key Takeaway
Only types in the module's root package or explicitly annotated with @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.

``java public record OrderPlacedEvent(Long orderId, BigDecimal amount, String customerEmail) {} ``

```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())); } } ```

``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.

OrderPlacedEvent.javaJAVA
1
public record OrderPlacedEvent(Long orderId, BigDecimal amount, String customerEmail) {}
Output
No output. This is a simple immutable event record.
⚠ Event Serialization Matters
📊 Production Insight
We added a custom event audit log that records every published event with a correlation ID. This helped us debug a production issue where an event was published but never consumed due to a transaction rollback.
🎯 Key Takeaway
Use @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 verify() method checks all modules against their declared dependencies. If the 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.

PaymentModularityTest.javaJAVA
1
2
3
4
5
6
7
8
9
@ApplicationModuleTest
class PaymentModularityTest {
    @Test
    void verifyModules() {
        var modules = ApplicationModules.of(PaymentApplication.class);
        modules.forEach(System.out::println);  // debug output
        modules.verify();
    }
}
Output
If no violations: test passes silently. If violation:
'Module 'order' depends on 'notification' which is not declared in allowedDependencies'
💡Run Verification in CI, Not Just Locally
📊 Production Insight
We also run a 'module isolation' test that loads each module's beans in isolation using @ModuleTest and checks that no beans from other modules are injected. This catches accidental @Autowired violations.
🎯 Key Takeaway
The 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.

```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.

OrderModuleTest.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@ModuleTest(module = "order")
class OrderModuleTest {
    @Autowired
    private OrderService orderService;

    @MockBean
    private ApplicationEventPublisher events;

    @Test
    void shouldCreateOrder() {
        var order = new Order(null, BigDecimal.valueOf(100), "test@example.com");
        var saved = orderService.placeOrder(order);
        assertThat(saved.getId()).isNotNull();
        verify(events).publishEvent(any(OrderPlacedEvent.class));
    }
}
Output
Test passes if order is saved and event is published. If module has dependencies on other modules, test fails with bean not found.
⚠ @ModuleTest Does Not Load Full Context
📊 Production Insight
We run @ModuleTest tests in parallel to speed up CI. They take about 2 seconds each, compared to 30 seconds for a full @SpringBootTest.
🎯 Key Takeaway
Use @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.

``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.

EventLoggingAspect.javaJAVA
1
2
3
4
5
6
7
8
9
10
@Aspect
@Component
public class EventLoggingAspect {
    private static final Logger log = LoggerFactory.getLogger(EventLoggingAspect.class);

    @Before("@annotation(org.springframework.modulith.events.ApplicationModuleListener)")
    public void logEvent(JoinPoint joinPoint) {
        log.info("Module event: {}", joinPoint.getSignature().toShortString());
    }
}
Output
INFO [main] c.e.p.shared.EventLoggingAspect - Module event: InvoiceListener.onOrderPlaced(..)
💡Keep Shared Module Minimal
📊 Production Insight
We use Micrometer with custom meters to track event processing times per module. This helped us identify a slow listener in the notification module that was blocking the event bus.
🎯 Key Takeaway
Use a minimal 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.

``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.

application.propertiesPROPERTIES
1
2
3
4
5
6
# Enable modulith actuator endpoints
management.endpoints.web.exposure.include=modulith,health,info
management.endpoint.modulith.enabled=true

# Configure JPA event store
spring.modulith.events.jdbc.schema-initialization.enabled=true
Output
No direct output. Actuator endpoints become available at /actuator/modulith.
⚠ Event Store Schema Initialization
📊 Production Insight
We had a production incident where a database deadlock caused events to fail silently. The Actuator event endpoint showed a growing backlog of uncompleted events, which alerted us before customers noticed.
🎯 Key Takeaway
Use Actuator endpoints and a persistent event store to monitor and debug event-driven communication in production.
● Production incidentPOST-MORTEMseverity: high

The Day a Cross-Module Import Brought Down Production Billing

Symptom
Intermittent transaction deadlocks during peak billing hours, stack traces showing a cycle between PaymentService and InvoiceService.
Assumption
The team assumed that since both modules were in the same JVM, direct imports were harmless and faster than events.
Root cause
A direct bean dependency from payment module to invoicing module created an implicit circular dependency when invoicing tried to call payment back via the event bus. The module boundary verification test was not running in CI.
Fix
Removed the direct import, introduced a PaymentCompletedEvent that the invoicing module listens to, and added the verifyModules() test to the CI pipeline with a build breaker.
Key lesson
  • 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.
Production debug guideHow to diagnose and fix common production issues with modular monoliths4 entries
Symptom · 01
Event not processed after restart
Fix
Check if event store is configured. Without persistent store, events are lost on restart. Enable JPA or MongoDB event store.
Symptom · 02
Verification test passes locally but fails in CI
Fix
Ensure CI uses the same build tool and dependency versions. Check for profile-specific dependencies that might introduce illegal imports.
Symptom · 03
Transaction deadlocks under load
Fix
Review event listener methods for long-running operations. Move heavy processing to asynchronous listeners with @Async.
Symptom · 04
Module cannot find a bean from another module
Fix
Verify that the bean is in a public package (root of module or annotated with @NamedInterface). Check that the module is listed in allowedDependencies.
★ Spring Modulith Quick Debug Cheat SheetImmediate actions for common Spring Modulith issues
Verification test fails with 'illegal dependency'
Immediate action
Check the import statement in the offending class
Commands
mvn test -Dtest=PaymentModularityTest
grep -r 'import com.example.payment.invoicing' src/main/java/com/example/payment/order/
Fix now
Move the import to the public API of the other module or add the dependency to allowedDependencies in package-info.java
Event listener not invoked+
Immediate action
Check if the event is published in the same transaction
Commands
curl http://localhost:8080/actuator/modulith/events
Check application logs for 'Event handled' from the logging aspect
Fix now
Ensure the listener method is public and annotated with @ApplicationModuleListener. Verify the event type matches.
Transaction rolled back after event processing+
Immediate action
Check the exception in the logs
Commands
tail -100 application.log | grep -i 'rollback'
Check the event store table for failed events
Fix now
Add a retry mechanism using @Retryable or a scheduled job to reprocess failed events from the event store.
Monolith (no Modulith)Modular Monolith (Spring Modulith)Microservices
No module boundariesEnforced via verification testsService boundaries enforced by network
Direct bean injection everywhereEvent-driven communicationREST/gRPC/event bus communication
Hard to extract servicesClean extraction pathIndependent deployment
Single deployment unitSingle deployment unitMultiple deployment units
Lower operational overheadLow operational overheadHigh operational overhead
Testing is simple but fragileModule isolation tests + integration testsComplex integration testing
⚙ Quick Reference
8 commands from this guide
FileCommand / CodePurpose
srcmainjavacomexamplepaymentorderpackage-info.java@org.springframework.modulith.ApplicationModule(1. Setting Up Spring Modulith in Your Project
Example.java@SpringBootApplication2. What the Official Docs Won't Tell You
srcmainjavacomexamplepaymentinvoicingpackage-info.java@org.springframework.modulith.ApplicationModule(3. Defining Module Boundaries and Public API
OrderPlacedEvent.javapublic record OrderPlacedEvent(Long orderId, BigDecimal amount, String customerE...4. Event-Driven Communication Between Modules
PaymentModularityTest.java@ApplicationModuleTest5. Writing Verification Tests for Module Boundaries
OrderModuleTest.java@ModuleTest(module = "order")6. Testing Modules in Isolation with @ModuleTest
EventLoggingAspect.java@Aspect7. Handling Cross-Cutting Concerns
application.propertiesmanagement.endpoints.web.exposure.include=modulith,health,info8. Production Monitoring and Debugging with Modulith

Key takeaways

1
Spring Modulith enforces module boundaries at compile time using package-level annotations and verification tests.
2
Use events for cross-module communication to maintain loose coupling and enable future extraction into microservices.
3
Run verification tests in CI to prevent boundary violations from entering the codebase.
4
Monitor event processing with Actuator endpoints and a persistent event store for production debugging.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is Spring Modulith and how does it enforce module boundaries?
Q02SENIOR
How would you handle an event that fails to process in a Spring Modulith...
Q03SENIOR
Design a modular monolith for a payment system with fraud detection, inv...
Q01 of 03JUNIOR

What is Spring Modulith and how does it enforce module boundaries?

ANSWER
Spring Modulith is a framework for building modular monoliths. It enforces boundaries via an @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.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Can I use Spring Modulith with an existing Spring Boot application?
02
How does Spring Modulith compare to ArchUnit?
03
What happens if I have a circular dependency between modules?
04
Does Spring Modulith support reactive programming with WebFlux?
05
Can I extract a module into a separate microservice later?
N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.

Follow
Verified
production tested
July 15, 2026
last updated
2,398
articles · all by Naren
🔥

That's Spring Boot. Mark it forged?

6 min read · try the examples if you haven't

Previous
Multi-Tenancy in Spring Boot: Database, Schema, and Discriminator Strategies
40 / 121 · Spring Boot
Next
Spring Boot Admin: Monitoring and Managing Spring Boot Applications