Pavan Rangani

HomeBlogSpring Modulith: Building Event-Driven Modular Monoliths in Production

Spring Modulith: Building Event-Driven Modular Monoliths in Production

By Pavan Rangani · March 23, 2026 · Java & Spring

Spring Modulith: Building Event-Driven Modular Monoliths in Production

Spring Modulith for Event-Driven Modular Monoliths

Spring Modulith modular monolith architecture bridges the gap between traditional monoliths and microservices. Instead of distributing your system across dozens of services prematurely, Spring Modulith helps you build a well-structured monolith with enforced module boundaries, event-driven communication, and clear extraction paths for when (and if) you need to split into microservices. In other words, you get the deployment simplicity of a single artifact without sacrificing the internal discipline that keeps a large codebase maintainable.

This guide covers building production modular monoliths with Spring Modulith 1.3+, from defining module boundaries and enforcing architectural rules to implementing async event-driven communication between modules. Moreover, you will learn how to document your architecture automatically and prepare individual modules for extraction into standalone services. Along the way, we will look at concrete configuration, common failure modes, and the trade-offs that decide whether this approach actually fits your team.

The Spring Modulith Modular Monolith Sweet Spot

Microservices solve organizational scaling problems — enabling independent teams to deploy independently. But they introduce distributed systems complexity: network failures, eventual consistency, distributed transactions, and operational overhead. For most teams (especially those under 50 engineers), a modular monolith provides the right balance of structure and simplicity.

Furthermore, starting with a modular monolith means you can extract modules into services later based on actual scaling needs rather than speculative architecture. Spring Modulith makes this extraction straightforward by enforcing the same communication patterns (events, APIs) that microservices use. As a result, the migration from “module” to “service” becomes a mechanical refactor rather than a rewrite, because the seams already exist in your code.

Java Spring application architecture
Modular monolith architecture with enforced boundaries

Defining Module Boundaries

In Spring Modulith, each top-level package under your application package becomes a module. Only the classes in the package root are part of the module’s public API — everything in sub-packages is internal. This convention is the entire foundation: there are no annotations required to declare a module, just a disciplined package structure that the verification tooling can reason about.

com.myapp/
├── order/                    # Order module
│   ├── OrderService.java     # Public API (accessible by other modules)
│   ├── OrderApi.java         # Public API interface
│   ├── OrderCreatedEvent.java # Public event
│   ├── internal/             # Internal (not accessible by other modules)
│   │   ├── OrderRepository.java
│   │   ├── OrderEntity.java
│   │   ├── OrderMapper.java
│   │   └── OrderValidator.java
│   └── web/                  # Internal
│       └── OrderController.java
├── inventory/                # Inventory module
│   ├── InventoryService.java
│   ├── StockReservedEvent.java
│   └── internal/
│       ├── InventoryRepository.java
│       └── StockEntity.java
├── payment/                  # Payment module
│   ├── PaymentService.java
│   ├── PaymentCompletedEvent.java
│   └── internal/
└── notification/             # Notification module
    ├── NotificationService.java
    └── internal/
// Verify module boundaries with architecture tests
@ModulithTest
class ModularArchitectureTest {

    @Test
    void verifyModularStructure(ApplicationModules modules) {
        // Verifies that modules only access each other's public APIs
        modules.verify();
    }

    @Test
    void documentArchitecture(ApplicationModules modules) {
        // Generates PlantUML and Asciidoc documentation
        new Documenter(modules)
            .writeModulesAsPlantUml()
            .writeIndividualModulesAsPlantUml()
            .writeModuleCanvases();
    }
}

// Module metadata for documentation
@org.springframework.modulith.ApplicationModule(
    displayName = "Order Management",
    allowedDependencies = {"inventory", "payment"}
)
package com.myapp.order;

Explicit Dependencies and Named Interfaces

By default a module may depend on the public API of any other module. In practice, that freedom erodes quickly into a tangle. Therefore, most production teams declare allowedDependencies explicitly, as shown above, so that modules.verify() fails the build the moment someone introduces an unsanctioned coupling. The order module, for example, may call inventory and payment, but if a developer reaches into notification directly, the architecture test breaks in CI.

