Spring Modulith Modular Application Guide
Spring Modulith modular architecture enables you to build well-structured monolithic applications with clear module boundaries enforced at compile time. Therefore, you get the simplicity of a monolith with the modularity benefits typically associated with microservices. This guide covers practical implementation patterns for production applications, including how to verify boundaries, communicate through events, and evolve modules safely over time.
The Case for Modular Monoliths
Microservices introduce distributed system complexity including network failures, data consistency challenges, and operational overhead. Moreover, most teams adopt microservices prematurely before understanding their domain boundaries. As a result, they end up with a distributed monolith that combines the worst of both approaches — the cognitive load of remote calls without the deployment independence that justifies it.
In contrast, a modular monolith runs as a single deployment unit while maintaining strict internal boundaries. Consequently, refactoring is simpler because a single compiler and a single test suite cover every module. Furthermore, you can extract modules into services later, once the boundaries are well understood and the traffic actually warrants a separate process. In practice teams find that the hardest part of microservices — getting the boundaries right — is something a modular monolith lets you rehearse cheaply.
Modular application structure with clear package boundaries
Defining Module Boundaries with Spring Modulith
Spring Modulith uses package conventions to define modules. Additionally, each top-level package under your application base package becomes a module with its own public API:
// Module structure
com.example.app/
├── order/ // Order module
│ ├── OrderService.java // Public API
│ ├── Order.java
│ └── internal/ // Hidden from other modules
│ ├── OrderProcessor.java
│ └── OrderValidator.java
├── inventory/ // Inventory module
│ ├── InventoryService.java
│ └── internal/
│ └── StockCalculator.java
└── shipping/ // Shipping module
├── ShippingService.java
└── internal/
└── CarrierClient.java
Classes in the internal package are invisible to other modules. Therefore, Spring Modulith enforces encapsulation that plain Java packages cannot guarantee. Because Java only has package-private and public visibility, a class that needs to be public within its module would otherwise be reachable from anywhere — Modulith closes that gap by treating internal sub-packages as private to the module.
Verifying the Architecture at Build Time
The single most valuable thing Spring Modulith gives you is an executable description of your architecture. Specifically, the ApplicationModules abstraction scans your packages, derives the module graph, and fails a test if any module reaches into another module’s internals or creates a cyclic dependency. As a result, architectural drift becomes a red build rather than a slow erosion nobody notices.
class ModularityTests {
ApplicationModules modules = ApplicationModules.of(Application.class);
@Test
void verifiesModularStructure() {
// Fails on cycles, on access to another module's internal
// packages, and on undeclared dependencies.
modules.verify();
}
@Test
void writesDocumentation() {
new Documenter(modules)
.writeDocumentation() // C4 + PlantUML diagrams
.writeIndividualModulesAsPlantUml()
.writeModuleCanvases(); // tables of APIs and events
}
}
Notably, you can tighten or relax the rules per module. For instance, a module can declare an allowed dependency on a shared kernel module while still rejecting everything else, using a package-info.java annotated with @ApplicationModule(allowedDependencies = "kernel"). Consequently, the intended design is documented in code and checked on every push.
Event-Based Module Communication
Modules communicate through Spring application events rather than direct method calls. Furthermore, this decoupling enables modules to evolve independently. However, synchronous events still execute within the same transaction for consistency, which is often exactly what you want when two modules must agree on an outcome.
For example, when an order is placed, the order module publishes an OrderPlaced event. Meanwhile, inventory and shipping modules listen and react without direct dependencies on the order module:
// In the order module — the publisher knows nothing about listeners
@Service
class OrderService {
private final ApplicationEventPublisher events;
@Transactional
public Order place(OrderRequest request) {
Order order = repository.save(Order.from(request));
events.publishEvent(new OrderPlaced(order.getId(), order.getTotal()));
return order;
}
}
// In the inventory module — reacts after the order transaction commits
@Component
class InventoryListener {
@ApplicationModuleListener // async + transactional + persistent
void on(OrderPlaced event) {
stock.reserve(event.orderId());
}
}
The @ApplicationModuleListener annotation is a meta-annotation that combines @Async, @Transactional, and @TransactionalEventListener(phase = AFTER_COMMIT). Therefore, the listener runs in its own transaction only after the publisher commits, which prevents a failure in inventory from rolling back a perfectly valid order. To make this reliable, Spring Modulith’s Event Publication Registry persists each event before delivery and marks it complete afterward; if the application crashes mid-delivery, incomplete events can be republished on restart. As a result, you get an at-least-once guarantee without standing up Kafka on day one.
Spring Modulith Modular Testing
Module tests verify that each module works correctly in isolation. Specifically, the @ApplicationModuleTest annotation bootstraps only the module under test with its dependencies. As a result, tests are faster and failures point directly to the affected module. Moreover, the test scenario API lets you publish an event and assert that the expected outgoing event was produced, without wiring up the downstream modules at all.
@ApplicationModuleTest
class OrderModuleTests {
@Test
void publishesOrderPlacedOnSuccessfulOrder(Scenario scenario) {
scenario.stimulate(() -> orderService.place(sampleRequest()))
.andWaitForEventOfType(OrderPlaced.class)
.matchingMappedValue(OrderPlaced::orderId, expectedId)
.toArrive();
}
}
Additionally, architecture tests validate module dependency rules at build time. Consequently, accidental coupling between modules fails the build before it reaches production, and code reviewers no longer have to spot every illegal import by eye.
Documentation and Observability
Spring Modulith generates module documentation including dependency diagrams and event catalogs automatically. Moreover, it integrates with Actuator to expose module health endpoints, and the optional observability starter creates an OpenTelemetry span for each inter-module interaction. Therefore, operations teams can monitor module boundaries in production and detect architecture drift as it happens rather than during a postmortem.
Event-driven communication between application modules
When NOT to Use Spring Modulith — Trade-offs
Spring Modulith is not free of cost, and a few situations argue against it. First, a genuinely small application — a handful of controllers and one service layer — gains little from formal module verification; the ceremony outweighs the payoff. Second, if your team already operates mature, independently scaled microservices with separate datastores, retrofitting Modulith onto a monolith you are actively decommissioning is wasted effort.
There are also operational caveats. The Event Publication Registry needs a table and a serialization strategy, and abandoned or poison events require a cleanup policy so the table does not grow unbounded. Asynchronous module listeners trade strong consistency for resilience, so flows that truly must be atomic should stay synchronous and in-process. Finally, Modulith documents and enforces boundaries but cannot invent good ones — if your domain model is tangled, the tool will simply fail the build faithfully until you untangle it. Used with that understanding, however, it is one of the cheapest ways to keep a large codebase honest.
Auto-generated module documentation and dependency visualization
Related Reading:
Further Resources:
In conclusion, Spring Modulith modular architecture bridges the gap between monoliths and microservices by enforcing clean boundaries within a single deployment. Therefore, start with a modular monolith, let the verification tests guard your design, and extract services only when domain boundaries are proven and traffic demands it.