Spring Modulith: Building Modular Monoliths with Spring Boot

Spring Modulith: The Best of Monoliths and Microservices

Spring Modulith modular monolith architecture bridges the gap between simple monoliths and complex microservices. Spring Modulith provides the tooling to enforce module boundaries, manage inter-module communication through events, and verify architectural rules at test time. Therefore, teams can start with a well-structured monolith and extract microservices only when specific modules need independent scaling. Moreover, because everything still ships as one deployable, you avoid the network hops, partial failures, and distributed-tracing burden that distributed systems impose from day one.

The modular monolith approach acknowledges that microservices introduce significant operational complexity — distributed transactions, service discovery, network failures — that many applications don’t need. Moreover, Spring Modulith makes it easy to define clear module boundaries within a single deployable unit, getting the organizational benefits of modularity without the operational overhead of distributed systems. Consequently, development velocity remains high while the architecture stays clean and evolvable.

Spring Modulith Modular Monolith: Module Structure

Spring Modulith uses Java packages as module boundaries. Each top-level package under your application package becomes a module with controlled visibility. Furthermore, Spring Modulith verifies these boundaries at test time, catching violations before they reach production. The convention is deliberately lightweight: there is no special build plugin partitioning the codebase, only ordinary package structure that the framework reads reflectively.

// Application structure — each package is a module
// com.myapp/
//   ├── order/          ← Order module
//   │   ├── Order.java  (public API)
//   │   ├── OrderService.java (public API)
//   │   └── internal/   ← Hidden from other modules
//   │       ├── OrderRepository.java
//   │       └── OrderValidator.java
//   ├── inventory/      ← Inventory module
//   │   ├── InventoryService.java
//   │   └── internal/
//   ├── payment/        ← Payment module
//   └── shipping/       ← Shipping module

// Order module — public API
package com.myapp.order;

@Service
@RequiredArgsConstructor
public class OrderService {
    private final ApplicationEventPublisher events;
    private final OrderRepository repository;

    @Transactional
    public Order createOrder(CreateOrderRequest request) {
        Order order = Order.create(request);
        repository.save(order);

        // Publish event — other modules react asynchronously
        events.publishEvent(new OrderCreated(
            order.getId(), order.getItems(), order.getTotal()
        ));
        return order;
    }
}

// Order events — part of the public API
public record OrderCreated(String orderId, List items, BigDecimal total) {}
public record OrderCancelled(String orderId, String reason) {}

By default, only types in the top-level module package are visible to other modules; everything under internal is hidden. As a result, the repository and validator above cannot be referenced from the inventory or payment modules, which forces interaction to flow through the public API or through events. If a finer-grained policy is needed, the @ApplicationModule annotation lets you declare allowed dependencies and named interfaces explicitly, so a module can expose more than one curated surface.

Spring Modulith modular architecture code
Spring Modulith enforces module boundaries through package conventions and test-time verification

Event-Driven Communication Between Modules

Modules communicate through domain events rather than direct method calls. This decouples modules and makes the system more resilient — if the inventory module is temporarily broken, order creation still succeeds. Additionally, Spring Modulith provides an event publication log that ensures events are delivered even after application restarts.

// Inventory module — reacts to order events
package com.myapp.inventory;

@Service
@RequiredArgsConstructor
public class InventoryEventHandler {
    private final InventoryService inventoryService;

    @ApplicationModuleListener
    public void onOrderCreated(OrderCreated event) {
        for (OrderItem item : event.items()) {
            inventoryService.reserveStock(item.productId(), item.quantity());
        }
    }

    @ApplicationModuleListener
    public void onOrderCancelled(OrderCancelled event) {
        inventoryService.releaseReservation(event.orderId());
    }
}

// Payment module — reacts to order events
package com.myapp.payment;

@Service
public class PaymentEventHandler {
    @ApplicationModuleListener
    public void onOrderCreated(OrderCreated event) {
        paymentService.processPayment(event.orderId(), event.total());
    }
}

The Event Publication Log and Delivery Guarantees

The @ApplicationModuleListener annotation is more than a convenience wrapper around Spring’s @EventListener. Under the hood it combines three behaviors: the listener runs asynchronously, it participates in a transaction, and its invocation is recorded in the event publication registry. Specifically, before the publishing transaction commits, Spring Modulith writes a row describing each pending listener; once the listener completes successfully, that row is marked complete. Therefore, if the application crashes between commit and listener execution, the incomplete entry survives and the event is republished on restart.

