Spring Modulith: Build a Modular Monolith in Spring Boot
By Uddip Acharjee Shuvo9 min read
Most Spring Boot monoliths start tidy. A year later the order code reads the product tables directly, the product code imports an order DTO, and nobody can change one area without breaking another. Splitting into microservices fixes the coupling, but you pay for it with network calls, separate deployments and data that is only eventually consistent.
A modular monolith is the middle path: one deployable application, with boundaries inside it that are enforced rather than hoped for. Spring Modulith gives a Spring Boot app those boundaries, a test that fails when one is crossed, and documentation generated from the code. The examples come from my modulith repository, a small shop with catalog, orders and inventory modules on Spring Boot 3.4 and Spring Modulith 1.3. Spring Modulith 2.x, for Spring Boot 4, keeps every API shown here.
Modules are packages
Spring Modulith needs no new build structure. Every direct sub-package of your application’s main package is a module:
com.incognito.modulith
├── ModulithApplication.java
├── catalog
│ ├── Product.java API
│ ├── ProductService.java API
│ ├── domain/ internal
│ └── web/ internal
├── orders
│ ├── domain/ internal
│ │ └── events/ named interface "order-events"
│ └── web/ internal
├── inventory
│ └── InventoryService.java
└── common open moduleTypes in a module’s top-level package are its API. Everything in a sub-package is internal, even when it is declared public. That last part is the point: Java’s public means “visible to everyone”, and there is no keyword for “visible to my module only”. Spring Modulith adds that rule and checks it.
Add Spring Modulith to the build
dependencyManagement {
imports {
mavenBom "org.springframework.modulith:spring-modulith-bom:1.3.3"
}
}
dependencies {
implementation 'org.springframework.modulith:spring-modulith-starter-core'
testImplementation 'org.springframework.modulith:spring-modulith-starter-test'
}Verify the boundaries in a test
One test turns the package conventions into rules the build enforces:
class ModularityTests {
static ApplicationModules modules = ApplicationModules.of(ModulithApplication.class);
@Test
void verifiesModularStructure() {
modules.verify();
}
}verify() fails the test with a list of violations if:
- two modules depend on each other in a cycle,
- a module uses a type from another module’s internal packages, such as
ordersinjecting catalog’sProductRepository, or - a module depends on one it has not declared as allowed (see below).
Because it is a plain unit test, it runs on every build. A boundary violation shows up in a pull request instead of in an architecture review six months later.
Expose only what other modules need
The catalog module publishes an interface and a record at its top level. The implementation, the JPA entity and the repository all live in catalog.domain, where no other module can reach them:
package com.incognito.modulith.catalog;
public interface ProductService {
PagedResult<Product> getProducts(int pageNo);
Optional<Product> getByCode(String code);
}package com.incognito.modulith.catalog;
public record Product(String code, String name, String description,
String imageUrl, BigDecimal price) {}The orders module validates a new order against that API and nothing else. It never sees the ProductEntity or its table:
private void validate(CreateOrderRequest request) {
String code = request.item().code();
Product product = productService.getByCode(code)
.orElseThrow(() -> new InvalidOrderException("Product not found with code: " + code));
if (product.price().compareTo(request.item().price()) != 0) {
throw new InvalidOrderException("Product price mismatch");
}
}Returning a record instead of the entity matters. If catalog later splits its table or moves to another database, the Product record stays the same and orders never notices.
Named interfaces for types that live deeper
Sometimes a module needs to share a type that does not belong in its top-level package, such as events. A package-info.java with @NamedInterface exposes one sub-package under a name:
@org.springframework.modulith.NamedInterface("order-events")
package com.incognito.modulith.orders.domain.events;Other modules may now use OrderCreatedEvent, but the rest of orders.domain stays internal.
Declare which modules a module may depend on
By default a module may use any other module’s API. To make the dependency graph explicit, list the allowed ones on the module’s base package. Anything not listed fails verify():
@org.springframework.modulith.ApplicationModule(
allowedDependencies = { "catalog" }
)
package com.incognito.modulith.orders;To allow only a named interface, use the module :: interface form, for example "orders :: order-events". A small shared kernel, such as a PagedResult type every module returns, can be declared an open module, which exposes all of its packages:
@ApplicationModule(type = ApplicationModule.Type.OPEN)
package com.incognito.modulith.common;
import org.springframework.modulith.ApplicationModule;Talk through events instead of calls
Orders validates against catalog with a direct call, because it needs the answer before it can continue. Inventory is different: orders does not need to wait for it, and should not even know it exists. So orders publishes an event after saving:
OrderEntity savedOrder = orderRepository.save(newOrder);
eventPublisher.publishEvent(new OrderCreatedEvent(
savedOrder.getOrderNumber(),
savedOrder.getOrderItem().code(),
savedOrder.getOrderItem().quantity()));and inventory listens:
@Service
class InventoryService {
@EventListener
void on(OrderCreatedEvent event) {
log.info("Update inventory for product code: {}", event.productCode());
}
}The dependency now points from inventory to orders, not the other way round. Adding shipping or notifications later means adding a listener, not editing OrderService.
A plain @EventListener runs synchronously, inside the publisher’s transaction. If the inventory update throws, the order rolls back with it. Spring Modulith’s @ApplicationModuleListener and its event publication registry fix that. They are covered in Transactional Outbox with Spring Modulith Events.
Test one module at a time
@ApplicationModuleTest starts only the module the test class lives in, rather than the whole application. Beans from other modules are not created, so dependencies on them are mocked. The Scenario parameter lets a test trigger something and wait for the event it should publish:
@ApplicationModuleTest
class OrdersModuleTests {
@MockitoBean
ProductService productService; // catalog is not started
@Autowired
OrderService orderService;
@Test
void publishesOrderCreatedEvent(Scenario scenario) {
when(productService.getByCode("P100")).thenReturn(Optional.of(
new Product("P100", "The Hunger Games", "", "", new BigDecimal("34.00"))));
var request = new CreateOrderRequest(
new OrderItem("P100", "The Hunger Games", new BigDecimal("34.00"), 1),
new Customer("Ayesha", "ayesha@example.com", "+8801700000000"));
scenario.stimulate(() -> orderService.createOrder(request))
.andWaitForEventOfType(OrderCreatedEvent.class)
.matchingMappedValue(OrderCreatedEvent::productCode, "P100")
.toArrive();
}
}@MockitoBean is the Spring Framework 6.2 replacement for Boot’s deprecated @MockBean. The stimulus runs in its own transaction, which commits, so clean up what it created (Scenario has an andCleanup(…) hook for this). The orders module still needs its database, so run this against a real PostgreSQL. The testing guide shows how with Testcontainers.
Generate documentation from the code
The same ApplicationModules model can write diagrams of the modules and their dependencies, so the architecture documentation is regenerated on every build instead of drifting out of date:
@Test
void writeDocumentation() {
new Documenter(modules).writeDocumentation();
}This writes PlantUML component diagrams for the whole application and for each module, plus a “module canvas” per module listing its API, Spring beans, events published and events listened to. They go into a spring-modulith-docs folder under the build output directory.
Modular monolith or microservices?
| Modular monolith | Microservices | |
|---|---|---|
| Deployables | One | One per service |
| Calls between parts | In-process method calls and events | Network calls and messaging |
| Consistency | Local transactions | Eventual, with sagas or outboxes |
| Boundaries enforced by | Tests like verify() | Separate codebases and the network |
| Scaling | The whole application | Each service on its own |
| Operational cost | Low | High |
For most teams, a modular monolith is the better place to start. If a module later needs to scale or deploy on its own, clean boundaries and event-based communication make extracting it a refactoring, not a rewrite.
Checklist
- One top-level package per business capability.
- Interfaces and records in the module’s base package; everything else below it.
ApplicationModules.of(App.class).verify()in the test suite.allowedDependenciesto make the dependency graph explicit, and named interfaces for shared events.- Events for anything the caller does not need to wait for.
@ApplicationModuleTestfor module-level integration tests.- Generated documentation, regenerated on every build.