Spring Modulith: Building a Modular Monolith
Introduction
Most applications start as one deployable unit, and most of them should. What goes wrong with a monolith over the years is rarely that it is one unit. It is that nothing inside it keeps its parts apart: every class can call every other class, the order code reads the inventory tables, and after a while no part can be changed without knowing all the others. A modular monolith keeps the single deployment and adds what was missing - modules with a public interface, hidden internals and checked dependencies. Spring Modulith is the Spring project for building one. In this tutorial we structure a small shop into modules, let Spring Modulith verify the structure and reject a bad one, connect the modules with events that survive a failing listener, and generate documentation from the code. The code runs on Spring Boot 4.1.2 with Spring Modulith 2.1.Between a monolith and microservices
The tutorial on when and why to use microservicesClick to read tutorial: Microservices with Spring: When and Why listed what the split into services costs: network calls that fail, data spread over databases, and far more to deploy and operate. Most of what teams want from microservices - parts that can be understood and changed on their own - does not need separate processes. It needs boundaries. A modular monolith provides the boundaries without the network, and if one module later does have to become a service, a clean boundary is what makes that extraction possible.
Java alone does not give much help here. A class that another package needs must be public, and a public class is visible to the whole application. Spring Modulith adds the missing level: it defines what a module is, and checks at test time that the modules only use each other in the allowed ways.
Modules are packages
Spring Modulith needs no configuration to find the modules. It uses a convention -- Every direct sub-package of the package of the
@SpringBootApplicationclass is a module. - The public types in a module's base package are its API. Other modules may use them.
- Everything in sub-packages of a module is internal. Other modules may not use it, even where Java would allow it because the class is public.
The example is a shop with four modules: catalog, order, inventory and notification. The dependencies are spring-modulith-starter-core and spring-modulith-starter-jdbc, with versions managed by the spring-modulith-bom, plus spring-boot-starter-jdbc and H2.
ShopApp.java
//ShopApp.java
package com.javabrahman.shop;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ShopApp {
public static void main(String[] args) {
System.exit(SpringApplication.exit(SpringApplication.run(ShopApp.class, args)));
}
}
catalog/CatalogService.java - the API of the catalog module
//CatalogService.java
package com.javabrahman.shop.catalog;
import com.javabrahman.shop.catalog.internal.PriceTable;
import org.springframework.stereotype.Service;
@Service
public class CatalogService {
private final PriceTable prices;
CatalogService(PriceTable prices) {
this.prices = prices;
}
public int priceInCents(String isbn) {
return prices.lookup(isbn);
}
}
catalog/internal/PriceTable.java - internal to the catalog module
//PriceTable.java
package com.javabrahman.shop.catalog.internal;
import java.util.Map;
import org.springframework.stereotype.Component;
@Component
public class PriceTable {
private final Map<String, Integer> prices = Map.of("978-1", 4500, "978-2", 3850);
public int lookup(String isbn) {
return prices.get(isbn);
}
}
order/OrderPlaced.java
//OrderPlaced.java
package com.javabrahman.shop.order;
public record OrderPlaced(long orderId, String isbn, int quantity) {
}
order/OrderService.java - the API of the order module
//OrderService.java
package com.javabrahman.shop.order;
import com.javabrahman.shop.catalog.CatalogService;
import com.javabrahman.shop.order.internal.OrderRepository;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
private final OrderRepository orders;
private final CatalogService catalog;
private final ApplicationEventPublisher events;
OrderService(OrderRepository orders, CatalogService catalog, ApplicationEventPublisher events) {
this.orders = orders;
this.catalog = catalog;
this.events = events;
}
@Transactional
public void place(long id, String isbn, int quantity) {
int total = catalog.priceInCents(isbn) * quantity;
orders.save(id, isbn, quantity, total);
events.publishEvent(new OrderPlaced(id, isbn, quantity));
System.out.println("order: order " + id + " saved, total " + total + " cents [" + Thread.currentThread().getName() + "]");
}
}
order/internal/OrderRepository.java - internal to the order module
//OrderRepository.java
package com.javabrahman.shop.order.internal;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
@Repository
public class OrderRepository {
private final JdbcClient jdbc;
OrderRepository(JdbcClient jdbc) {
this.jdbc = jdbc;
}
public void save(long id, String isbn, int quantity, int totalCents) {
jdbc.sql("insert into orders values (?, ?, ?, ?)").params(id, isbn, quantity, totalCents).update();
}
}
src/main/resources/schema.sql
create table orders (id bigint primary key, isbn varchar(20), quantity int, total_cents int);
create table stock (isbn varchar(20) primary key, available int);
insert into stock values ('978-1', 10);
- The
catalogmodule offersCatalogService. ItsPriceTableis in the sub-packageinternal. It has to be a public class, becauseCatalogServicein another package uses it, but for Spring Modulith it is hidden. - The
ordermodule offersOrderServiceand the eventOrderPlaced.OrderServicecallsCatalogService- a call from one module to the API of another, which is allowed. - After saving the order,
place()publishesOrderPlacedas an application eventClick to read tutorial: Application Events in Spring with @EventListener. The two modules that react to it, shown below, are not mentioned in the order module at all.
Verifying the structure
ApplicationModules.of() analyses the compiled classes of the application, and verify() checks the rules.
VerifyModules.java
//VerifyModules.java
package com.javabrahman.shop;
import org.springframework.modulith.core.ApplicationModules;
public class VerifyModules {
public static void main(String[] args) {
ApplicationModules modules = ApplicationModules.of(ShopApp.class);
modules.forEach(System.out::println);
modules.verify();
System.out.println("verify(): no violations");
}
}
# Catalog > Logical name: catalog > Base package: com.javabrahman.shop.catalog > Excluded packages: none > Spring beans: + ….CatalogService o ….internal.PriceTable # Order > Logical name: order > Base package: com.javabrahman.shop.order > Excluded packages: none > Spring beans: + ….OrderService o ….internal.OrderRepository # Inventory > Logical name: inventory > Base package: com.javabrahman.shop.inventory > Excluded packages: none > Spring beans: o ….InventoryListener # Notification > Logical name: notification > Base package: com.javabrahman.shop.notification > Excluded packages: none > Spring beans: + ….MailGateway o ….NotificationListener verify(): no violations
- Spring Modulith found the four modules from the package names alone.
- Under "Spring beans", a
+marks a bean that is part of the module's API, and anoone that is internal.CatalogServiceis API;internal.PriceTableis not.InventoryListenerandNotificationListenerare in their base packages, but they are not public classes, so they are internal too. verify()found nothing to object to.
main method here so that its output can be shown. In a project the same two lines are a test, which then fails the build whenever somebody breaks the structure -
src/test/java/.../ModularityTests.java
class ModularityTests {
@Test
void verifiesModularStructure() {
ApplicationModules.of(ShopApp.class).verify();
}
}
What verify() rejects
To see a failing verification, here is a second, deliberately wrong application with two modules,pricing and billing.
BadShopApp.java
//BadShopApp.java
package com.javabrahman.badshop;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.modulith.core.ApplicationModules;
import org.springframework.modulith.core.Violations;
@SpringBootApplication
public class BadShopApp {
public static void main(String[] args) {
try {
ApplicationModules.of(BadShopApp.class).verify();
} catch (Violations violations) {
System.out.println(violations.getMessage());
}
}
}
pricing/PricingService.java
//PricingService.java
package com.javabrahman.badshop.pricing;
import com.javabrahman.badshop.billing.BillingService;
import org.springframework.stereotype.Service;
@Service
public class PricingService {
private final BillingService billing;
PricingService(BillingService billing) {
this.billing = billing;
}
public int price(String isbn) {
return billing.hasUnpaidInvoices() ? 5000 : 4500;
}
}
pricing/internal/DiscountTable.java
//DiscountTable.java
package com.javabrahman.badshop.pricing.internal;
import org.springframework.stereotype.Component;
@Component
public class DiscountTable {
public int discountPercent(String isbn) {
return 10;
}
}
billing/BillingService.java
//BillingService.java
package com.javabrahman.badshop.billing;
import com.javabrahman.badshop.pricing.PricingService;
import com.javabrahman.badshop.pricing.internal.DiscountTable;
import org.springframework.stereotype.Service;
@Service
public class BillingService {
private final PricingService pricing;
private final DiscountTable discounts;
BillingService(PricingService pricing, DiscountTable discounts) {
this.pricing = pricing;
this.discounts = discounts;
}
public int invoiceAmount(String isbn) {
return pricing.price(isbn) * (100 - discounts.discountPercent(isbn)) / 100;
}
public boolean hasUnpaidInvoices() {
return false;
}
}
- Cycle detected: Slice billing ->
Slice pricing ->
Slice billing
1. Dependencies of Slice billing
- Constructor <com.javabrahman.badshop.billing.BillingService.<init>(com.javabrahman.badshop.pricing.PricingService, com.javabrahman.badshop.pricing.internal.DiscountTable)> has parameter of type <com.javabrahman.badshop.pricing.PricingService> in (BillingService.java:0)
- Constructor <com.javabrahman.badshop.billing.BillingService.<init>(com.javabrahman.badshop.pricing.PricingService, com.javabrahman.badshop.pricing.internal.DiscountTable)> has parameter of type <com.javabrahman.badshop.pricing.internal.DiscountTable> in (BillingService.java:0)
- Field <com.javabrahman.badshop.billing.BillingService.discounts> has type <com.javabrahman.badshop.pricing.internal.DiscountTable> in (BillingService.java:0)
- Field <com.javabrahman.badshop.billing.BillingService.pricing> has type <com.javabrahman.badshop.pricing.PricingService> in (BillingService.java:0)
- Method <com.javabrahman.badshop.billing.BillingService.invoiceAmount(java.lang.String)> calls method <com.javabrahman.badshop.pricing.PricingService.price(java.lang.String)> in (BillingService.java:20)
- Method <com.javabrahman.badshop.billing.BillingService.invoiceAmount(java.lang.String)> calls method <com.javabrahman.badshop.pricing.internal.DiscountTable.discountPercent(java.lang.String)> in (BillingService.java:20)
2. Dependencies of Slice pricing
- Constructor <com.javabrahman.badshop.pricing.PricingService.<init>(com.javabrahman.badshop.billing.BillingService)> has parameter of type <com.javabrahman.badshop.billing.BillingService> in (PricingService.java:0)
- Field <com.javabrahman.badshop.pricing.PricingService.billing> has type <com.javabrahman.badshop.billing.BillingService> in (PricingService.java:0)
- Method <com.javabrahman.badshop.pricing.PricingService.price(java.lang.String)> calls method <com.javabrahman.badshop.billing.BillingService.hasUnpaidInvoices()> in (PricingService.java:17)
- Module 'billing' depends on non-exposed type com.javabrahman.badshop.pricing.internal.DiscountTable within module 'pricing'!
BillingService declares constructor BillingService(PricingService, DiscountTable) in (BillingService.java:0)
- Module 'billing' depends on non-exposed type com.javabrahman.badshop.pricing.internal.DiscountTable within module 'pricing'!
Method <com.javabrahman.badshop.billing.BillingService.invoiceAmount(java.lang.String)> calls method <com.javabrahman.badshop.pricing.internal.DiscountTable.discountPercent(java.lang.String)> in (BillingService.java:20)
- Module 'billing' depends on non-exposed type com.javabrahman.badshop.pricing.internal.DiscountTable within module 'pricing'!
Field <com.javabrahman.badshop.billing.BillingService.discounts> has type <com.javabrahman.badshop.pricing.internal.DiscountTable> in (BillingService.java:0)
- Module 'billing' depends on non-exposed type com.javabrahman.badshop.pricing.internal.DiscountTable within module 'pricing'!
Constructor <com.javabrahman.badshop.billing.BillingService.<init>(com.javabrahman.badshop.pricing.PricingService, com.javabrahman.badshop.pricing.internal.DiscountTable)> has parameter of type <com.javabrahman.badshop.pricing.internal.DiscountTable> in (BillingService.java:0)
- This code compiles without a warning. For Java, both things it does are legal.
verify()reports two kinds of violation. - A cycle - "Cycle detected: Slice billing -> Slice pricing -> Slice billing".
BillingServiceusesPricingService, andPricingServiceusesBillingService. Two modules that depend on each other are in effect one module: neither can be understood, tested or extracted without the other. The report lists every line of code that contributes to the cycle. - Access to internals - "Module 'billing' depends on non-exposed type ...pricing.internal.DiscountTable within module 'pricing'".
BillingServicereaches past the API of the pricing module into itsinternalpackage. It is reported once for each place where the type is used: the constructor, its parameter, the field and the method call.
A cycle is usually removed in one of two ways: by moving the shared piece into the module where it belongs, or by replacing one of the two calls with an event, as the next section does.
Events between modules
When an order is placed, the stock has to be reduced and the customer told. The order module could call an inventory service and a notification service. But then it would depend on both, and each new reaction to an order would mean changing the order module again. Instead, the order module publishesOrderPlaced and knows nothing more; the other modules listen.
inventory/InventoryListener.java
//InventoryListener.java
package com.javabrahman.shop.inventory;
import com.javabrahman.shop.order.OrderPlaced;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Component;
@Component
class InventoryListener {
private final JdbcClient jdbc;
InventoryListener(JdbcClient jdbc) {
this.jdbc = jdbc;
}
@ApplicationModuleListener
void on(OrderPlaced event) {
jdbc.sql("update stock set available = available - ? where isbn = ?").params(event.quantity(), event.isbn()).update();
System.out.println("inventory: stock reduced for order " + event.orderId() + " [" + Thread.currentThread().getName() + "]");
}
}
notification/MailGateway.java
//MailGateway.java
package com.javabrahman.shop.notification;
import org.springframework.stereotype.Component;
@Component
public class MailGateway {
private volatile boolean available = true;
public void setAvailable(boolean available) {
this.available = available;
}
void send(String text) {
if (!available) {
System.out.println("notification: mail NOT sent - mail server not reachable [" + Thread.currentThread().getName() + "]");
throw new IllegalStateException("mail server not reachable");
}
System.out.println("notification: mail sent - " + text + " [" + Thread.currentThread().getName() + "]");
}
}
notification/NotificationListener.java
//NotificationListener.java
package com.javabrahman.shop.notification;
import com.javabrahman.shop.order.OrderPlaced;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Component;
@Component
class NotificationListener {
private final MailGateway mail;
NotificationListener(MailGateway mail) {
this.mail = mail;
}
@ApplicationModuleListener
void on(OrderPlaced event) {
mail.send("your order " + event.orderId() + " is confirmed");
}
}
@ApplicationModuleListeneris a shortcut for three annotations that belong together when one module reacts to another:@TransactionalEventListener, so the listener runs only after the publishing transaction has committed; @AsyncClick to read tutorial: Async Processing in Spring with @Async and Virtual Threads, so it runs on another thread and the publisher does not wait; and@TransactionalwithREQUIRES_NEW, so it runs in a transaction of its own.- The effect is that a failure in the notification module cannot roll back or delay an order. The price is that the reactions happen a moment later, and may fail on their own.
MailGatewaystands in for a mail server. It only prints, and it can be switched to "not reachable" for the next example.
The event publication registry
"May fail on their own" is a real problem. With plain Spring events, if the listener throws - or the application stops just after the order's commit - the event is gone. The order exists and the mail is never sent. This is the dual-write problem of the transactional outbox tutorialClick to read tutorial: Transactional Outbox Pattern with Spring, inside one application. Spring Modulith solves it the same way, with a table: the event publication registry.
It is switched on by having spring-modulith-starter-jdbc on the classpath (there are starters for JPA, MongoDB and Neo4j as well). One property lets it create its table.
src/main/resources/application.properties
spring.main.banner-mode=off
logging.level.root=WARN
spring.modulith.events.jdbc.schema-initialization.enabled=true
logging.level.org.springframework.aop.interceptor=OFF
ShopRunner.java
//ShopRunner.java
package com.javabrahman.shop;
import com.javabrahman.shop.notification.MailGateway;
import com.javabrahman.shop.order.OrderService;
import org.springframework.boot.CommandLineRunner;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.modulith.events.IncompleteEventPublications;
import org.springframework.stereotype.Component;
@Component
class ShopRunner implements CommandLineRunner {
private final OrderService orders;
private final MailGateway mail;
private final IncompleteEventPublications incomplete;
private final JdbcClient jdbc;
ShopRunner(OrderService orders, MailGateway mail, IncompleteEventPublications incomplete, JdbcClient jdbc) {
this.orders = orders;
this.mail = mail;
this.incomplete = incomplete;
this.jdbc = jdbc;
}
@Override
public void run(String... args) throws Exception {
System.out.println("--- order 1");
orders.place(1, "978-1", 2);
Thread.sleep(1000);
publications();
System.out.println("--- order 2, while the mail server is down");
mail.setAvailable(false);
orders.place(2, "978-1", 1);
Thread.sleep(1000);
publications();
System.out.println("--- the mail server is back: resubmit what is incomplete");
mail.setAvailable(true);
incomplete.resubmitIncompletePublications(publication -> true);
Thread.sleep(1000);
publications();
}
private void publications() {
jdbc.sql("select listener_id, serialized_event, status from event_publication order by publication_date, listener_id")
.query((row, n) -> {
String listener = row.getString(1).replaceAll(".*\\.(\\w+\\.\\w+)\\.on\\(.*", "$1");
return " " + listener + " " + row.getString(2) + " " + row.getString(3);
}).list().forEach(System.out::println);
}
}
--- order 1
order: order 1 saved, total 9000 cents [main]
notification: mail sent - your order 1 is confirmed [task-2]
inventory: stock reduced for order 1 [task-1]
inventory.InventoryListener {"orderId":1,"isbn":"978-1","quantity":2} COMPLETED
notification.NotificationListener {"orderId":1,"isbn":"978-1","quantity":2} COMPLETED
--- order 2, while the mail server is down
order: order 2 saved, total 4500 cents [main]
notification: mail NOT sent - mail server not reachable [task-4]
inventory: stock reduced for order 2 [task-3]
inventory.InventoryListener {"orderId":1,"isbn":"978-1","quantity":2} COMPLETED
notification.NotificationListener {"orderId":1,"isbn":"978-1","quantity":2} COMPLETED
inventory.InventoryListener {"orderId":2,"isbn":"978-1","quantity":1} COMPLETED
notification.NotificationListener {"orderId":2,"isbn":"978-1","quantity":1} FAILED
--- the mail server is back: resubmit what is incomplete
notification: mail sent - your order 2 is confirmed [task-5]
inventory.InventoryListener {"orderId":1,"isbn":"978-1","quantity":2} COMPLETED
notification.NotificationListener {"orderId":1,"isbn":"978-1","quantity":2} COMPLETED
inventory.InventoryListener {"orderId":2,"isbn":"978-1","quantity":1} COMPLETED
notification.NotificationListener {"orderId":2,"isbn":"978-1","quantity":1} COMPLETED
- Order 1 - the order is saved on the
mainthread. The two listeners then run on the threadstask-1andtask-2, after the commit; which of the two prints first can differ from run to run. The registry has two rows for the one event, one per listener, each with the event as JSON, and both areCOMPLETED. - Order 2 - the order is saved and the stock is reduced. The mail cannot be sent, and the notification listener throws. Neither the order nor the inventory is affected. The registry shows exactly what is outstanding: the notification's row for order 2 is
FAILED. - Resubmission -
IncompleteEventPublications.resubmitIncompletePublications()hands every incomplete publication to its listener again. Only the notification listener for order 2 runs; the mail goes out and the row becomesCOMPLETED. The inventory listener is not called a second time.
spring.modulith.events.republish-outstanding-events-on-restart=true resubmits incomplete publications when the application starts. As with any such mechanism, a listener may then run twice for one event - once before the crash and once after - so listeners should be idempotent, as described in the outbox tutorial.
Documentation from the code
Because Spring Modulith knows the modules and their relationships, it can also draw them. TheDocumenter from spring-modulith-docs writes a PlantUML component diagram and an overview of each module.
DocumentModules.java
//DocumentModules.java
package com.javabrahman.shop;
import org.springframework.modulith.core.ApplicationModules;
import org.springframework.modulith.docs.Documenter;
public class DocumentModules {
public static void main(String[] args) {
new Documenter(ApplicationModules.of(ShopApp.class))
.writeModulesAsPlantUml()
.writeModuleCanvases();
System.out.println("documentation written");
}
}
components.puml module-catalog.adoc module-inventory.adoc module-notification.adoc module-order.adoc Rel(ShopApp.ShopApp.Order, ShopApp.ShopApp.Catalog, "uses", $techn="", $tags="", $link="") Rel(ShopApp.ShopApp.Inventory, ShopApp.ShopApp.Order, "listens to", $techn="", $tags="", $link="") Rel(ShopApp.ShopApp.Notification, ShopApp.ShopApp.Order, "listens to", $techn="", $tags="", $link="")
- The files are written to
build/spring-modulith-docshere, and totarget/spring-modulith-docsin a Maven project:components.pumland one.adocfile per module. - The three
Rellines of the diagram are the relationships that Spring Modulith found in the code: the order module uses the catalog module, and the notification and inventory modules listen to the order module. Nobody wrote this down; it is read from the classes, so it cannot be out of date.
More that Spring Modulith offers
- Explicitly allowed dependencies - a
package-info.javain a module's base package can restrict what the module may use:@ApplicationModule(allowedDependencies = "catalog")on the order module makes any other dependency a violation. - Named interfaces - a module whose API does not fit into one package can expose more with
@NamedInterfaceon a sub-package. - Module tests -
@ApplicationModuleTeststarts only one module and the modules it depends on, instead of the whole application, like the slice testsClick to read tutorial: Spring Boot Slice Tests: @WebMvcTest, @DataJpaTest and More of Spring Boot. Events can be checked with theScenarioAPI. - Event externalization - an event annotated with
@Externalizedis also sent to Kafka, RabbitMQ or another broker, through the event publication registry. This is a ready-made transactional outbox. - Observability - calls between modules can appear as spans in tracesClick to read tutorial: Distributed Tracing with Micrometer and OpenTelemetry, and an actuator endpoint shows the module structure.
Summary
In this tutorial we built a modular monolith with Spring Modulith. The sub-packages of the application package became modules, with the base package of each as its API and its sub-packages as internals.ApplicationModules.verify() accepted that structure, and rejected a second application for a cycle between two modules and for access to an internal type - both of which Java had compiled without complaint. The modules were then connected by an event: @ApplicationModuleListener ran each listener after the commit, on its own thread and in its own transaction, and the event publication registry recorded a row per listener, kept the row of a failed listener, and let it be resubmitted. Finally the Documenter generated a component diagram from the code.Spring Messaging and Integration Tutorials
Spring Batch Fundamentals: Jobs, Steps and ChunksClick to read tutorial: Spring Batch Fundamentals: Jobs, Steps and Chunks Spring Boot Actuator: Health, Metrics and InfoClick to read tutorial: Spring Boot Actuator: Health, Metrics and Info Transactional Outbox Pattern with SpringClick to read tutorial: Transactional Outbox Pattern with Spring
Spring Batch Fundamentals: Jobs, Steps and ChunksClick to read tutorial: Spring Batch Fundamentals: Jobs, Steps and Chunks Spring Boot Actuator: Health, Metrics and InfoClick to read tutorial: Spring Boot Actuator: Health, Metrics and Info Transactional Outbox Pattern with SpringClick to read tutorial: Transactional Outbox Pattern with Spring