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.

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.

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

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.

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;

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.

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

// 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));
    }
}

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.

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

Key Takeaways

Spring Modulith modular monolith architecture 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.

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 and Spring Boot virtual threads complement this architectural approach.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top