This pattern is effectively a built-in transactional outbox, which is the same reliability technique teams hand-roll when integrating with Kafka. To enable durable storage you add a persistence module and a small configuration flag:

# Persist the event publication log to JDBC so it survives restarts
spring.modulith.events.jdbc.schema-initialization.enabled=true
spring.modulith.events.republish-outstanding-events-on-restart=true
spring.modulith.events.completion-mode=delete

A word of caution, however. The republication mechanism delivers at-least-once, not exactly-once, so handlers must be idempotent. For example, the payment handler above should check whether a payment for that order already exists before charging the card, otherwise a crash-and-replay sequence could double-charge a customer. In production teams typically key these operations on the order ID and short-circuit duplicates.

Architecture Verification Tests

Spring Modulith provides test infrastructure that verifies your modular architecture is correctly structured. These tests catch dependency violations, circular references, and improper access to internal packages. Furthermore, you can document your module structure automatically, which keeps diagrams honest because they are generated from code rather than drawn by hand and left to rot.

@SpringBootTest
class ModularityTests {

    @Test
    void verifyModularStructure() {
        ApplicationModules modules = ApplicationModules.of(Application.class);
        // Fails if any module accesses another module's internal package
        modules.verify();
    }

    @Test
    void documentModules() {
        ApplicationModules modules = ApplicationModules.of(Application.class);
        // Generates PlantUML and Asciidoc documentation
        new Documenter(modules)
            .writeModulesAsPlantUml()
            .writeIndividualModulesAsPlantUml();
    }

    @Test
    void verifyOrderModule() {
        ApplicationModules.of(Application.class)
            .getModuleByName("order")
            .ifPresent(module -> {
                // Verify order module only depends on allowed modules
                assertThat(module.getDependencies())
                    .extracting("name")
                    .containsOnly("inventory", "payment");
            });
    }
}

Running modules.verify() in CI is what makes the boundaries real rather than aspirational. Without it, a hurried developer can reach straight into another module’s internal repository and the design quietly erodes. With it, that violation fails the build, and the pull request reviewer sees the broken rule before it merges. Additionally, faster slices are available — @ApplicationModuleTest bootstraps only one module and its declared dependencies, so module-level integration tests start in a fraction of the time a full context would take.

Architecture testing and verification
Architecture verification tests catch module boundary violations at build time

Migration Path to Microservices

When a module needs independent scaling, extract it into a microservice by replacing event publication with a message broker (Kafka, RabbitMQ) and converting the module’s public API to REST/gRPC endpoints. The clean module boundaries established by Spring Modulith make this extraction straightforward. See the Spring Modulith documentation for detailed patterns. Because modules already communicate through events and never touch each other’s internals, the seam where you cut is well defined, which is precisely what makes the strangler-style extraction low-risk.

When the Modular Monolith Is the Wrong Fit

This architecture is not a universal answer, and it is worth being honest about its limits. If two parts of your system genuinely need to scale on completely different curves — a write-heavy ingestion path and a compute-heavy analytics path — keeping them in one process means you scale the whole deployment to satisfy the hungriest component. Additionally, a single shared database can become a coordination point that several teams contend over, and one runtime means a memory leak or a thread-pool exhaustion in any module can take down the rest. Therefore, very large organizations with dozens of independent teams may still prefer true service boundaries to get separate deploy cadences and blast-radius isolation. For most teams, though, starting modular and staying monolithic until a concrete scaling or autonomy pressure appears is the lower-risk path. Teams weighing this decision often find it useful to compare against a full modular monolith versus microservices breakdown and to study how event-driven communication scales across services once you do split.

Key Takeaways

  • Start with a solid foundation and build incrementally based on your requirements
  • Test thoroughly in staging before deploying to production environments
  • Monitor performance metrics and iterate based on real-world data
  • Follow security best practices and keep dependencies up to date
  • Document architectural decisions for future team members
System architecture evolution planning
Spring Modulith provides a clear path from modular monolith to microservices when needed

In conclusion, Spring Modulith modular monolith architecture gives you the best of both worlds — the simplicity of a monolith with the organizational clarity of microservices. Start with a modular monolith, enforce boundaries with tests, communicate through events with the durable publication log, and extract microservices only when you have a proven need for independent deployment or scaling.

Leave a Comment

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

Scroll to Top