Modular Monolith: A Practical Introduction to Spring Modulith
As an application grows, we generally end up picking one of two paths. Either everything sticks together in a single application (a monolith) where services reach into each other and it’s no longer clear who uses what (tightly coupled), or we panic and split the application down into the smallest possible pieces, moving to a microservices architecture. In that case we then have to deal with network calls, distributed transactions, and a heavy dose of devops overhead. In this post we won’t be discussing the pros and cons of microservices versus monoliths, or when to choose which under what conditions.
Instead, we’ll look at an approach well-suited to small-to-medium sized projects today — one that, if its rules are followed, stays easy to maintain, clean, keeps every module as independent (loosely coupled) from the others as possible, and makes a later transition to a microservices architecture as smooth as possible when needed. It’s called the Modular Monolith. In a Modular Monolith you still remain a single application, but internally there are clear boundaries between modules. First we’ll look at what a Modular Monolith is, and then we’ll examine Spring Modulith — which makes this even easier in Spring and helps prevent architectural mistakes up to a certain degree.
What Is a Modular Monolith?
A Modular Monolith is an architectural approach in which an application exists as a single deployment, but its codebase is internally split into independent modules (domains) with clear boundaries.
Core Idea:
- The application runs as a single process.
- It’s split into modules according to business domain.
- Each module has its own internal logic and its own data model.
- Modules can only reach each other through a designated, public API, and cannot directly touch each other’s internal details.
- These boundaries aren’t just “good intentions” — they’re enforced and violations are caught by a verification that runs at test time (e.g.
verify()in Spring Modulith). If a module’s internal class has to be madepublicbecause it’s used by another class within the same module, the Java compiler can’t stop that class from being accessed from outside the module too; what actually protects the boundary is the verification test that’s run regularly. - Communication between modules generally happens in one of two ways: 1) through the exposed public API (usually an interface), 2) via events.
What a Modular Monolith Is Not
It’s not a monolith: In classic monolith designs, code is generally split into layers (presentation, service, dao/repo), but there’s no restriction between these layers. Any component can reach into any other component however it likes. This leads to spaghetti code and dependency hell. In a modular monolith, boundaries are drawn by business domain rather than by technical layer, and these boundaries are enforced.
It’s not a microservice:
- There are no separate processes or separate servers. (I’m not talking about replicas and/or instances here — i.e., not about scalability.)
- Communication between modules doesn’t happen over the network; it generally happens within the same process (in-memory). Or it can happen through a message broker.
- Distributed-systems concerns like network latency, partial failure, distributed transaction management, or service discovery are not relevant here.
- It generally uses a single database. (In microservice architectures, by the very philosophy and structure of microservices, each service must be completely independent of the others and able to stand up on its own. That’s why, if a database is needed, each microservice has its own separate database.)
Now that we have a clearer picture of what a Modular Monolith is and isn’t, let’s look specifically at Spring Modulith in the context of Spring Boot — a project that makes it easier to work with a modular monolith, lets us test whether the boundaries between modules are being respected, provides an event infrastructure for inter-module communication (a mechanism similar to the outbox pattern), and is a standalone Spring project independent of Spring Cloud.
What Spring Modulith Is, and Is Not
Let’s clarify one thing first: Spring Modulith is not a framework, it’s a thin layer sitting on top of Spring Boot. It doesn’t ask you to rewrite your application. It gives you three things:
- The module concept — it splits your code into logical modules based on package structure. You define what part of each module is exposed and what part is internal detail.
- Boundary verification — you write a test, and Modulith checks whether modules are touching another module’s disallowed internals. If there’s a violation, the test fails. This protection relies on a test that runs as a CI step; it is not a mechanism that automatically kicks in at compile time.
- Inter-module events — modules talk to each other by publishing events instead of calling each other directly; Modulith provides a persistent, transactional infrastructure for these events so that they aren’t lost even if the application crashes.
As for the version, this post is based on the current generation, Spring Modulith 2.0 (built on the Spring Boot 4 / Spring Framework 7 baseline, per the official announcement).
Why “Based on Package Structure”?
Most Spring Boot projects are organized by technical layer:
shop
├── config
├── controllers
├── services
├── repositories
└── entities When you open this structure, you see “controllers”, “services”, “repositories” — but you can’t see what the application actually does. Where’s “orders”? Where’s “inventory”? The business logic is hidden behind the technical folders.
Modulith instead encourages you to split by business capability:
shop
├── order
├── inventory
└── catalog The rule is simple: every direct subpackage of your main package is a module. Above, order, inventory, and catalog are each a module. Furthermore, a module’s root package is its public API; its subpackages (like order.internal, order.service) are specific to that module and cannot be accessed from outside.
Setup
As a dependency — for the persistent events we’ll see shortly — you add an event store starter and test support. To avoid writing out versions one by one, it’s the officially recommended practice to put the Spring Modulith BOM into dependencyManagement. The officially available starters are: spring-modulith-starter-test, spring-modulith-starter-jdbc, spring-modulith-starter-jpa, spring-modulith-starter-mongodb, spring-modulith-starter-neo4j, and spring-modulith-starter-insight (actuator + observability). The core module (spring-modulith-core) is already transitively included in your project through these starters, so you don’t need to add it separately. With the JDBC example:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-bom</artifactId>
<version>2.1.0</version>
<scope>import</scope>
<type>pom</type>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies> Marking Modules
Actually, even if you do nothing at all, every subpackage is treated as a module by default. But explicitly marking a module lets you place additional rules on it. You do this by putting @ApplicationModule on the package’s package-info.java file:
@ApplicationModule
package com.example.shop.order;
import org.springframework.modulith.ApplicationModule; Now order is an explicitly marked module. Classes at the root of the order package are exposed; subpackages like order.internal are private to order. If another module tries to directly import an internal class of order, the verification test we’ll write shortly catches it — this is a check that surfaces when the test is run, not at compile time.
Named Interface: Exposing Something Outside the Root
Sometimes a module has a section it wants to expose outside its root as well — for example, a separate order.api package. You expose this with @NamedInterface:
@NamedInterface("api")
package com.example.shop.order.api;
import org.springframework.modulith.NamedInterface; Now other modules connect not to order’s internal structure, but only through order :: api. This keeps the distinction between “which part of me is a stable contract, and which part is an internal detail that might change tomorrow” clear at the code level.
Restricting Allowed Dependencies
By default, a module can connect to the exposed parts of other modules. If you want, you can tighten this further. The allowedDependencies field on @ApplicationModule is exactly for this:
@ApplicationModule(allowedDependencies = { "catalog", "shared" })
package com.example.shop.order;
import org.springframework.modulith.ApplicationModule; This list is a declaration of intent: order can only connect to catalog and shared. If tomorrow order tries to use something from a module not on the list (say, inventory), the verification test fails. If you want to allow access to only a specific interface of a module, you use the "inventory :: api" syntax.
Deliberately “Open” Modules
You might want to explicitly mark certain modules as OPEN — for example, a shared module that genuinely holds common utilities. An OPEN module doesn’t hide its internal structure; anyone can freely connect to it:
@ApplicationModule(type = ApplicationModule.Type.OPEN)
package com.example.shop.shared;
import org.springframework.modulith.ApplicationModule; Modulith lets you honestly document this as “this module is deliberately open,” so you don’t end up trapping shared code behind module walls unnecessarily. This feature is mainly intended for existing projects gradually moving toward the packaging structure Spring Modulith recommends; in a fully modularized application, using an OPEN module generally signals a sub-optimal modularization.
Verifying Boundaries With a Test
Everything described so far is just good intentions without a single test. The test that holds the entire structure together is this small:
class ModularityTests {
@Test
void verifiesModularStructure() {
ApplicationModules.of(ShopApplication.class).verify();
}
} ApplicationModules.of(...).verify() scans all modules and checks: is one module touching another module’s internal (non-public) structure, are the allowedDependencies lists being respected, is there a circular dependency. If any of these is violated, the test fails. So your architecture isn’t a diagram rotting away on a wiki page — it’s a living rule tested on every CI run — but this protection depends on the test actually being run; it is not a mechanism that automatically kicks in at compile time. When you accidentally breach a boundary, you find out in that commit’s test run, not weeks later.
Bonus: Automatically Documenting the Architecture
From the same model you can also produce C4-style diagrams and “module cards.” Documenter outputs PlantUML component diagrams:
@Test
void writeDocumentation() {
var modules = ApplicationModules.of(ShopApplication.class);
new Documenter(modules)
.writeModulesAsPlantUml()
.writeIndividualModulesAsPlantUml();
} Since the diagrams are generated from the code, the documented architecture always stays in sync with the actual code.
Testing a Single Module in Isolation
Modulith offers @ApplicationModuleTest so you can test a single module without starting up the whole application. It loads only the relevant module; you mock the other modules’ APIs. This makes tests both fast and focused.
Talking Between Modules: Publish an Event Instead of Calling Directly
Splitting modules apart is easy; the real question is how they’ll communicate without tightly coupling to each other. Modulith’s suggested answer is almost always the same: don’t call directly, publish an event.
Consider the scenario “when an order is placed, reduce stock.” The bad solution would be for the order module to directly call the inventory module — at that moment the two become coupled. Instead, order just publishes an event. The event itself is extremely simple, a data-carrying record:
public record OrderCompleted(UUID orderId) {} To publish it, you use Spring’s standard ApplicationEventPublisher:
@Service
class OrderService {
private final ApplicationEventPublisher events;
OrderService(final ApplicationEventPublisher events) {
this.events = events;
}
@Transactional
public void complete(final UUID orderId) {
// ... complete the order ...
this.events.publishEvent(new OrderCompleted(orderId));
}
} On the other side, the inventory module listens for this event:
@Component
class InventoryManagement {
@ApplicationModuleListener
void on(final OrderCompleted event) {
// ... reduce stock ...
}
} Notice: inventory has no knowledge of order, and order has no knowledge of inventory. The two only meet through the shared OrderCompleted event. The publisher and the consumer are fully decoupled from one another — that’s exactly what modularity is after. If tomorrow you want to extract inventory into a separate service, the path is already open.
What Does @ApplicationModuleListener Actually Do?
The key annotation here is @ApplicationModuleListener. It looks like an ordinary listener, but underneath it combines three annotations: @Async, @Transactional(propagation = Propagation.REQUIRES_NEW), and @TransactionalEventListener. So @ApplicationModuleListener is actually shorthand for:
@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener
void on(OrderCompleted event) { /* … */ } In concrete terms: the original work that publishes the event (the order’s database operation) completes and commits within its own transaction; the listener then runs afterward, asynchronously, in a separate, new transaction. So even if reducing stock is slow or fails, the order itself isn’t affected by it. And you’re spared from having to write these three annotations by hand every time.
Events That Don’t Get Lost: The Event Publication Registry
Asynchronous events have a classic danger: what if the application crashes after the event is published but before the listener runs? With ordinary Spring events, that event silently evaporates. In most business scenarios this is unacceptable — it would mean the order was completed but stock was never reduced.
This is where Spring Modulith’s strongest feature comes in: the Event Publication Registry. Here’s how it works:
- When an event is published, Modulith writes a database record for every listener interested in that event — and it does so inside the original business transaction. So the event becomes persistent in the same transaction as the work itself. The record is initially created in the
PUBLISHEDstate. - When a listener starts processing a record, its state moves to
PROCESSING(this transition happens before the listener is invoked, so that even in the event of a crash, the attempt count stays accurate). - If the listener finishes successfully, the record is marked
COMPLETED. - If the listener throws an error, or if the record is caught by the staleness mechanism, the state becomes
FAILED. - When a failed record is resubmitted, its state returns to
RESUBMITTEDand it waits to be processed again.
Each record also keeps track of how many completion attempts have been made and when it was last resubmitted; this lets you apply policies like “only resubmit records that have been failing for a certain amount of time” or “stop after N attempts.”
Records are kept in an event publication log, containing fields like the event’s type, its serialized form, which listener it was sent to, and its publication and completion dates. The registry has implementations for different persistence technologies: JPA, JDBC, MongoDB, and Neo4j. A Jackson-based serializer is used by default to serialize events.
A few useful settings:
spring:
modulith:
events:
republish-outstanding-events-on-restart: true
completion-mode: update completion-modedetermines what happens to completed records.update(the default) doesn’t delete the record, it just fills in the completion date — so you can see the history.deleteremoves completed records (keeps the table clean).archivemoves them into a separate archive table instead.- With
republish-outstanding-events-on-restartenabled, the application automatically republishes unfinished events every time it starts up. This is a practical way to recover after a crash in single-instance (singleton) setups.
With the 2.0 generation, the registry also gained a Staleness Monitor (a periodically running background task) that automatically marks publications stuck for a certain duration as FAILED, along with resubmission APIs like resubmitIncompletePublications(...). This monitor is active as long as the staleness durations are non-zero; if they’re all zero (the default), the monitor never kicks in at all. So you can both keep an eye on unfinished work and retrigger it in a controlled way.
From Internal Event to the Outside World: Externalization
If you want to send an event not only within the application but also to an external message queue, Modulith has an event externalization capability. You mark the relevant events with @Externalized, and Modulith delivers them to a supported broker. Supported infrastructures: Kafka, AMQP, JMS, and Spring Messaging (MessageChannel-based, typically used together with Spring Integration flows).
Best of all, this externalization is also protected by the same Event Publication Registry: if an error occurs while talking to the broker, the publication record isn’t lost and can be resubmitted later.
So the transition from “start as a modular monolith, open up to a real broker when needed” can come down to a single annotation.
Visibility at Runtime
Modulith also adds an actuator endpoint: /actuator/modulith. To enable it, you need to add the spring-modulith-actuator dependency (and spring-boot-starter-actuator for general actuator support). From here you can view the application’s module structure and the relationships between modules at runtime. Combined with tracing tools, it’s also possible to visualize HTTP calls on a per-module basis — for this you additionally need the spring-modulith-observability dependency.
Wrapping Up
The real problem Spring Modulith solves is opening up a third door to the “monolith or microservices” dilemma. You stay within a single Spring Boot application — without taking on the complexity of distributed systems — but you gain disciplined boundaries inside it.
In summary, here’s what you get:
- Boundaries drawn by packages, protected by tests. Code is split by business capability, and if one module leaks into another’s internals, this is caught the first time the detecting test runs (typically a CI run). This protection doesn’t happen automatically at compile time; it depends on the test being run.
- Communication through events instead of direct calls. Modules talk to each other through shared events without knowing each other’s names; if you want to extract a module into a separate service later, the path is already open.
- Events that don’t get lost. Thanks to the Event Publication Registry, asynchronous integrations aren’t fragile calls tossed around in memory — they’re crash-resistant, traceable records written to the database together with the business transaction, with a complete lifecycle as of 2.0 consisting of the PUBLISHED, PROCESSING, COMPLETED, FAILED, and RESUBMITTED states.
And if you ever genuinely need microservices, you’ll already have modules with clear boundaries that talk through events — meaning you’ll have already done the hardest part.
To Dig Deeper
- Official reference: https://docs.spring.io/spring-modulith/reference/
- Fundamentals: https://docs.spring.io/spring-modulith/reference/fundamentals.html
- Verification: https://docs.spring.io/spring-modulith/reference/verification.html
- Events section: https://docs.spring.io/spring-modulith/reference/events.html
- Production-ready features (actuator/observability): https://docs.spring.io/spring-modulith/reference/production-ready.html