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

Three ways to structure an application - a plain monolith is one deployment in which every class may call every other; a modular monolith is still one deployment and one database, but is divided into modules that talk only through their public interfaces and events; microservices are separate deployments with separate databases that talk over the network 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 @SpringBootApplication class 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.
How Spring Modulith reads the package structure - below the application package com.javabrahman.shop, the packages catalog, order, inventory and notification are the four modules; in each module the types of the base package such as CatalogService and OrderService are the API, and the types of sub-packages such as internal.PriceTable and internal.OrderRepository are hidden from other modules 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);
Explanation of the code
  • The catalog module offers CatalogService. Its PriceTable is in the sub-package internal. It has to be a public class, because CatalogService in another package uses it, but for Spring Modulith it is hidden.
  • The order module offers OrderService and the event OrderPlaced. OrderService calls CatalogService - a call from one module to the API of another, which is allowed.
  • After saving the order, place() publishes OrderPlaced as 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");
  }
}
 OUTPUT of verifying the modules
# 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
Explanation of the output
  • 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 an o one that is internal. CatalogService is API; internal.PriceTable is not. InventoryListener and NotificationListener are in their base packages, but they are not public classes, so they are internal too.
  • verify() found nothing to object to.
The program is a 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;
  }
}
 OUTPUT of verifying the second application
- 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)
Explanation of the output
  • 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". BillingService uses PricingService, and PricingService uses BillingService. 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'". BillingService reaches past the API of the pricing module into its internal package. It is reported once for each place where the type is used: the constructor, its parameter, the field and the method call.
What Spring Modulith verifies - allowed: a module uses a type from the base package of another module, or listens to its events; rejected: a module uses a type from an internal sub-package of another module; rejected: two modules depend on each other and form a cycle 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 publishes OrderPlaced 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");
  }
}
Explanation of the code
  • @ApplicationModuleListener is 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 @Transactional with REQUIRES_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.
  • MailGateway stands 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. The event publication registry - when OrderService publishes OrderPlaced, Spring Modulith writes one row per listener into the EVENT_PUBLICATION table in the same transaction as the order; after the commit each listener runs on its own thread and in its own transaction, and its row is marked completed when it succeeds; the row of a listener that failed stays incomplete and can be resubmitted 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
The last line only keeps the output short: it hides the stack trace that Spring logs when an asynchronous listener throws. The following runner places two orders, the second while the mail server is down, and prints the registry's table after each step.
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);
  }
}
 OUTPUT of the application
--- 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
Explanation of the output
  • Order 1 - the order is saved on the main thread. The two listeners then run on the threads task-1 and task-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 are COMPLETED.
  • 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 becomes COMPLETED. The inventory listener is not called a second time.
The rows are written in the transaction that publishes the event, so they also survive a crash. For that case the property 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. The Documenter 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");
  }
}
 The generated files, and the relationships in components.puml
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="")
Explanation of the output
  • The files are written to build/spring-modulith-docs here, and to target/spring-modulith-docs in a Maven project: components.puml and one .adoc file per module.
  • The three Rel lines 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.java in 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 @NamedInterface on a sub-package.
  • Module tests - @ApplicationModuleTest starts 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 the Scenario API.
  • Event externalization - an event annotated with @Externalized is 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.
A module with a small API, no access to its internals from outside, and events instead of calls is also most of the way to being a service. If the shop's notification module one day needs to scale or be deployed on its own, its boundary is already there: the events are externalized to a broker, and the module moves out. That is a far safer route to microservices than starting with them.

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.