Build a Modular Bookstore with Spring Modulith
Build a modular monolith with enforced module boundaries and domain events.
Introduction
30 Second Summary
As applications grow, a single codebase can quickly become a tangle of dependencies where changing one feature breaks three others. The modular monolith pattern gives you the isolation of microservices without the deployment complexity.
In this project, you will build a bookstore application structured as a modular monolith using Spring Modulith. Three isolated modules communicate exclusively through domain events, and architecture tests guarantee no one can sneak in a direct dependency.
What You'll Build
A Spring Boot application with three enforced application modules (order, inventory, notification) that communicate through domain events, backed by PostgreSQL running in Docker for event persistence.
By the end of this project, you'll have:
- A running modular monolith with three isolated modules that never directly call each other.
- Event-driven communication between modules using Spring's @ApplicationModuleListener and an Event Publication Registry that guarantees no events are lost.
- Architecture verification tests that catch illegal cross-module dependencies and auto-generate PlantUML module diagrams.
- Secret Mission: Lock down module boundaries with explicit dependency constraints and prove violations are caught.
Are there any prerequisites?
You should be familiar with building REST APIs in Spring Boot and have a basic understanding of Maven. You also need Docker installed on your machine to run PostgreSQL.
Set Up the Project
Before you can build isolated modules and wire them together with events, you need a solid foundation. A Spring Boot project with the right dependencies gives you the web layer, database access, and module enforcement tools all in one place.
In this step, you will generate a project from Spring Initializr, add Spring Modulith dependencies for module boundary enforcement, and spin up a PostgreSQL database in Docker so your app has somewhere to persist data and events.
In this step, get ready to:
- Generate a Spring Boot project with the base dependencies.
- Add the Spring Modulith BOM and starter libraries to your build.
- Run PostgreSQL in Docker and verify the application starts.
Generate the Spring Boot project
Spring Initializr is a web tool that scaffolds a ready-to-run Spring Boot project with the dependencies you choose. You will configure it to use Java 21 and include the four base starters the bookstore needs.
- Open start.spring.io in your browser.
- Leave Project set to Maven and Language set to Java.
- In the Spring Boot version dropdown, select 3.5.15.
- Under Project Metadata, set Group to com.example.
- Set Artifact to bookstore.
- Change Description to Modular Bookstore with Spring Modulith.
- Set Java to 21.
- Click ADD DEPENDENCIES and search for Spring Web. Select it.
- Search for Spring Data JPA and select it.
- Search for PostgreSQL Driver and select it.
- Search for Lombok and select it.
- Click GENERATE to download a ZIP file.
- Extract the ZIP to your Desktop.
What does Spring Initializr generate?
The downloaded ZIP contains a complete Maven project with a pom.xml listing your chosen dependencies, a BookstoreApplication.java main class annotated with @SpringBootApplication, and placeholder config files.
You will modify several of these files in the following substeps to add Spring Modulith support and connect to PostgreSQL.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type IntelliJ IDEA and press Enter to open it.
- Click Open on the welcome screen.
- Navigate to the extracted bookstore folder on your Desktop.
- Select the bookstore folder and click OK.
- If IntelliJ prompts you to trust the project, click Trust Project.
Spring Modulith uses asynchronous event listeners under the hood. To enable this, you need to add the @EnableAsync annotation to the main application class.
- In IntelliJ's Project panel on the left, expand src/main/java/com/example/bookstore.
- Open BookstoreApplication.java.
- Replace the entire contents of the file with the following code:
package com.example.bookstore;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
@SpringBootApplication
@EnableAsync
public class BookstoreApplication {
public static void main(String[] args) {
SpringApplication.run(BookstoreApplication.class, args);
}
}
Why @EnableAsync?
Spring Modulith's @ApplicationModuleListener annotation is a shortcut that combines @Async, @Transactional, and @TransactionalEventListener.
Without @EnableAsync on the application class, the async behavior is silently ignored. Adding it now means event listeners will work correctly when you wire them up later.
- Save BookstoreApplication.java with Cmd+S (macOS) or Ctrl+S (Windows).
Seeing a red underline on @EnableAsync?
Make sure the import line import org.springframework.scheduling.annotation.EnableAsync; is present at the top of the file. If IntelliJ has not finished indexing yet, wait a moment and the error should resolve on its own.
Help me fix this
Add Spring Modulith dependencies
The project from Spring Initializr includes base starters for web and JPA, but it does not know about Spring Modulith yet. You need to import the Spring Modulith Bill of Materials (BOM) and add two starter libraries.
- Open pom.xml in IntelliJ (it is at the root of your project).
- Find the <properties> section. Add the Spring Modulith version property so it looks like this:
<properties>
<java.version>21</java.version>
<spring-modulith.version>1.4.12</spring-modulith.version>
</properties>
- Immediately after the closing </properties> tag, add this <dependencyManagement> section to import the BOM:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-bom</artifactId>
<version>${spring-modulith.version}</version>
<scope>import</scope>
<type>pom</type>
</dependency>
</dependencies>
</dependencyManagement>
What is a BOM?
A Bill of Materials (BOM) is a special POM that locks compatible versions for a family of libraries. By importing the Spring Modulith BOM, you can add any spring-modulith-* dependency without specifying a version. Maven resolves the version from the BOM automatically.
- Inside the <dependencies> section, add these three new dependencies after the existing entries:
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
What do these dependencies provide?
spring-modulith-starter-jpa enables the Event Publication Registry. It stores every inter-module event in a database table so no event is ever lost.
spring-modulith-starter-test includes tools for architecture verification and module-scoped integration tests. H2 is an in-memory database used only during tests for speed.
- Save pom.xml.
- If IntelliJ shows a popup asking to reload Maven changes, click Load Maven Changes.
Now create a separate configuration file so tests use H2 instead of PostgreSQL.
- In IntelliJ's Project panel, expand src/test/resources.
- Right-click the resources folder and select New then File.
- Name it application.properties and press Enter.
- Paste the following content into the new file:
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=create-drop
Why a separate test properties file?
Tests run in isolation. They should not depend on Docker or a running PostgreSQL instance. By overriding the datasource to H2 in-memory, tests start instantly and clean up after themselves with create-drop.
- Save src/test/resources/application.properties.
✔️ Awesome, I've got everything!
Great. Double check you have saved both pom.xml and src/test/resources/application.properties.
ⓧ I'd like to double check the full code
Here is the complete pom.xml you should have at this point:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.15</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>bookstore</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>bookstore</name>
<description>Modular Bookstore with Spring Modulith</description>
<properties>
<java.version>21</java.version>
<spring-modulith.version>1.4.12</spring-modulith.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-bom</artifactId>
<version>${spring-modulith.version}</version>
<scope>import</scope>
<type>pom</type>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
And here is src/test/resources/application.properties:
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=create-drop
Run PostgreSQL in Docker and verify the app starts
Your application needs a real database to persist orders, inventory, and events. You will use Docker Compose to run PostgreSQL locally with zero manual configuration.
- In IntelliJ's Project panel, right-click the project root (bookstore) and select New then File.
- Name it docker-compose.yml and press Enter.
- Paste the following content:
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_DB: bookstore
POSTGRES_USER: bookstore
POSTGRES_PASSWORD: bookstore
ports:
- "5432:5432"
volumes:
- bookstore-data:/var/lib/postgresql/data
volumes:
bookstore-data:
What does this Docker Compose file do?
It defines a single PostgreSQL 17 container with a database called bookstore, exposed on port 5432. The volumes section persists data between container restarts so you do not lose your database when you stop Docker.
- Save docker-compose.yml.
Next, configure the main application to connect to this PostgreSQL instance.
- Open src/main/resources/application.properties in IntelliJ.
- Replace its contents with:
spring.application.name=bookstore
spring.datasource.url=jdbc:postgresql://localhost:5432/bookstore
spring.datasource.username=bookstore
spring.datasource.password=bookstore
spring.jpa.hibernate.ddl-auto=update
spring.jpa.open-in-view=false
What do these properties mean?
spring.datasource.url points the app at the Docker PostgreSQL instance. spring.jpa.hibernate.ddl-auto=update tells Hibernate to auto-create or modify database tables to match your entities.
spring.jpa.open-in-view=false disables a legacy pattern that can cause lazy-loading issues in production. This is a best practice for any Spring Boot application.
- Save src/main/resources/application.properties.
Now start the database container and verify everything connects.
- Open a terminal in IntelliJ by pressing Option+F12 (macOS) or Alt+F12 (Windows).
- Start the PostgreSQL container by running:
docker compose up -d
- Verify the container is running by running:
docker ps
You should see a container named something like bookstore-postgres-1 with status Up.
- Start the Spring Boot application by running:
mvn spring-boot:run
You should see log output ending with a line like Started BookstoreApplication and no error stack traces.
- Press Ctrl+C in the terminal to stop the application.
Application fails to start?
If you see a connection refused error, make sure the Docker container is running with docker ps. If it is not listed, run docker compose up -d again from the project root where docker-compose.yml lives.
If you see a port conflict on 5432, another PostgreSQL instance may be running locally. Stop it before retrying.
Help me troubleshoot my Spring Boot startup error
Your foundation is in place. You have a Spring Boot project with Spring Modulith support, a running PostgreSQL database, and an application that starts cleanly. Next up, you will create the three application modules that form the heart of your bookstore.
Create the Module Structure
Your Spring Boot project is wired up and connected to PostgreSQL. Now it is time to give the application its modular shape.
In a Spring Modulith application, each business domain lives in its own package directly under the application root. These direct sub-packages are what Spring Modulith recognizes as application modules. You will create three of them: order, inventory, and notification.
In this step, get ready to:
- Build the order module with a JPA entity, repository, service, and REST controller.
- Build the inventory module with a JPA entity, repository, and REST controller.
- Build the notification module as a minimal service class.
Create the order module
The order module handles placing book orders. It needs an entity to persist orders, a repository for database access, a service to hold business logic, and a controller to expose a REST endpoint.
Why direct sub-packages?
Spring Modulith auto-detects application modules by scanning for direct sub-packages of your main application package. Since BookstoreApplication.java lives in com.example.bookstore, any package like com.example.bookstore.order automatically becomes a module.
No annotations, no configuration files. The package structure IS the architecture declaration.
- In IntelliJ, right-click the com.example.bookstore package in the src/main/java directory.
- Select New then Package.
- Enter order as the package name.
- Create a new Java class inside com.example.bookstore.order called Order.java with the following code:
package com.example.bookstore.order;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.util.UUID;
@Entity
@Table(name = "orders")
@Getter
@Setter
@NoArgsConstructor
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
private String customerName;
private String bookTitle;
private int quantity;
private String status;
public Order(String customerName, String bookTitle, int quantity) {
this.customerName = customerName;
this.bookTitle = bookTitle;
this.quantity = quantity;
this.status = "COMPLETED";
}
}
What does this code do?
- @Entity and @Table(name = "orders") mark this class as a JPA entity mapped to the orders table. The table cannot be named order because that is a reserved SQL keyword.
- @GeneratedValue(strategy = GenerationType.UUID) tells Hibernate to auto-generate a UUID primary key for each order.
- The constructor sets status to "COMPLETED" immediately. In a real app you would use a state machine, but this keeps the demo simple.
- Lombok annotations (@Getter, @Setter, @NoArgsConstructor) generate boilerplate code at compile time so the class stays concise.
- Create another class in the same package called OrderRepository.java:
package com.example.bookstore.order;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface OrderRepository extends JpaRepository<Order, UUID> {
}
- Create a Java record called OrderCompleted.java in the same order package. This defines the domain event that the order module will publish:
package com.example.bookstore.order;
import java.util.UUID;
public record OrderCompleted(UUID orderId, String bookTitle, int quantity) {
}
What is a domain event?
A domain event is a lightweight data object that says "something happened". OrderCompleted carries just enough information (the order ID, book title, and quantity) for other modules to react without needing a direct reference to the Order entity.
Java records are perfect for events because they are immutable and concise.
- Create the service class OrderManagement.java in the order package:
package com.example.bookstore.order;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class OrderManagement {
private final OrderRepository orders;
private final ApplicationEventPublisher events;
@Transactional
public Order placeOrder(String customerName, String bookTitle, int quantity) {
var order = new Order(customerName, bookTitle, quantity);
orders.save(order);
events.publishEvent(new OrderCompleted(order.getId(), order.getBookTitle(), order.getQuantity()));
return order;
}
}
What does this service do?
- ApplicationEventPublisher is Spring's built-in mechanism for publishing events within the application context. No external messaging system needed.
- After saving the order, publishEvent(new OrderCompleted(...)) fires the event. Any listener in another module can react to it.
- @Transactional ensures the order save and event publication happen atomically.
- Create the REST controller OrderController.java in the order package:
package com.example.bookstore.order;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderManagement orderManagement;
@PostMapping
public Order placeOrder(@RequestBody OrderRequest request) {
return orderManagement.placeOrder(request.customerName(), request.bookTitle(), request.quantity());
}
record OrderRequest(String customerName, String bookTitle, int quantity) {
}
}
What does this controller do?
- @RequestMapping("/orders") maps all requests under the /orders path to this controller.
- OrderRequest is a Java record defined inline as the request body shape. Spring automatically deserializes incoming JSON into this record.
- The controller delegates immediately to OrderManagement. Controllers should be thin. Business logic belongs in the service.
- Save all five files in the order package.
You won't see a visible change yet. The next two modules complete the structure before you run the app.
Seeing red import errors in IntelliJ?
IntelliJ sometimes needs to reload the Maven project after adding new files. Right-click pom.xml and select Maven then Reload Project. Lombok errors specifically may require enabling annotation processing under Settings then Build, Execution, Deployment then Compiler then Annotation Processors.
Help me fix red import errors in my Spring Boot order module.
Create the inventory module
The inventory module tracks book stock levels. It follows the same pattern as the order module but with a custom repository query method.
- Right-click com.example.bookstore and create a new package called inventory.
- Create InventoryItem.java inside the inventory package:
package com.example.bookstore.inventory;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.util.UUID;
@Entity
@Table(name = "inventory_items")
@Getter
@Setter
@NoArgsConstructor
public class InventoryItem {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
private String bookTitle;
private int stock;
public InventoryItem(String bookTitle, int stock) {
this.bookTitle = bookTitle;
this.stock = stock;
}
}
What does this entity represent?
Each InventoryItem row represents one book title and how many copies are in stock. The stock field will be decremented later when an order event arrives.
- Create InventoryRepository.java in the same package:
package com.example.bookstore.inventory;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface InventoryRepository extends JpaRepository<InventoryItem, UUID> {
Optional<InventoryItem> findByBookTitle(String bookTitle);
}
What is findByBookTitle?
Spring Data JPA generates the SQL query automatically from the method name. findByBookTitle(String bookTitle) translates to SELECT * FROM inventory_items WHERE book_title = ?. No implementation code needed.
- Create InventoryController.java in the inventory package:
package com.example.bookstore.inventory;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/inventory")
@RequiredArgsConstructor
public class InventoryController {
private final InventoryRepository inventory;
@GetMapping
public List<InventoryItem> getAll() {
return inventory.findAll();
}
}
What does this controller do?
This exposes a GET endpoint at /inventory that returns all inventory items as JSON. It gives you a quick way to check stock levels.
- Save all three files in the inventory package.
Create the notification module
The notification module is intentionally minimal. It demonstrates that an application module can be as simple as a single class with no entities or repositories.
- Right-click com.example.bookstore and create a new package called notification.
- Create NotificationService.java inside the notification package:
package com.example.bookstore.notification;
import com.example.bookstore.order.OrderCompleted;
import lombok.extern.slf4j.Slf4j;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Component;
@Component
@Slf4j
public class NotificationService {
@ApplicationModuleListener
void on(OrderCompleted event) {
log.info("Notification sent: Order {} completed for book '{}' (quantity: {})",
event.orderId(), event.bookTitle(), event.quantity());
}
}
What does @ApplicationModuleListener do?
@ApplicationModuleListener is Spring Modulith shorthand for combining @Async, @Transactional, and @TransactionalEventListener into one annotation. It tells Spring to call this method asynchronously whenever an OrderCompleted event is published.
Notice the import: com.example.bookstore.order.OrderCompleted. The notification module depends on the order module's public API (the event record), but it never touches the Order entity or OrderRepository directly. This is a clean module boundary.
- Save NotificationService.java.
All three module packages are now in place. Time to verify everything compiles and the endpoints work.
- Stop the application if it is still running from Step 1. Then restart it by running:
mvn spring-boot:run
- Test the order endpoint by running this in a separate terminal:
curl -X POST http://localhost:8080/orders -H "Content-Type: application/json" -d '{"customerName":"Alice","bookTitle":"Spring in Action","quantity":2}'
You should see a JSON response containing an id, customerName, bookTitle, quantity, and status set to COMPLETED.
- Test the inventory endpoint by running:
curl http://localhost:8080/inventory
You should see an empty JSON array [] since no inventory items have been added yet. That is expected.
Application fails to start?
- Check that PostgreSQL is still running by executing docker compose ps in the project directory. If the container is stopped, run docker compose up -d to restart it.
- Look for compilation errors in the terminal output. A missing import or typo in a class name will prevent the app from starting.
- Ensure the order, inventory, and notification packages are direct children of com.example.bookstore, not nested deeper.
Help me fix my Spring Boot application startup error.
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files and the application is running without errors.
ⓧ I'd like to double check the full code
Here is the complete code for each file in the order module:
package com.example.bookstore.order;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.util.UUID;
@Entity
@Table(name = "orders")
@Getter
@Setter
@NoArgsConstructor
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
private String customerName;
private String bookTitle;
private int quantity;
private String status;
public Order(String customerName, String bookTitle, int quantity) {
this.customerName = customerName;
this.bookTitle = bookTitle;
this.quantity = quantity;
this.status = "COMPLETED";
}
}
package com.example.bookstore.order;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface OrderRepository extends JpaRepository<Order, UUID> {
}
package com.example.bookstore.order;
import java.util.UUID;
public record OrderCompleted(UUID orderId, String bookTitle, int quantity) {
}
package com.example.bookstore.order;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class OrderManagement {
private final OrderRepository orders;
private final ApplicationEventPublisher events;
@Transactional
public Order placeOrder(String customerName, String bookTitle, int quantity) {
var order = new Order(customerName, bookTitle, quantity);
orders.save(order);
events.publishEvent(new OrderCompleted(order.getId(), order.getBookTitle(), order.getQuantity()));
return order;
}
}
package com.example.bookstore.order;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderManagement orderManagement;
@PostMapping
public Order placeOrder(@RequestBody OrderRequest request) {
return orderManagement.placeOrder(request.customerName(), request.bookTitle(), request.quantity());
}
record OrderRequest(String customerName, String bookTitle, int quantity) {
}
}
Here is the complete code for the inventory module:
package com.example.bookstore.inventory;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.util.UUID;
@Entity
@Table(name = "inventory_items")
@Getter
@Setter
@NoArgsConstructor
public class InventoryItem {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
private String bookTitle;
private int stock;
public InventoryItem(String bookTitle, int stock) {
this.bookTitle = bookTitle;
this.stock = stock;
}
}
package com.example.bookstore.inventory;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface InventoryRepository extends JpaRepository<InventoryItem, UUID> {
Optional<InventoryItem> findByBookTitle(String bookTitle);
}
package com.example.bookstore.inventory;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/inventory")
@RequiredArgsConstructor
public class InventoryController {
private final InventoryRepository inventory;
@GetMapping
public List<InventoryItem> getAll() {
return inventory.findAll();
}
}
And the notification module:
package com.example.bookstore.notification;
import com.example.bookstore.order.OrderCompleted;
import lombok.extern.slf4j.Slf4j;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Component;
@Component
@Slf4j
public class NotificationService {
@ApplicationModuleListener
void on(OrderCompleted event) {
log.info("Notification sent: Order {} completed for book '{}' (quantity: {})",
event.orderId(), event.bookTitle(), event.quantity());
}
}
Your three modules are in place and the endpoints are live. Next up, you will wire the modules together with domain events so placing an order automatically updates inventory and sends a notification.
Connect Modules with Domain Events
Your three modules exist, but they operate in isolation. Placing an order does nothing to inventory or notifications. In a traditional monolith, you would inject the inventory service directly into the order service.
That direct coupling is exactly what a modular monolith avoids. Instead, you will use domain events to let the order module broadcast what happened without knowing who is listening.
In this step, get ready to:
- Create an OrderCompleted domain event record in the order module.
- Wire up event listeners in the inventory and notification modules using @ApplicationModuleListener.
- Test the full event-driven flow end-to-end and inspect the Event Publication Registry in PostgreSQL.
Create the OrderCompleted domain event
A domain event is a simple data carrier that describes something that happened. In Java, a record is the perfect fit because it is immutable and concise.
- In IntelliJ, right-click the com.example.bookstore.order package in the Project panel.
- Select New, then Java Class.
- Choose Record as the type, name it OrderCompleted, and press Enter.
- Replace the generated content with this code:
package com.example.bookstore.order;
import java.util.UUID;
// Domain event representing a completed order
public record OrderCompleted(UUID orderId, String bookTitle, int quantity) {
}
What does this code do?
This record defines the contract between your order module and any module that cares about completed orders. It carries three fields: the order's ID, the book title, and the quantity ordered.
Because it is a record, Java auto-generates the constructor, accessor methods, equals(), hashCode(), and toString(). No boilerplate needed.
Now update OrderManagement to publish this event after saving an order.
- Open OrderManagement.java in the order package.
- Replace the entire file content with the following:
package com.example.bookstore.order;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class OrderManagement {
private final OrderRepository orders;
private final ApplicationEventPublisher events;
@Transactional
public Order placeOrder(String customerName, String bookTitle, int quantity) {
var order = new Order(customerName, bookTitle, quantity);
orders.save(order);
// Publish a domain event so other modules can react
events.publishEvent(new OrderCompleted(order.getId(), order.getBookTitle(), order.getQuantity()));
return order;
}
}
What does this code do?
The ApplicationEventPublisher is Spring's built-in event bus. After saving the order, the service publishes an OrderCompleted event carrying the order data.
The @Transactional annotation is critical here. Spring Modulith's Event Publication Registry intercepts this event inside the transaction and persists it to the event_publication table. If the transaction rolls back, the event is never delivered.
- Save both OrderCompleted.java and OrderManagement.java.
Seeing a compile error?
If IntelliJ shows a red underline on OrderCompleted in OrderManagement, make sure you created the record in the same com.example.bookstore.order package. The two files must share the same package declaration.
help me fix the compile error in OrderManagement
Add event listeners in the inventory and notification modules
With the event being published, you need listeners that react to it. Spring Modulith provides @ApplicationModuleListener for this purpose. It combines async execution, a new transaction, and transactional event listening into a single annotation.
- Right-click the com.example.bookstore.inventory package.
- Select New, then Java Class.
- Name it InventoryManagement and press Enter.
- Replace the generated content with this code:
package com.example.bookstore.inventory;
import com.example.bookstore.order.OrderCompleted;
import lombok.RequiredArgsConstructor;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class InventoryManagement {
private final InventoryRepository inventory;
// React to OrderCompleted events by decrementing stock
@ApplicationModuleListener
void on(OrderCompleted event) {
inventory.findByBookTitle(event.bookTitle()).ifPresent(item -> {
item.setStock(item.getStock() - event.quantity());
inventory.save(item);
});
}
}
What does this code do?
- The @ApplicationModuleListener annotation tells Spring Modulith to invoke this method asynchronously in its own transaction whenever an OrderCompleted event is published.
- The method looks up the inventory item by book title and decrements its stock by the ordered quantity.
- Notice the inventory module imports OrderCompleted from the order module's public API. This is allowed because OrderCompleted lives in the order module's root package and is therefore part of its public surface.
Why @ApplicationModuleListener instead of @EventListener?
A plain @EventListener runs synchronously inside the publisher's transaction. If the listener fails, the entire transaction rolls back.
@ApplicationModuleListener decouples the listener by running it asynchronously in a separate transaction. The Event Publication Registry guarantees delivery even if the listener temporarily fails.
Now update NotificationService in the notification module to also listen for the event.
- Open NotificationService.java in the com.example.bookstore.notification package.
- Replace the entire file content with the following:
package com.example.bookstore.notification;
import com.example.bookstore.order.OrderCompleted;
import lombok.extern.slf4j.Slf4j;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Component;
@Component
@Slf4j
public class NotificationService {
// Log a confirmation when an order is completed
@ApplicationModuleListener
void on(OrderCompleted event) {
log.info("Notification sent: Order {} completed for book '{}' (quantity: {})",
event.orderId(), event.bookTitle(), event.quantity());
}
}
What does this code do?
This listener simply logs a message confirming the order. In a production system, you would replace the log statement with an email or push notification.
Both InventoryManagement and NotificationService react to the same OrderCompleted event independently. Neither knows the other exists. This is the power of event-driven architecture within a modular monolith.
- Save InventoryManagement.java and NotificationService.java.
- Verify the project compiles by running:
mvn compile
Compile fails with 'cannot find symbol'?
- Check that InventoryManagement.java imports com.example.bookstore.order.OrderCompleted (not a wrong package path).
- Confirm OrderCompleted.java is in src/main/java/com/example/bookstore/order/ (direct sub-package of bookstore).
- Make sure @EnableAsync is present on your BookstoreApplication class. Without it, the @ApplicationModuleListener will not execute asynchronously.
help me debug my Spring Modulith compile error
Test the full event-driven flow
Time to see everything working together. You will start the app, seed some inventory data, place an order, and then verify that all three things happen: the order is saved, inventory decreases, and a notification is logged.
- Make sure your PostgreSQL container is running. If you stopped it earlier, start it again by running:
docker compose up -d
- Start the application by running:
mvn spring-boot:run
- Open a new terminal tab. First, seed an inventory item by sending a POST request directly to the database via psql:
docker exec -it bookstore-postgres-1 psql -U bookstore -d bookstore -c "INSERT INTO inventory_items (id, book_title, stock) VALUES (gen_random_uuid(), 'Spring in Action', 10);"
Why seed inventory manually?
Your app does not have an endpoint to create inventory items yet. Inserting directly into PostgreSQL gives you a known starting point (10 copies of 'Spring in Action') so you can verify the stock decreases after placing an order.
- Place an order by running this curl command:
curl -X POST http://localhost:8080/orders \
-H "Content-Type: application/json" \
-d '{"customerName": "Alice", "bookTitle": "Spring in Action", "quantity": 2}'
You should see a JSON response with the order details including an id and status of COMPLETED.
- Switch to the terminal running your app. Look for a log line containing:
Notification sent: Order ... completed for book 'Spring in Action' (quantity: 2)
- Verify the inventory decreased by checking the stock level:
curl http://localhost:8080/inventory
You should see the stock field for 'Spring in Action' is now 8 (down from 10).
- Finally, confirm the Event Publication Registry recorded the events by querying the event_publication table:
docker exec -it bookstore-postgres-1 psql -U bookstore -d bookstore -c "SELECT id, event_type, completion_date FROM event_publication;"
You should see two rows (one per listener) with a non-null completion_date, confirming both listeners processed the event successfully.
What is the Event Publication Registry?
The spring-modulith-starter-jpa dependency you added earlier auto-configures a JPA-backed Event Publication Registry. Every time an event is published inside a @Transactional method, Spring Modulith writes a row to event_publication for each registered listener.
When a listener completes successfully, the row's completion_date is set. If a listener fails or the app crashes, the row stays incomplete. On next startup, Spring Modulith can automatically redeliver those events. This guarantees no event is ever lost.
Event publication table is empty?
- Make sure spring-modulith-starter-jpa is in your pom.xml (not test-scoped). This starter activates the Event Publication Registry.
- Verify your application.properties has spring.jpa.hibernate.ddl-auto=update. Hibernate uses this setting to auto-create the event_publication table schema.
- Confirm you placed the order AFTER the app fully started (wait for the 'Started BookstoreApplication' log line).
help me debug why my event_publication table is empty
- Stop the application by pressing Ctrl+C in the terminal running mvn spring-boot:run.
✔️ Awesome, I've got everything!
Great. Double check you have saved all four files before moving on.
ⓧ I'd like to double check the full code
Here are the key files for this step. Compare them against yours.
package com.example.bookstore.order;
import java.util.UUID;
public record OrderCompleted(UUID orderId, String bookTitle, int quantity) {
}
package com.example.bookstore.order;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class OrderManagement {
private final OrderRepository orders;
private final ApplicationEventPublisher events;
@Transactional
public Order placeOrder(String customerName, String bookTitle, int quantity) {
var order = new Order(customerName, bookTitle, quantity);
orders.save(order);
events.publishEvent(new OrderCompleted(order.getId(), order.getBookTitle(), order.getQuantity()));
return order;
}
}
package com.example.bookstore.inventory;
import com.example.bookstore.order.OrderCompleted;
import lombok.RequiredArgsConstructor;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class InventoryManagement {
private final InventoryRepository inventory;
@ApplicationModuleListener
void on(OrderCompleted event) {
inventory.findByBookTitle(event.bookTitle()).ifPresent(item -> {
item.setStock(item.getStock() - event.quantity());
inventory.save(item);
});
}
}
package com.example.bookstore.notification;
import com.example.bookstore.order.OrderCompleted;
import lombok.extern.slf4j.Slf4j;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Component;
@Component
@Slf4j
public class NotificationService {
@ApplicationModuleListener
void on(OrderCompleted event) {
log.info("Notification sent: Order {} completed for book '{}' (quantity: {})",
event.orderId(), event.bookTitle(), event.quantity());
}
}
Your modules are now talking through events. Next up, you will write automated tests that verify these boundaries and generate architecture documentation.
Verify and Test Your Architecture
Your bookstore modules communicate through events, but how do you know no one accidentally breaks the boundary later? Without automated checks, a developer could import a class directly from another module and silently couple them together.
Spring Modulith provides architecture verification tests that scan your package structure and reject illegal dependencies at test time. In this step, you'll write those tests and generate visual diagrams of your module relationships.
In this step, get ready to:
- Write a modularity verification test that validates module boundaries.
- Create a module-level integration test that asserts event publication.
- Generate PlantUML diagrams documenting your module structure.
Write the architecture verification test
Spring Modulith uses ApplicationModules.verify() to scan your package structure and fail the test if any module accesses another module's internal classes. This catches boundary violations before they reach production.
- Create a new file called ModularityTests.java inside src/test/java/com/example/bookstore/.
- Add the following code:
package com.example.bookstore;
import org.junit.jupiter.api.Test;
import org.springframework.modulith.core.ApplicationModules;
class ModularityTests {
// Scan the application's package structure and detect all modules
ApplicationModules modules = ApplicationModules.of(BookstoreApplication.class);
@Test
void verifyModularStructure() {
// Fails if any module accesses another module's internals
modules.verify();
}
}
What does this code do?
ApplicationModules.of(BookstoreApplication.class) scans the root package and detects each direct sub-package (order, inventory, notification) as an application module.
The verify() method checks that no module reaches into the internal types of another module. If someone adds a direct import from inventory into notification, this test fails.
- Save ModularityTests.java.
- Run the verification test by executing this command in your terminal:
mvn test -Dtest=ModularityTests#verifyModularStructure
- You should see BUILD SUCCESS in the terminal output, confirming that your three modules respect each other's boundaries.
Test failing with a compilation error?
- Make sure ModularityTests.java is in src/test/java/com/example/bookstore/ (the root test package, not inside a module sub-package).
- Confirm your pom.xml includes the spring-modulith-starter-test dependency with test scope.
- Help me debug my ModularityTests verification failure.
Create a module integration test for the order module
The verification test confirms module boundaries are intact, but it doesn't prove that events actually get published when business logic runs. A module integration test boots only the order module in isolation and asserts that placing an order emits the correct event.
- Create a new file called OrderModuleIntegrationTests.java inside src/test/java/com/example/bookstore/order/.
- Add the following code:
package com.example.bookstore.order;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.modulith.test.ApplicationModuleTest;
import org.springframework.modulith.test.PublishedEvents;
import static org.assertj.core.api.Assertions.assertThat;
// Boots only the order module, not the full application context
@ApplicationModuleTest
class OrderModuleIntegrationTests {
@Autowired
OrderManagement orderManagement;
@Test
void placingOrderPublishesEvent(PublishedEvents events) {
// Place an order through the service
orderManagement.placeOrder("Alice", "Spring in Action", 2);
// Assert the correct event was published
var orderEvents = events.ofType(OrderCompleted.class)
.matching(OrderCompleted::bookTitle, "Spring in Action");
assertThat(orderEvents).hasSize(1);
}
}
What does this code do?
- @ApplicationModuleTest replaces @SpringBootTest. It boots only the order module's beans, not the entire application context. This keeps the test fast and focused.
- PublishedEvents is injected as a test method parameter. It captures all events published during the test without needing a real listener.
- The .matching(OrderCompleted::bookTitle, "Spring in Action") filter checks that the published event carries the correct book title.
Why use H2 for tests?
Your test-scoped application.properties overrides the PostgreSQL datasource with an H2 in-memory database. This means your tests run without needing Docker, making them fast and portable for CI pipelines.
- Save OrderModuleIntegrationTests.java.
- Run the integration test by executing this command:
mvn test -Dtest=OrderModuleIntegrationTests
- You should see BUILD SUCCESS with 1 test passing, confirming that placing an order correctly publishes an OrderCompleted event.
Test failing or context not loading?
- Make sure the file is inside src/test/java/com/example/bookstore/order/ (matching the module's package).
- Confirm src/test/resources/application.properties exists with the H2 datasource configuration from Step 1.
- Check that you have the h2 dependency in your pom.xml with test scope.
- Help me debug my OrderModuleIntegrationTests failure.
✔️ Awesome, I've got everything!
Great. Double check you've saved the file.
ⓧ I'd like to double check the full code
package com.example.bookstore.order;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.modulith.test.ApplicationModuleTest;
import org.springframework.modulith.test.PublishedEvents;
import static org.assertj.core.api.Assertions.assertThat;
@ApplicationModuleTest
class OrderModuleIntegrationTests {
@Autowired
OrderManagement orderManagement;
@Test
void placingOrderPublishesEvent(PublishedEvents events) {
orderManagement.placeOrder("Alice", "Spring in Action", 2);
var orderEvents = events.ofType(OrderCompleted.class)
.matching(OrderCompleted::bookTitle, "Spring in Action");
assertThat(orderEvents).hasSize(1);
}
}
Generate PlantUML module diagrams
Spring Modulith can auto-generate PlantUML component diagrams that visualize your modules and their event-based relationships. These diagrams update every time you run the test, keeping documentation in sync with your code.
- Open ModularityTests.java in src/test/java/com/example/bookstore/.
- Add the Documenter import at the top of the file, below the existing imports:
import org.springframework.modulith.docs.Documenter;
- Add the following test method inside the ModularityTests class, below the verifyModularStructure() method:
@Test
void writeDocumentationSnippets() {
// Generate PlantUML diagrams of module relationships
new Documenter(modules)
.writeModulesAsPlantUml()
.writeIndividualModulesAsPlantUml();
}
What does this code do?
- writeModulesAsPlantUml() generates an overview diagram showing all three modules and how they relate through events.
- writeIndividualModulesAsPlantUml() generates a separate diagram for each module, showing its internal structure and external connections.
- Both methods output .puml files to target/spring-modulith-docs/.
- Save ModularityTests.java.
- Run the full test suite to verify everything passes and generate the diagrams:
mvn test
- You should see BUILD SUCCESS with all tests passing.
- Confirm the diagrams were generated by running:
ls target/spring-modulith-docs/
- You should see .puml files listed, including an overview diagram and individual module diagrams for order, inventory, and notification.
No files in target/spring-modulith-docs/?
- Make sure you added the Documenter import at the top of the file: import org.springframework.modulith.docs.Documenter;.
- Check that the writeDocumentationSnippets() method has the @Test annotation. Without it, the method won't run as part of the test suite.
- Help me debug my PlantUML diagram generation.
✔️ Awesome, I've got everything!
Great. Double check you've saved the file.
ⓧ I'd like to double check the full code
package com.example.bookstore;
import org.junit.jupiter.api.Test;
import org.springframework.modulith.core.ApplicationModules;
import org.springframework.modulith.docs.Documenter;
class ModularityTests {
ApplicationModules modules = ApplicationModules.of(BookstoreApplication.class);
@Test
void verifyModularStructure() {
modules.verify();
}
@Test
void writeDocumentationSnippets() {
new Documenter(modules)
.writeModulesAsPlantUml()
.writeIndividualModulesAsPlantUml();
}
}
Secret mission
Enforce Explicit Dependency Constraints
Right now, Spring Modulith silently allows any module to access any other module's public types. In a real codebase, that's a ticking time bomb. Lock down each module's allowed dependencies and prove the enforcement catches violations.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing cloud costs. The only resource consuming system resources is the PostgreSQL Docker container.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- PostgreSQL Docker container running locally on port 5432.
- Local project files in the bookstore folder.
Keep everything running
No action needed. Choose this if you're still actively building or plan to continue with the next project in the series.
- The PostgreSQL container uses minimal resources. It will keep running until you stop Docker or restart your machine.
- Your bookstore project files remain on disk, ready to open in IntelliJ IDEA at any time.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your data and files stay intact so you can pick up where you left off.
- Stop the Spring Boot application by pressing Ctrl+C in the terminal where it is running.
- Stop the PostgreSQL container without removing data by running:
docker compose stop
- When you come back, restart the container by running docker compose up -d from your bookstore folder.
Delete - I don't want to use this again
Remove all project resources and start fresh. This removes the Docker container, its stored data, and your local project files.
- Remove the PostgreSQL container and its data volume by running this from your bookstore folder:
docker compose down -v
- Delete the project folder. On macOS or Linux:
cd ~/Desktop
rm -rf bookstore
- On Windows:
cd ~\Desktop
rmdir /s /q bookstore
- Confirm the folder is gone by running ls and checking that bookstore no longer appears.
That's a Wrap!
That's a Wrap!
Nice work! You've just built a production-grade modular monolith using Spring Modulith, complete with enforced module boundaries, event-driven communication, and architecture verification tests.
You've learned how to:
- Built a modular monolith with three isolated application modules (order, inventory, notification) that communicate exclusively through domain events using @ApplicationModuleListener and ApplicationEventPublisher.
- Set up PostgreSQL in Docker with the Event Publication Registry guaranteeing no events are lost between modules.
- Wrote architecture verification tests that catch illegal cross-module dependencies at test time, and generated PlantUML module dependency diagrams automatically from the codebase.
- Secret Mission: Enforced explicit dependency constraints with @ApplicationModule(allowedDependencies) and proved that violations are caught by the verification test.
Ready to quiz yourself?