Modular Monolith with Spring Modulith: Enforcing Boundaries
A modular monolith only stays modular if its boundaries are enforced at build time. We walk through Spring Modulith's module rules, verification test, event-driven communication with the publication registry, module-level tests and generated documentation, with code.
In Monolith vs Microservices we made the call: for most enterprise Java projects the starting point is a modular monolith whose boundaries are enforced at build time. This article is the implementation of that decision. The biggest risk of a modular monolith is that the modularity stays on a slide: the packages exist, but three months later everyone calls everyone else's repository. Spring Modulith turns that erosion into a failing test.
We cover what Spring Modulith is and isn't, the module rules, event-driven communication with the publication registry, module-level tests, generated documentation, and how to modularize an existing monolith step by step, with code.
What Spring Modulith is, and what it isn't
Spring Modulith is not a framework change; it is a set of conventions and tools layered on your Spring Boot application. It provides four things: module definition and verification based on package structure, a persistent publication registry for events between modules, test support that bootstraps a single module, and C4 documentation generated from code. Your existing Spring Data, Spring Security or JPA code stays as it is.
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-jpa</artifactId> <!-- core + JPA-based event registry -->
</dependency>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-test</artifactId>
<scope>test</scope>
</dependency>Module rules: package = module, sub-package = internal
The default rule is simple: every package directly under the @SpringBootApplication class's package is a module. Types in the module's root package are public; everything in sub-packages is internal and inaccessible from other modules.
com.acme.erp
├── order ← module
│ ├── OrderService.java ← API (root package)
│ ├── OrderPlaced.java ← event (root package)
│ ├── internal/ ← closed to other modules
│ └── api/ ← can be exposed with @NamedInterface
├── inventory
├── billing
└── customerTo keep the root package from bloating, a sub-package can be exposed as a named interface with @NamedInterface; dependency declarations reference it as order::api:
// order/api/package-info.java
@org.springframework.modulith.NamedInterface("api")
package com.acme.erp.order.api;
// inventory/package-info.java — inventory may only depend on order's "api" interface
@org.springframework.modulith.ApplicationModule(
displayName = "Inventory",
allowedDependencies = { "order::api" }
)
package com.acme.erp.inventory;The test that protects the rule is one line and runs in CI:
class ModularityTest {
ApplicationModules modules = ApplicationModules.of(ErpApplication.class);
@Test
void boundariesAreRespected() {
modules.verify();
}
@Test
void listViolations() { // during migration: report without breaking
modules.detectViolations().getMessages().forEach(System.out::println);
}
}verify() catches three violations: cyclic dependencies between modules, access to internal packages, and dependencies outside allowedDependencies. A legacy block of code that hasn't been split yet can temporarily be treated as a module with @ApplicationModule(type = Type.OPEN); this exposes sub-packages too and is an escape hatch for migration only.
Communication between modules: call or event?
There are two mechanisms, and the choice is an architectural decision:
Situation | Mechanism | Example |
|---|---|---|
An answer is needed now; the caller cannot continue without it | Direct API call (synchronous) | Checking the customer's credit limit while placing an order |
The continuation of the workflow is another module's responsibility | Event (asynchronous) | Order placed → stock reservation, invoice draft, notification |
A module needs to list another module's data | Local copy fed by events | The reporting module keeping customer names in its own table |
Two modules must be updated in the same transaction | They should probably be one module | Rethink the boundary |
On the event side, the order module only publishes; it doesn't know who listens:
public record OrderPlaced(OrderNumber number, List<OrderLine> lines) {}
@Service
@Transactional
public class OrderService {
private final OrderRepository repository;
private final ApplicationEventPublisher events;
public OrderNumber place(OrderRequest request) {
OrderEntity o = repository.save(OrderEntity.from(request));
events.publishEvent(new OrderPlaced(o.getNumber(), o.lines()));
return o.getNumber();
}
}// inventory module
@Component
class InventoryReservation {
@ApplicationModuleListener // = @Async + REQUIRES_NEW + @TransactionalEventListener
void on(OrderPlaced e) {
reservations.reserve(e.lines());
}
}The event publication registry: events that don't get lost
With plain Spring events, if a listener fails the event is gone. With spring-modulith-starter-jpa, every publication is written to the event_publication table as part of the order transaction; when a listener completes successfully the record is marked complete. Even if the application crashes, incomplete publications are in the database and get reprocessed:
spring:
modulith:
events:
republish-outstanding-events-on-restart: true # republish incomplete ones on startup
completion-mode: archive # delete / update / archive completed ones@Component
class StuckEvents {
private final IncompleteEventPublications publications;
@Scheduled(fixedDelay = 60_000)
void retry() {
publications.resubmitIncompletePublicationsOlderThan(Duration.ofMinutes(5));
}
}This is an at-least-once delivery guarantee without Kafka or a hand-written outbox table. Its consequence: listeners must be idempotent — a second reservation request for the same order number must not create a new reservation.
Externalization: the same event, to Kafka
When a module is later extracted into a service, or another system needs to consume the event, you externalize it without changing the publishing code:
@Externalized("order.placed::#{#this.number().value()}") // topic::key
public record OrderPlaced(OrderNumber number, List<OrderLine> lines) {}With the spring-modulith-events-kafka dependency, the event is written to the given topic after the transaction commits. In-module listeners keep working exactly as before.
Module-level tests
Instead of a @SpringBootTest that boots the whole application, you can bootstrap a single module; beans from dependent modules are mocked:
@ApplicationModuleTest // default: STANDALONE — this module only
class InventoryModuleTest {
@MockitoBean CustomerService customerService; // dependency from another module
@Test
void stockIsReservedWhenOrderIsPlaced(Scenario scenario) {
scenario.publish(new OrderPlaced(new OrderNumber("O-1"), lines()))
.andWaitForStateChange(() -> reservations.find("O-1"))
.andVerify(r -> assertThat(r).isPresent());
}
}The Scenario API standardizes waiting for asynchronous listeners and verifying state changes. With @ApplicationModuleTest(mode = DIRECT_DEPENDENCIES) directly dependent modules are loaded for real as well. Test duration drops noticeably because a single module is loaded instead of the whole application.
Documentation and observability
Because the module structure is derived from code, documentation is generated from code too:
@Test
void generateDocs() {
new Documenter(modules)
.writeModulesAsPlantUml() // C4 component diagram
.writeIndividualModulesAsPlantUml()
.writeModuleCanvases(); // per-module summary of API, events and beans
}The output goes to target/spring-modulith-docs, and when generated in CI the architecture document stays in sync with the code. spring-modulith-observability marks every module entry as a separate span in Micrometer tracing, so even inside a single deployment unit you can answer "which module did this request spend its time in". spring-modulith-actuator exposes the module structure at the /actuator/modulith endpoint.
Data ownership: the boundary goes down to the table
A code boundary isn't enough; if two modules write to the same table, there is no boundary. We apply three rules:
One schema per module:
order.*,inventory.*. Flyway migrations live in module folders.No JPA relationships across modules: The order holds the customer as a
CustomerIdvalue, not as@ManyToOne Customer.Read needs are served by an event-fed local copy: The reporting module copies the customer name into its own table; it doesn't run live JOINs.
Modularizing an existing monolith step by step
Map it: Draw the current package dependencies with
Documenter; mark the cycles.Set up a ratchet: Record the
detectViolations()count in CI; break the build if it grows, make reducing it the goal.Turn cycles into events: In A→B→A chains, replace the return direction with an event.
Move internals into the
internalpackage: Repositories and entities leave the root; only services and events stay public.Separate the data: Give shared tables to their owner; others get fed by copies or the API.
This order delivers measurable progress within a few sprints without a big rewrite, and the application works at the end of every step.
Frequently asked questions
Does Spring Modulith replace ArchUnit?
For module boundaries, yes, with less code and more meaningful error messages. For layer rules (a controller must not call a repository, for example) it can be used alongside ArchUnit.
Does the publication registry affect performance?
Every publication means one INSERT and, on completion, one UPDATE/DELETE; for business events such as orders this cost is negligible. For technical events produced by the thousands per second, plain Spring events or a direct message broker are preferable to the registry.
How many modules should there be?
As many as there are business capabilities; in a typical enterprise application 6–15. Counts above twenty are a sign that modules were split by technical layer rather than by business capability.
Conclusion
A modular monolith is protected by tooling, not by discipline: the package rule, the verify() test, reliable events via the publication registry, module-level tests and documentation generated from code. Once this structure is in place, development speed stays at monolith level and extracting a module into a service becomes a matter of weeks.
To map the modules of your existing codebase and define a modularization roadmap, see our software design and architecture service. The discovery call and first estimate are free.