The modular monolith with Gradle and Spring Modulith
A four-year-old Spring Boot application. One build module, two hundred packages, and any class can import any other. The order service reads the product table directly. Inventory calls orders directly, which call inventory. Nobody knows anymore which service depends on which.
At that point, the answer you hear most often is “let’s split it into microservices”. It is an answer that can quickly become very expensive. It swaps method calls for network calls, one transaction for several, one deployment for ten. And it does not fix the real problem, which is the lack of boundaries. Services that all call each other are the same mess, with latency on top.
There is another option before changing the whole architecture. Keep a single deployable, and draw real boundaries inside it. That is what a modular monolith is. Spring Modulith lets you check the boundaries, and Gradle has them checked during the build.
Every example in this article was run on Spring Boot 4.1.1, Spring Modulith 2.1.1, Gradle 9.5.1 and JDK 25.
A module is a business feature
The word “module” means different things depending on the context. Here it has one meaning: a part of the application that matches a business feature, with a public API and private logic not exposed to the outside.
For an order management application, the modules are, for example: catalog, order and inventory. Not controllers, services and repositories. Splitting by technical layer puts everything that looks alike technically in the same package. Splitting by module puts together everything that changes for the same reason.
What matters is the boundary. A module exposes a few types and a few methods. The rest is its own. Another module that needs something must go through the API, and never through the module’s internal code.
On that point, it is the same idea as hexagonal architecture. The difference is the scale. Hexagonal architecture protects a module’s domain from the technical implementation (e.g. the framework). The modular monolith protects the modules from each other and makes their boundaries clear.
The Spring Modulith conventions
Spring Modulith is an official Spring project. It does not ask you to restructure your application. It reads your packages and derives the modules, with three conventions:
- every direct sub-package of the application package is a module. With
ShopApplicationinshop, the modules areshop.catalog,shop.orderandshop.inventory; - the types placed at the root of a module are its public API.
shop.catalog.Catalogandshop.catalog.Productare visible to the other modules; - everything in a sub-package is internal.
shop.catalog.internal.ProductStorebelongs to thecatalogmodule only.
The starting tree:
shop
├── ShopApplication
├── catalog
│ ├── Catalog // API
│ ├── Product // API
│ └── internal
│ └── ProductStore
├── order
│ ├── Orders // API
│ ├── Order // API
│ └── internal
│ └── OrderRepository
└── inventory
└── Inventory // API
The BOM and two dependencies are enough, one for the code and one for the tests:
implementation(platform("org.springframework.modulith:spring-modulith-bom:2.1.1"))
implementation("org.springframework.modulith:spring-modulith-starter-core")
testImplementation("org.springframework.modulith:spring-modulith-starter-test")
A first test prints what Modulith detected:
class ModularityTests {
ApplicationModules modules = ApplicationModules.of(ShopApplication.class);
@Test
void printsModules() {
modules.forEach(System.out::println);
}
}
# Catalog
> Logical name: catalog
> Base package: shop.catalog
> Excluded packages: none
> Spring beans:
+ ….Catalog
o ….internal.ProductStore
# Inventory
> Logical name: inventory
> Base package: shop.inventory
> Excluded packages: none
> Spring beans:
+ ….Inventory
# Order
> Logical name: order
> Base package: shop.order
> Excluded packages: none
> Spring beans:
+ ….Orders
o ….internal.OrderRepository
The + marks an exposed bean, the o an internal one. Without any configuration, the tool already has the same map of the code as you do.
The test that protects the boundary
One line of test:
@Test
void verifiesModuleStructure() {
modules.verify();
}
By default, this test checks two things. No module uses another module’s internal code. And there is no cycle between modules. If a module declares its allowed dependencies with @ApplicationModule(allowedDependencies = ...), the test checks those too.
Let’s try to break the first rule. The order service needs a product’s price. The shortest path is to inject the catalog’s ProductStore directly:
@Service
public class Orders {
private final ProductStore products; // shop.catalog.internal
private final OrderRepository repository;
Orders(ProductStore products, OrderRepository repository) { ... }
public Order place(String sku, int quantity) {
var product = products.find(sku).orElseThrow();
var order = new Order(sku, quantity, product.priceInCents() * quantity);
repository.save(order);
return order;
}
}
It compiles. Spring injects the bean without complaint. But the test fails:
org.springframework.modulith.core.Violations:
- Module 'order' depends on non-exposed type shop.catalog.internal.ProductStore within module 'catalog'!
Orders declares constructor Orders(ProductStore, OrderRepository) in (Orders.java:0)
- Module 'order' depends on non-exposed type shop.catalog.internal.ProductStore within module 'catalog'!
Method <shop.order.Orders.place(java.lang.String, int)> calls method <shop.catalog.internal.ProductStore.find(java.lang.String)> in (Orders.java:19)
- Module 'order' depends on non-exposed type shop.catalog.internal.ProductStore within module 'catalog'!
Field <shop.order.Orders.products> has type <shop.catalog.internal.ProductStore> in (Orders.java:0)
- Module 'order' depends on non-exposed type shop.catalog.internal.ProductStore within module 'catalog'!
Constructor <shop.order.Orders.<init>(shop.catalog.internal.ProductStore, shop.order.internal.OrderRepository)> has parameter of type <shop.catalog.internal.ProductStore> in (Orders.java:0)
Four violations for a single mistake: the field, the constructor, its parameter and the method call. Each line names the offending module, the forbidden type and the exact spot. The :0 on the field and the constructor is not a bug. Modulith is built on ArchUnit, which reads bytecode. Only accesses made inside a method body, such as a call, keep a line number there. A declaration, such as the type of a field or a constructor parameter, has none. It is the same detail as in the article on ArchUnit and Konsist.
The fix is to go through the catalog’s API:
@Service
public class Orders {
private final Catalog catalog; // shop.catalog, exposed
private final OrderRepository repository;
public Order place(String sku, int quantity) {
var product = catalog.findBySku(sku).orElseThrow();
...
}
}
The test is green again. And if you want to expose a whole sub-package, say shop.catalog.api, a @NamedInterface on its package-info.java is enough. Everything else stays internal to the module.
Cycles
The second rule is about cycles. It is less visible, and more serious.
A cycle is two modules that need each other. Here, an order consumes stock at the moment it is placed. And inventory, to know what is consumed, receives the order:
// shop.order
public Order place(String sku, int quantity) {
...
repository.save(order);
inventory.reserve(order);
return order;
}
// shop.inventory
public void reserve(Order order) {
stock.merge(order.sku(), -order.quantity(), Integer::sum);
}
Each module only uses the other’s API. The first rule holds. And yet:
org.springframework.modulith.core.Violations: - Cycle detected: Slice inventory ->
Slice order ->
Slice inventory
1. Dependencies of Slice inventory
- Method <shop.inventory.Inventory.reserve(shop.order.Order)> has parameter of type <shop.order.Order> in (Inventory.java:0)
- Method <shop.inventory.Inventory.reserve(shop.order.Order)> calls method <shop.order.Order.quantity()> in (Inventory.java:18)
- Method <shop.inventory.Inventory.reserve(shop.order.Order)> calls method <shop.order.Order.sku()> in (Inventory.java:18)
2. Dependencies of Slice order
- Constructor <shop.order.Orders.<init>(shop.catalog.Catalog, shop.inventory.Inventory, shop.order.internal.OrderRepository)> has parameter of type <shop.inventory.Inventory> in (Orders.java:0)
- Field <shop.order.Orders.inventory> has type <shop.inventory.Inventory> in (Orders.java:0)
- Method <shop.order.Orders.place(java.lang.String, int)> calls method <shop.inventory.Inventory.reserve(shop.order.Order)> in (Orders.java:25)
The message gives the full cycle, then every dependency that forms it, in both directions.
Why it is serious: two modules in a cycle are one module. You cannot test one without the other. You cannot extract one later. And one cycle invites the next: once there is one, adding another costs less than avoiding it.
Breaking the cycle with an event
In a cycle, one of the two directions is almost always a “when this happens, do that”. Here: when an order is placed, decrement the stock quantity. That direction does not need a call. It needs an event.
The order publishes a fact, without knowing who listens:
// shop.order
public record OrderPlaced(String sku, int quantity) {}
@Transactional
public Order place(String sku, int quantity) {
...
repository.save(order);
events.publishEvent(new OrderPlaced(sku, quantity));
return order;
}
Inventory listens:
// shop.inventory
@ApplicationModuleListener
void on(OrderPlaced event) {
stock.merge(event.sku(), -event.quantity(), Integer::sum);
}
The inventory module still depends on order, for the OrderPlaced type. But order no longer knows about inventory. The cycle is broken, and verify() is green again.
@ApplicationModuleListener is not a plain @EventListener. It fires after the commit of the transaction that published the event. It runs on another thread, here task-1, and in its own transaction. If the listener fails, the order is already saved, and the event stays recorded as unprocessed in a registry in the database.
The annotation lives in spring-modulith-events-api, and the registry needs a database. The core starter brings neither, a persistence starter brings both. So three more dependencies are needed, here with H2 as the database:
implementation("org.springframework.modulith:spring-modulith-starter-jdbc")
implementation("org.springframework.boot:spring-boot-starter-jdbc")
runtimeOnly("com.h2database:h2")
And one property so the registry table is created at startup:
spring.modulith.events.jdbc.schema-initialization.enabled=true
Without a transaction manager there is no commit, so nothing fires. The test in the next section says so plainly if you forget it: To use a Scenario in an integration test you need to define a bean of type TransactionTemplate!.
An event makes the code less direct to read. You no longer see in place() that stock moves. That is the price. It is only worth paying to break a cycle, or to isolate a module you want to extract one day. For a module that simply needs an answer, a call to the API remains the right choice.
Testing one module on its own
A classic monolith has one kind of integration test: the one that starts everything. Modulith adds one per module. @ApplicationModuleTest starts only the module under test. The beans of the other modules are not loaded, and if the module needs them, they have to be replaced by mocks:
@ApplicationModuleTest
class InventoryTests {
@Autowired Inventory inventory;
@Test
void anOrderRemovesStock(Scenario scenario) {
scenario.publish(new OrderPlaced("LAMP-01", 3))
.andWaitForStateChange(() -> inventory.available("LAMP-01"))
.andVerify(available -> assertThat(available).isEqualTo(7));
}
}
Scenario comes with Modulith. It publishes the event, waits for the state to change, then verifies. Without it you would need a Thread.sleep or a polling loop, because the listener is asynchronous.
The startup log says exactly what was loaded:
Bootstrapping @org.springframework.modulith.test.ApplicationModuleTest for Inventory in mode STANDALONE (class shop.ShopApplication)…
> Logical name: inventory
For a module that depends on another, there is a mode that brings its direct dependencies along. That is the case for order, which needs catalog:
@ApplicationModuleTest(mode = BootstrapMode.DIRECT_DEPENDENCIES)
class OrdersTests {
@Autowired Orders orders;
@Test
void placingAnOrderPublishesAnEvent(Scenario scenario) {
scenario.stimulate(() -> orders.place("LAMP-01", 2))
.andWaitForEventOfType(OrderPlaced.class)
.toArriveAndVerify(event -> assertThat(event.quantity()).isEqualTo(2));
}
}
Bootstrapping @org.springframework.modulith.test.ApplicationModuleTest for Order in mode DIRECT_DEPENDENCIES (class shop.ShopApplication)…
> Logical name: order
Included dependencies:
> Logical name: catalog
This test knows nothing about inventory. It checks that the order publishes the right event, and stops there. That is what a cleanly split module gives you: a test that starts fast, and does not break when another module changes.
When the compiler should check the rule
Everything above fits in a single build module. The boundaries exist, but a test checks them. As long as the test runs, that is enough.
There are cases where you want more. One team per module, with build times to isolate. A module you plan to move into a separate service. Or simply wanting the error to show up in the IDE, before the tests even run. In those cases you move to a Gradle multi-project build, and the compiler enforces the rule.
One subproject per module, plus one for the application:
// settings.gradle.kts
rootProject.name = "shop"
include("catalog", "order", "inventory", "app")
Each subproject declares what it needs, and only that:
// order/build.gradle.kts
dependencies {
implementation(project(":catalog"))
implementation("org.springframework:spring-context")
implementation("org.springframework:spring-tx")
}
// inventory/build.gradle.kts
dependencies {
implementation(project(":order"))
implementation("org.springframework:spring-context")
implementation("org.springframework.modulith:spring-modulith-events-api")
}
The app subproject has the Spring Boot plugin, the starters, and depends on the other three. It holds ShopApplication and the verify() test. Modulith reads the classes from the classpath, whether they come from a folder or from another subproject’s jar. The test prints the same three modules as before.
What changes is what happens when you forget a dependency. Remove project(":catalog") from the order subproject:
> Task :order:compileJava FAILED
.../order/src/main/java/shop/order/Orders.java:6: error: package shop.catalog does not exist
import shop.catalog.Catalog;
^
No test to run. The IDE underlines the import in red before the commit.
The cycle becomes a build error too. Add project(":inventory") to the dependencies of order, while inventory already depends on order:
FAILURE: Build failed with an exception.
* What went wrong:
Circular dependency between the following tasks:
:inventory:compileJava
\--- :order:compileJava
\--- :inventory:compileJava (*)
Gradle refuses to work out a compilation order. There is nothing to check, because nothing compiles.
A word on implementation and api. With implementation(project(":catalog")), the catalog’s types are only visible inside order. A third subproject that depends on order does not see them. That is the behavior to keep by default. api passes the dependency on, and it is only useful when the API of order exposes catalog types in its signatures.
What Gradle does not see
Gradle knows about subprojects. It knows nothing about your packages. Put back in order the direct injection of shop.catalog.internal.ProductStore from the start of the article, with project(":catalog") properly declared:
> Task :order:compileJava
BUILD SUCCESSFUL
The catalog subproject is on the classpath of order, so all its classes are, internal included. Only verify() sees it:
org.springframework.modulith.core.Violations:
- Module 'order' depends on non-exposed type shop.catalog.internal.ProductStore within module 'catalog'!
The Gradle split and the Modulith test do not replace each other. Gradle checks the direction of dependencies between modules. Modulith enforces what one module is allowed to see of another. You need both, and they fit in one build file per subproject and one test.
Documentation that does not lie
One last test, one line, and Modulith writes the module documentation:
@Test
void writesDocumentation() {
new Documenter(modules).writeDocumentation();
}
In build/spring-modulith-docs/, you get a C4 diagram of the whole application and a canvas per module. In the PlantUML diagram, the only lines that matter are the two relations, right before the legend:
Rel(ShopApplication.ShopApplication.Order, ShopApplication.ShopApplication.Catalog, "uses", ...)
Rel(ShopApplication.ShopApplication.Inventory, ShopApplication.ShopApplication.Order, "listens to", ...)
order uses catalog. inventory listens to order. That is exactly the application, because it is generated from the classes, on every build. The canvas of the inventory module says the same thing, as a table:
|Base package
|`shop.inventory`
|Spring components
|_Services_
* `s.i.Inventory`
|Events listened to
|* `s.o.OrderPlaced` (async)
A diagram in a wiki is wrong after three months. This one cannot be: if it no longer matches the code, verify() is already red.
Where to draw the boundaries
The tool checks the boundaries. It does not tell you where to put them. A few landmarks that hold up over time.
Split by business capability, and not by technical layer. If a module is called services, it is not a module.
Three or four modules on a mid-sized application, not fifteen. A module splits in two later without trouble. Two modules that should have been one give themselves away by their cycles.
Rely on the business vocabulary. A module boundary that cuts through one team’s vocabulary is in the wrong place. A module often matches a word the business teams use on its own: the catalog, orders, stock.
Keep what the monolith gives you for free. A transaction that spans two modules is still possible, and sometimes it is the right answer. A refactoring across three modules can happen in the IDE, in a single commit. Those are real advantages, and the modular monolith keeps them all. The day a module really has to be extracted, it already has an API, events and tests of its own. On that day, the work is simpler. And often, that day will never come.
In short
A monolith is not a problem. A monolith where everything imports everything is. Before thinking microservices, there is a cheaper step: keep a single deployable and draw clear boundaries inside it.
Spring Modulith reads your packages and turns them into modules, with an API at the root and the internal code below. A one-line test, verify(), refuses to let a module touch another’s internal code, and refuses cycles. An event breaks a cycle when one of the two directions is a “when this happens”. And @ApplicationModuleTest lets you test a module without starting the others.
When you want the error to show up at build time, you move to a Gradle multi-project build. It then validates the direction of dependencies and refuses cycles. But it knows nothing about packages: the Modulith test is still needed to protect the internal code. Both together come to one build file per subproject and one test.