For larger modules, a single public package can become crowded. Spring Modulith supports named interfaces via @NamedInterface on sub-packages, letting you expose more than one curated entry point while keeping the rest internal. Consequently, you can publish a stable order.api facade and a separate order.events namespace without flattening everything into the module root. This becomes valuable as the codebase grows, because it documents intent and prevents accidental reliance on implementation classes.

Spring Modulith: Event-Driven Communication

Therefore, modules communicate through application events rather than direct method calls. This loose coupling is what makes eventual microservice extraction possible — you replace in-process events with message broker events without changing the business logic. The publisher never references the consumer, so adding a new reaction (say, an analytics module) requires zero changes to the order module.

// Order module — publishes events
@Service
@Transactional
public class OrderService {

    private final OrderRepository orderRepository;
    private final ApplicationEventPublisher events;

    public OrderService(OrderRepository orderRepository,
                        ApplicationEventPublisher events) {
        this.orderRepository = orderRepository;
        this.events = events;
    }

    public Order createOrder(CreateOrderRequest request) {
        var order = OrderEntity.builder()
            .customerId(request.customerId())
            .items(request.items())
            .status(OrderStatus.PENDING)
            .build();

        order = orderRepository.save(order);

        // Publish event — inventory and payment modules react
        events.publishEvent(new OrderCreatedEvent(
            order.getId(),
            order.getCustomerId(),
            order.getItems(),
            order.getTotalAmount()
        ));

        return OrderMapper.toApi(order);
    }
}

// Public event record
public record OrderCreatedEvent(
    UUID orderId,
    String customerId,
    List<OrderItem> items,
    BigDecimal totalAmount
) {}

// Inventory module — reacts to order events
@Service
public class InventoryEventHandler {

    private final StockService stockService;
    private final ApplicationEventPublisher events;

    @ApplicationModuleListener
    public void onOrderCreated(OrderCreatedEvent event) {
        var reservation = stockService.reserveStock(
            event.orderId(), event.items());

        if (reservation.isSuccessful()) {
            events.publishEvent(new StockReservedEvent(
                event.orderId(), reservation.reservationId()));
        } else {
            events.publishEvent(new StockReservationFailedEvent(
                event.orderId(), reservation.failureReason()));
        }
    }
}

// Payment module — reacts to stock reservation
@Service
public class PaymentEventHandler {

    private final PaymentGateway paymentGateway;
    private final ApplicationEventPublisher events;

    @ApplicationModuleListener
    public void onStockReserved(StockReservedEvent event) {
        var result = paymentGateway.processPayment(event.orderId());

        events.publishEvent(new PaymentCompletedEvent(
            event.orderId(), result.transactionId()));
    }
}

The crucial detail is @ApplicationModuleListener rather than a plain @EventListener. The Modulith variant combines three behaviors: it runs the listener asynchronously, it wraps the work in its own transaction, and it registers the event with the publication registry described below. As a result, a slow inventory check never blocks the HTTP thread that created the order, and a failure in payment processing does not roll back the original order transaction. This is exactly the semantic boundary you want between modules — each reaction commits or fails on its own terms.

Event-driven architecture diagram
Event flow between modules in a Spring Modulith application

Event Publication Registry

Additionally, Spring Modulith provides an Event Publication Registry that ensures events are not lost if a listener fails. Events are persisted to the database and retried automatically, giving you transactional event processing guarantees. This solves the classic dual-write problem without a separate outbox table you have to maintain by hand — the registry effectively becomes a built-in transactional outbox.

// application.yml — Enable event publication registry
// spring.modulith.events.jdbc.schema-initialization.enabled=true
// spring.modulith.republish-outstanding-events-on-restart=true

// The registry automatically:
// 1. Saves events to a database table before publishing
// 2. Marks events as completed after successful listener execution
// 3. Retries failed events on application restart
// 4. Provides monitoring endpoints for incomplete events

@Configuration
public class ModulithConfig {

    @Bean
    public IncompleteEventPublications incompleteEvents(
            EventPublicationRegistry registry) {
        return registry.findIncompletePublications();
    }

    // Scheduled cleanup of completed events
    @Bean
    public CompletedEventPublications completedEvents(
            EventPublicationRegistry registry) {
        return () -> registry.deleteCompletedPublicationsOlderThan(
            Duration.ofDays(7));
    }
}

Operational Concerns the Registry Introduces

The registry is powerful, but it is not free. Every published event becomes a row in the event_publication table, and an incomplete publication is only retried on application restart unless you also enable the scheduled republish task. Therefore, in production teams typically pair it with a metric on the count of incomplete publications and an alert when that number stays non-zero for more than a few minutes. A growing backlog usually signals a poison event — one that fails the same listener every time — and you want to spot that before it accumulates.

Equally important is housekeeping. Without the cleanup bean shown above, completed publications accumulate indefinitely and the table eventually bloats. The docs recommend deleting completed entries on a schedule, and most teams settle on a retention window of a few days, which is long enough to investigate failures but short enough to keep the table small. Because the table is queried on every restart for republishing, keeping it lean directly affects startup time on large systems.

Testing Modules in Isolation

A frequently overlooked benefit is that Spring Modulith lets you spin up just one module in a test, rather than the whole application context. The @ApplicationModuleTest annotation bootstraps a single module and its declared dependencies, which dramatically speeds up integration tests and keeps them focused on one bounded context.

@ApplicationModuleTest
class OrderModuleTests {

    @Test
    void publishesEventOnOrderCreation(Scenario scenario) {
        scenario.stimulate(() ->
                orderService.createOrder(sampleRequest()))
            .andWaitForEventOfType(OrderCreatedEvent.class)
            .matchingMappedValue(OrderCreatedEvent::customerId, "cust-42")
            .toArriveAndVerify(event ->
                assertThat(event.totalAmount())
                    .isEqualByComparingTo("99.00"));
    }
}

The Scenario API is what makes asynchronous event testing tractable. It triggers an action, waits for a specific event to be published, and then asserts on its contents — all without brittle Thread.sleep calls or flaky timing. Consequently, your event choreography is covered by deterministic tests, which matters a great deal when the same events will later cross a network boundary.

When NOT to Use Spring Modulith

If your team already runs a successful microservices architecture with mature DevOps practices, migrating back to a modular monolith creates unnecessary churn. Spring Modulith is most valuable for new projects or teams considering microservices but lacking the operational maturity to manage distributed systems. Consequently, organizations with dedicated platform teams and established service mesh infrastructure should continue with their current approach.

Spring Modulith is Spring-specific — if your organization uses other JVM frameworks like Quarkus or Micronaut, you cannot use Modulith directly. The architectural patterns still apply, but you will need different tooling to enforce them. Likewise, if your workload genuinely demands independent scaling — for instance, a compute-heavy reporting component that needs to scale to dozens of instances while the rest of the app needs two — a single deployable forces you to scale everything together, and at that point a real service split earns its keep. Finally, the event registry assumes a relational database; if your modules do not share one, the transactional guarantees no longer hold and you are back to building an explicit outbox.

Software development and code review
Evaluating the modular monolith approach for your team

Key Takeaways

Spring Modulith gives you the best of both worlds — the simplicity of a monolith with the structure of microservices. Enforced module boundaries, event-driven communication, and the event publication registry create a system that is easy to develop, test, and deploy. Furthermore, individual modules can be extracted into microservices when genuine scaling needs emerge, not before.

Key Takeaways

  • Define modules as top-level packages and enforce dependencies with modules.verify() in CI
  • Use @ApplicationModuleListener so reactions run async, transactionally, and are registered for retry
  • Enable the event publication registry plus a cleanup schedule to get outbox-grade reliability for free
  • Test each module in isolation with @ApplicationModuleTest and the Scenario API
  • Document architectural decisions for future team members and generate diagrams automatically

Start by adding Spring Modulith to your existing Spring Boot application and running the architecture verification tests to discover existing boundary violations. For more details, see the Spring Modulith reference documentation and Spring’s Modulith introduction blog. Our guides on saga patterns for distributed transactions, the outbox pattern for reliable events, and Spring Boot virtual threads complement this architectural approach.

In conclusion, the Spring Modulith modular monolith is an essential topic for modern software development. By applying the patterns and practices covered in this guide, you can build more robust, scalable, and maintainable systems. Start with the fundamentals, iterate on your implementation, and continuously measure results to ensure you are getting the most value from these approaches.

← Back to all articles