Build an E-Commerce Modular Monolith
Build a Spring Modulith e-commerce backend with enforced module boundaries.
Introduction
30 Second Summary
As applications grow, codebases tend to turn into tangled messes where changing one feature breaks three others. Microservices solve this by splitting everything into separate deployments, but they introduce network complexity that most teams don't need early on.
In this project, you will build a modular monolith e-commerce backend using Spring Modulith. Your application will have clear module boundaries enforced at compile time, event-driven communication between business domains, and JWT-secured API endpoints.
What You'll Build
A Spring Boot backend with four application modules (Product, Order, Inventory, Auth) that communicate through domain events rather than direct method calls, secured with JWT authentication.
By the end of this project, you'll have:
- A running modular monolith with enforced module boundaries between Product, Order, and Inventory modules.
- Event-driven communication where placing an order automatically reduces inventory via @ApplicationModuleListener and the Event Publication Registry for guaranteed delivery.
- JWT-secured REST API endpoints using Spring Security 6 with self-issued RSA-signed tokens.
- Secret Mission: Add module structure verification tests with @ApplicationModuleTest.
Are there any prerequisites?
You should have Java programming experience and basic Spring Boot knowledge. Familiarity with REST APIs is also helpful.
Set Up the Project
Every modular monolith starts with a solid foundation. Before you write any business logic, you need a project structure that Spring Modulith can inspect and enforce.
In this step, you will create a Maven project in IntelliJ IDEA, configure all dependencies, spin up a PostgreSQL database with Docker, and establish the four-module package structure that Spring Modulith will detect automatically.
In this step, get ready to:
- Create a Maven project and configure the POM with Spring Boot and Spring Modulith dependencies.
- Start PostgreSQL via Docker Compose and configure the application settings.
- Create the application entry point and establish the four module packages.
Create the project and configure the Maven POM
Spring Modulith detects modules as direct sub-packages of the main application package. To make this work, you need a Spring Boot project with a specific set of dependencies that provide the modulith runtime, the event publication registry, web support, and database connectivity.
- In IntelliJ IDEA, click File in the menu bar.
- Click New, then Project.
- Select Java on the left.
- Set the Name field to ecommerce-modulith.
- Select Maven as the Build system.
- Select your JDK 21 from the JDK dropdown.
- Expand Advanced Settings at the bottom of the dialog.
- Set GroupId to com.example.
- Set ArtifactId to ecommerce-modulith.
- Click Create.
IntelliJ IDEA generates a basic pom.xml and the standard Maven directory structure. You will now replace the generated POM contents with the full project configuration.
- Open pom.xml in the editor (it should already be open in a tab).
- Select all the contents and replace them with the following configuration:
<?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>
<!-- Spring Boot parent provides dependency management and plugin defaults -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.13</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>ecommerce-modulith</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>ecommerce-modulith</name>
<description>E-Commerce Modular Monolith with Spring Modulith</description>
<properties>
<java.version>21</java.version>
</properties>
<!-- Import Spring Modulith BOM for consistent module versions -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-bom</artifactId>
<version>1.4.11</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Spring Modulith runtime + JPA-backed Event Publication Registry -->
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-jpa</artifactId>
</dependency>
<!-- Spring Data JPA for database access -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Spring Web for REST controllers -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- OAuth2 Resource Server for JWT-based authentication -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<!-- Spring Security core -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- PostgreSQL JDBC driver -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Testing support -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Spring Modulith test support for module verification -->
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
What does this POM configure?
The spring-boot-starter-parent at version 3.4.13 provides dependency management for all Spring Boot starters. The Spring Modulith BOM at version 1.4.11 manages all modulith-related dependency versions.
The key dependency is spring-modulith-starter-jpa. It bundles the modulith runtime, the events API, and the JPA-backed Event Publication Registry in a single import. This registry tracks published events in a database table so none are lost if a listener fails.
- Save pom.xml.
- When IntelliJ prompts you to reload the Maven project (a notification appears in the top-right corner), click Load Maven Changes.
- Wait for the Maven sync to finish. You should see the Dependencies node populate in the Project tool window on the left.
Maven sync failed or dependencies not downloading?
- Make sure you have internet connectivity. Maven downloads dependencies from Maven Central on first sync.
- Verify the XML is well-formed. A missing closing tag will cause parse errors.
- Try clicking the refresh icon in the Maven tool window (View > Tool Windows > Maven, then click the reload button).
Help me troubleshoot my Maven sync
✔️ Awesome, I've got everything!
Great. Make sure pom.xml is saved before moving on.
ⓧ I'd like to double check the full code
<?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.4.13</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>ecommerce-modulith</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>ecommerce-modulith</name>
<description>E-Commerce Modular Monolith with Spring Modulith</description>
<properties>
<java.version>21</java.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-bom</artifactId>
<version>1.4.11</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</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>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
Start PostgreSQL and configure the application
Your application needs a database to store entities and the event publication log. You will run PostgreSQL 16 in a Docker container so you do not need to install it on your machine.
- Create a new file called docker-compose.yml in the project root (the ecommerce-modulith folder). Right-click the project root in the Project tool window, then click New > File.
- Name the file docker-compose.yml and press Enter.
- Paste the following Docker Compose configuration:
# Defines the PostgreSQL service for local development
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: ecommerce
POSTGRES_USER: ecommerce
POSTGRES_PASSWORD: ecommerce
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
# Named volume persists data across container restarts
volumes:
pgdata:
What does this configuration do?
This creates a PostgreSQL 16 container with a database named ecommerce, accessible on port 5432. The pgdata volume ensures your data survives container restarts.
- Save docker-compose.yml.
- Open the built-in terminal in IntelliJ IDEA by pressing Alt+F12 (Windows) or Option+F12 (macOS).
- Start the PostgreSQL container by running this command:
docker compose up -d
- Verify the container is running by executing:
docker ps
You should see a container based on postgres:16 listed with status Up.
Container not starting or port conflict?
- If port 5432 is already in use, you may have another PostgreSQL instance running. Stop it or change the host port in docker-compose.yml.
- Ensure Docker Desktop is running. The docker compose command requires the Docker daemon.
Help me troubleshoot my Docker container
Now configure Spring Boot to connect to this database. You will also enable Spring Modulith's event republishing and set debug logging for module detection.
- In the Project tool window, navigate to src/main/resources.
- Delete the default application.properties file if it exists (right-click > Delete).
- Create a new file called application.yml in src/main/resources by right-clicking the folder, then New > File.
- Paste the following configuration:
spring:
# Database connection pointing to the Docker PostgreSQL container
datasource:
url: jdbc:postgresql://localhost:5432/ecommerce
username: ecommerce
password: ecommerce
# JPA settings for automatic schema management
jpa:
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
format_sql: true
# Spring Modulith event settings
modulith:
events:
republish-outstanding-events-on-restart: true
# Enable debug logging for Spring Modulith module detection
logging:
level:
org.springframework.modulith: DEBUG
What do these settings do?
The ddl-auto: update setting tells Hibernate to automatically create or update database tables based on your entity classes. The show-sql: true setting logs all SQL queries so you can see what is happening.
The republish-outstanding-events-on-restart: true setting ensures that any events which failed to be processed before a restart are retried automatically. This is the Event Publication Registry in action.
- Save application.yml.
✔️ Awesome, I've got everything!
Great. Make sure both docker-compose.yml and application.yml are saved.
ⓧ I'd like to double check the full code
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: ecommerce
POSTGRES_USER: ecommerce
POSTGRES_PASSWORD: ecommerce
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
spring:
datasource:
url: jdbc:postgresql://localhost:5432/ecommerce
username: ecommerce
password: ecommerce
jpa:
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
format_sql: true
modulith:
events:
republish-outstanding-events-on-restart: true
logging:
level:
org.springframework.modulith: DEBUG
Create the application entry point and module packages
Spring Modulith automatically detects application modules as direct sub-packages of your main application package. You need to create the main class and then set up the four module packages.
- In the Project tool window, navigate to src/main/java.
- Right-click src/main/java, click New > Package, and enter com.example.ecommerce.
- Right-click the com.example.ecommerce package, click New > Java Class, and name it EcommerceApplication.
- Replace the generated class contents with the following code:
package com.example.ecommerce;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
// Main entry point - @EnableAsync is required for @ApplicationModuleListener
@SpringBootApplication
@EnableAsync
public class EcommerceApplication {
public static void main(String[] args) {
SpringApplication.run(EcommerceApplication.class, args);
}
}
Why @EnableAsync?
The @EnableAsync annotation is required for Spring Modulith's @ApplicationModuleListener to work. Under the hood, @ApplicationModuleListener combines @Async with @TransactionalEventListener. Without async support enabled, inter-module events will not be processed asynchronously.
- Save EcommerceApplication.java.
Now create the four module packages. Spring Modulith will detect each of these as a separate application module.
- Right-click the com.example.ecommerce package, click New > Package, and enter product.
- Repeat the same process to create order, inventory, and auth packages inside com.example.ecommerce.
Empty packages may collapse in IntelliJ
IntelliJ IDEA sometimes flattens empty packages visually. Click the gear icon in the Project tool window and uncheck Compact Middle Packages to see the full package hierarchy.
Time to verify everything works. Run the application to confirm Spring Modulith detects all four modules and connects to PostgreSQL.
- Click the green play button next to the main method in EcommerceApplication.java, then click Run 'EcommerceApplication'.
What should I see?
In the Run panel at the bottom, you should see Spring Boot start up with the Spring Modulith banner. Look for log lines mentioning the detected application modules: product, order, inventory, and auth. You should also see Hibernate SQL output as it creates the event_publication table.
Application fails to start?
- If you see a connection refused error, confirm the Docker container is running with docker ps. The PostgreSQL container must be healthy before the app can connect.
- If you see a class not found error, make sure Maven sync completed successfully. Try View > Tool Windows > Maven and click the reload button.
- If Spring Security blocks startup with a generated password, that is expected. The security configuration will be added in a later step.
Help me troubleshoot my application startup
✔️ Awesome, I've got everything!
Great. Make sure the application is running and you can see the 4 modules detected in the logs.
ⓧ I'd like to double check the full code
package com.example.ecommerce;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
@SpringBootApplication
@EnableAsync
public class EcommerceApplication {
public static void main(String[] args) {
SpringApplication.run(EcommerceApplication.class, args);
}
}
Your foundation is solid. You have a Maven project with all dependencies configured, PostgreSQL running in Docker, and Spring Modulith detecting your four business modules. Next up, you will build the Product module with a REST API for creating and querying products.
Build the Product Module
Your project skeleton is in place. Now it is time to build the first business module that will actually do something. The product module is the foundation of your e-commerce application.
In Spring Modulith, each module lives in its own sub-package and owns its data, service logic, and API layer. You will build the product module with a JPA entity, a repository, a service class, and a REST controller.
In this step, get ready to:
- Create the Product entity and its JPA repository.
- Build a service layer and REST controller for product CRUD operations.
- Test your product endpoints with curl to verify the module works independently.
Create the Product entity and repository
Every module starts with its domain model. The Product entity defines the data structure that Hibernate maps to a database table.
- In IntelliJ's Project panel on the left, expand src/main/java/com/example/ecommerce/product.
- Right-click the product package, select New > Java Class, and name it Product.
- Replace the generated content with the following code:
package com.example.ecommerce.product;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.Instant;
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
private String description;
@Column(nullable = false)
private BigDecimal price;
@Column(name = "created_at")
private Instant createdAt;
// JPA requires a no-arg constructor
protected Product() {}
// Public constructor for creating new products
public Product(String name, String description, BigDecimal price) {
this.name = name;
this.description = description;
this.price = price;
this.createdAt = Instant.now();
}
public Long getId() { return id; }
public String getName() { return name; }
public String getDescription() { return description; }
public BigDecimal getPrice() { return price; }
public Instant getCreatedAt() { return createdAt; }
}
What does this code do?
- @Entity and @Table(name = "products") tell Hibernate to map this class to a products table in PostgreSQL.
- @Id with GenerationType.IDENTITY lets the database auto-generate the primary key.
- The protected Product() {} constructor is required by JPA for creating entity instances from database rows.
- The public constructor sets createdAt to the current timestamp automatically when a new product is created.
Next, create the repository interface that gives you database access without writing SQL.
- Right-click the product package, select New > Java Class, choose Interface, and name it ProductRepository.
- Replace the generated content with the following code:
package com.example.ecommerce.product;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> {
}
Why is the repository empty?
By extending JpaRepository<Product, Long>, Spring Data JPA automatically provides methods like save(), findAll(), and findById(). You get full CRUD operations without writing a single query.
- Save both Product.java and ProductRepository.java.
- Check that IntelliJ shows no red underlines in either file.
Seeing red underlines on the imports?
- Make sure your pom.xml includes the spring-boot-starter-data-jpa dependency. Without it, the jakarta.persistence imports will not resolve.
- If imports remain red, right-click your project root and select Maven > Reload Project to refresh dependencies.
- Confirm the package declaration matches com.example.ecommerce.product exactly.
Help me fix red underline errors in my Product entity
Build the service layer and REST controller
The entity and repository handle data storage. Now you need a service to hold business logic and a controller to expose HTTP endpoints.
- Right-click the product package, select New > Java Class, and name it ProductService.
- Replace the generated content with the following code:
package com.example.ecommerce.product;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class ProductService {
private final ProductRepository productRepository;
// Constructor injection - Spring automatically wires the repository
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
// Create a new product and persist it to the database
public Product createProduct(String name, String description, java.math.BigDecimal price) {
return productRepository.save(new Product(name, description, price));
}
// Retrieve all products from the database
public List<Product> getAllProducts() {
return productRepository.findAll();
}
// Find a single product by ID, throw if not found
public Product getProductById(Long id) {
return productRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Product not found: " + id));
}
}
What does this code do?
- @Service registers this class as a Spring-managed bean that other components can inject.
- Constructor injection receives the ProductRepository automatically. Spring creates the repository implementation behind the scenes.
- The getProductById method uses orElseThrow to convert an empty Optional into a clear error message.
- Save ProductService.java.
- Confirm there are no red underlines or compilation errors.
ProductRepository not resolving?
- Make sure ProductRepository.java is saved and in the same com.example.ecommerce.product package.
- If IntelliJ still shows red, click Build > Rebuild Project to force recompilation.
Help me fix unresolved references in ProductService
Now create the REST controller that exposes HTTP endpoints to the outside world.
- Right-click the product package, select New > Java Class, and name it ProductController.
- Replace the generated content with the following code:
package com.example.ecommerce.product;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.List;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
// GET /api/products - return all products
@GetMapping
public List<Product> getAllProducts() {
return productService.getAllProducts();
}
// GET /api/products/{id} - return a single product
@GetMapping("/{id}")
public Product getProduct(@PathVariable Long id) {
return productService.getProductById(id);
}
// POST /api/products - create a new product
@PostMapping
public Product createProduct(@RequestBody CreateProductRequest request) {
return productService.createProduct(request.name(), request.description(), request.price());
}
// DTO for incoming product creation requests
public record CreateProductRequest(String name, String description, BigDecimal price) {}
}
What does this code do?
- @RestController combines @Controller and @ResponseBody, so every method returns JSON directly.
- @RequestMapping("/api/products") sets the base URL path for all endpoints in this controller.
- The CreateProductRequest record is a lightweight DTO that Spring automatically deserializes from the JSON request body.
- Each method delegates to ProductService rather than calling the repository directly. This keeps the controller thin and the business logic testable.
- Save ProductController.java.
Red underline on CreateProductRequest?
- The record must be inside the ProductController class, not outside it. Make sure it sits between the last method's closing brace and the class's closing brace.
- Ensure your project is using Java 21. Records require Java 16 or higher.
Help me fix my ProductController record compilation error
✔️ Awesome, I've got everything!
Great. Make sure all four files in the product package are saved before moving on.
ⓧ I'd like to double check the full code
Here are all four product module files for comparison.
package com.example.ecommerce.product;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.Instant;
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
private String description;
@Column(nullable = false)
private BigDecimal price;
@Column(name = "created_at")
private Instant createdAt;
protected Product() {}
public Product(String name, String description, BigDecimal price) {
this.name = name;
this.description = description;
this.price = price;
this.createdAt = Instant.now();
}
public Long getId() { return id; }
public String getName() { return name; }
public String getDescription() { return description; }
public BigDecimal getPrice() { return price; }
public Instant getCreatedAt() { return createdAt; }
}
package com.example.ecommerce.product;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> {
}
package com.example.ecommerce.product;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class ProductService {
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public Product createProduct(String name, String description, java.math.BigDecimal price) {
return productRepository.save(new Product(name, description, price));
}
public List<Product> getAllProducts() {
return productRepository.findAll();
}
public Product getProductById(Long id) {
return productRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Product not found: " + id));
}
}
package com.example.ecommerce.product;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.List;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@GetMapping
public List<Product> getAllProducts() {
return productService.getAllProducts();
}
@GetMapping("/{id}")
public Product getProduct(@PathVariable Long id) {
return productService.getProductById(id);
}
@PostMapping
public Product createProduct(@RequestBody CreateProductRequest request) {
return productService.createProduct(request.name(), request.description(), request.price());
}
public record CreateProductRequest(String name, String description, BigDecimal price) {}
}
Test the product endpoints
Your product module is complete. Time to restart the application and confirm that the endpoints respond correctly.
- In IntelliJ, click the green Run button (or press Ctrl+R on macOS, Shift+F10 on Windows) to restart EcommerceApplication.
- Wait for the console to show Started EcommerceApplication in the logs.
- Look in the startup logs for a line that says Using generated security password: followed by a UUID. Copy that password.
Why do I need a generated password?
Spring Security is on your classpath, so it secures all endpoints by default. Until you build your own SecurityConfig in a later step, Spring generates a random password and prints it to the console. The default username is user.
- Open a terminal in IntelliJ by pressing Alt+F12 (Windows) or clicking View > Tool Windows > Terminal (macOS).
- Create a product by running this command (replace the password with your generated one):
curl -u user:your-generated-password -X POST http://localhost:8080/api/products -H "Content-Type: application/json" -d '{"name":"Mechanical Keyboard","description":"Cherry MX switches","price":89.99}'
- You should see a JSON response containing the product with an id, name, price, and createdAt timestamp.
- Retrieve the product you just created by running:
curl -u user:your-generated-password http://localhost:8080/api/products/1
- You should see the same product returned as JSON. This confirms the Product module stores and retrieves data independently.
Getting a 401 Unauthorized response?
- Make sure you copied the full generated password from the startup logs. It changes every time the application restarts.
- The username is user (lowercase). Check that your -u flag uses user:your-password with no extra spaces.
- If you restarted the application, a new password was generated. Grab the new one from the latest console output.
Help me fix 401 errors when testing my Spring Boot endpoints
What is a modular monolith boundary?
Notice how everything in the product module lives inside the com.example.ecommerce.product package. Spring Modulith treats each direct sub-package of your main application package as a separate module.
Other modules (like order and inventory) can only access classes in the root of this package. They cannot reach into sub-packages. This enforced boundary is what makes it a modular monolith rather than a tangled monolith.
Your product module is fully operational. It stores products in PostgreSQL and serves them over HTTP. Next up, you will build the order module and connect it to inventory through domain events.
Build the Order Module with Event Publishing
Your Product module works as a standalone unit. Now it is time to build the Order module, which introduces the most powerful concept in a modular monolith: event-driven communication between modules.
When a customer places an order, the Order module needs to tell other modules (like Inventory) that something happened. Instead of calling the Inventory module directly, the Order module publishes an event. Spring Modulith's Event Publication Registry tracks these events in the database, guaranteeing they are never lost even if a listener fails.
In this step, get ready to:
- Create the Order and OrderItem entities with an OrderPlacedEvent record as the module's public API.
- Build the OrderService with event publishing and OrderController with REST endpoints.
- Test the full order placement flow and verify event publication in the database.
Create the Order entities and OrderPlacedEvent record
The order module needs four files: an event record that serves as the public contract with other modules, two JPA entities for persistence, and a repository interface.
- In IntelliJ, right-click the com.example.ecommerce package in the src/main/java directory.
- Select New then Package and name it order.
- Create a new Java file called OrderPlacedEvent.java inside the order package by pasting the following code:
package com.example.ecommerce.order;
import java.util.List;
// This record defines the event contract between the Order module and any listener.
// Other modules depend on this record's shape, not on Order internals.
public record OrderPlacedEvent(Long orderId, List<OrderItemDetail> items) {
// Each item detail carries only what listeners need: product ID and quantity
public record OrderItemDetail(Long productId, int quantity) {}
}
What does this code do?
This Java record is the public API of the Order module. Other modules never import Order entities directly. They subscribe to this event instead.
The nested OrderItemDetail record carries only the data a listener needs (productId and quantity). This keeps the coupling between modules minimal.
- Create a new Java file called Order.java inside the order package by pasting the following code:
package com.example.ecommerce.order;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "customer_email", nullable = false)
private String customerEmail;
@Column(nullable = false)
private String status;
@Column(name = "total_amount", nullable = false)
private BigDecimal totalAmount;
@Column(name = "created_at")
private Instant createdAt;
// One order has many line items, cascaded so items are saved with the order
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
protected Order() {}
public Order(String customerEmail, BigDecimal totalAmount) {
this.customerEmail = customerEmail;
this.totalAmount = totalAmount;
this.status = "PLACED";
this.createdAt = Instant.now();
}
// Maintains bidirectional relationship between Order and OrderItem
public void addItem(OrderItem item) {
items.add(item);
item.setOrder(this);
}
public Long getId() { return id; }
public String getCustomerEmail() { return customerEmail; }
public String getStatus() { return status; }
public BigDecimal getTotalAmount() { return totalAmount; }
public Instant getCreatedAt() { return createdAt; }
public List<OrderItem> getItems() { return items; }
}
What does this code do?
The Order entity maps to the orders table. It sets the status to PLACED automatically in the constructor.
The addItem() method keeps the bidirectional JPA relationship in sync. When you add an item to the order, it also sets the item's parent reference back to this order.
- Create a new Java file called OrderItem.java inside the order package by pasting the following code:
package com.example.ecommerce.order;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entity
@Table(name = "order_items")
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "product_id", nullable = false)
private Long productId;
@Column(nullable = false)
private int quantity;
@Column(nullable = false)
private BigDecimal price;
// @JsonIgnore prevents infinite recursion when serializing Order to JSON
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
@JsonIgnore
private Order order;
protected OrderItem() {}
public OrderItem(Long productId, int quantity, BigDecimal price) {
this.productId = productId;
this.quantity = quantity;
this.price = price;
}
public void setOrder(Order order) { this.order = order; }
public Long getId() { return id; }
public Long getProductId() { return productId; }
public int getQuantity() { return quantity; }
public BigDecimal getPrice() { return price; }
}
Why @JsonIgnore on the order field?
Without @JsonIgnore, serializing an Order to JSON would follow the order reference on each item back to the parent Order, which contains the items again. This infinite loop would crash your API responses.
- Create a new Java file called OrderRepository.java inside the order package by pasting the following code:
package com.example.ecommerce.order;
import org.springframework.data.jpa.repository.JpaRepository;
// Package-private: only accessible within the order module
interface OrderRepository extends JpaRepository<Order, Long> {
}
Why is OrderRepository package-private?
Notice there is no public keyword. This makes the repository accessible only within the order package. Spring Modulith enforces module boundaries: other modules cannot directly query orders. They must use the module's public API (the event record or public service methods).
- Save all four files.
Seeing red underlines in IntelliJ?
If IntelliJ shows compilation errors, that is expected at this point. The OrderService (which you will create next) references PlaceOrderRequest which does not exist yet. The project will compile fully after the next substep.
If you see errors in the files you just created, check that your package declaration says package com.example.ecommerce.order; at the top of each file.
Help me debug my Order module compilation errors
Create the OrderService and OrderController
The service layer handles the business logic of placing an order and publishing the event. The controller exposes this as a REST API.
- Create a new Java file called OrderService.java inside the order package by pasting the following code:
package com.example.ecommerce.order;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final ApplicationEventPublisher eventPublisher;
public OrderService(OrderRepository orderRepository, ApplicationEventPublisher eventPublisher) {
this.orderRepository = orderRepository;
this.eventPublisher = eventPublisher;
}
@Transactional
public Order placeOrder(String customerEmail, List<PlaceOrderRequest.ItemRequest> items) {
// Calculate the total from item prices and quantities
BigDecimal total = items.stream()
.map(item -> item.price().multiply(BigDecimal.valueOf(item.quantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
// Create and persist the order with all its line items
Order order = new Order(customerEmail, total);
for (PlaceOrderRequest.ItemRequest item : items) {
order.addItem(new OrderItem(item.productId(), item.quantity(), item.price()));
}
order = orderRepository.save(order);
// Build event payload with only the data listeners need
List<OrderPlacedEvent.OrderItemDetail> eventItems = items.stream()
.map(item -> new OrderPlacedEvent.OrderItemDetail(item.productId(), item.quantity()))
.toList();
// Publish the event - Spring Modulith's Event Publication Registry tracks it
eventPublisher.publishEvent(new OrderPlacedEvent(order.getId(), eventItems));
return order;
}
public Order getOrder(Long id) {
return orderRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Order not found: " + id));
}
}
What does this code do?
The @Transactional annotation wraps the entire placeOrder method in a database transaction. The order is persisted first, then the event is published.
The ApplicationEventPublisher is Spring's built-in event mechanism. Spring Modulith intercepts these events and records them in the event_publication table before dispatching to listeners. If a listener fails, the event remains in the table and can be retried on restart.
- Create a new Java file called OrderController.java inside the order package by pasting the following code:
package com.example.ecommerce.order;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
// POST /api/orders - place a new order
@PostMapping
public Order placeOrder(@RequestBody PlaceOrderRequest request) {
return orderService.placeOrder(request.customerEmail(), request.items());
}
// GET /api/orders/{id} - retrieve an existing order
@GetMapping("/{id}")
public Order getOrder(@PathVariable Long id) {
return orderService.getOrder(id);
}
}
What does this code do?
The controller is intentionally thin. It accepts the HTTP request, delegates to OrderService, and returns the result. All business logic (persisting, calculating totals, publishing events) stays in the service layer.
- Save both files.
Create the PlaceOrderRequest DTO and test the order flow
The final piece is the request DTO that defines what the API expects from clients. Once this file exists, the entire module compiles and you can test the event-driven order flow end to end.
- Create a new Java file called PlaceOrderRequest.java inside the order package by pasting the following code:
package com.example.ecommerce.order;
import java.math.BigDecimal;
import java.util.List;
// DTO defining the shape of incoming order requests
public record PlaceOrderRequest(String customerEmail, List<ItemRequest> items) {
// Each item in the order specifies a product, quantity, and price
public record ItemRequest(Long productId, int quantity, BigDecimal price) {}
}
What does this code do?
This record defines the JSON shape your API expects. A client sends a customerEmail and a list of items, each with a productId, quantity, and price. Spring Boot automatically deserializes the JSON body into this record.
- Save the file.
- Restart your application in IntelliJ by clicking the green Run button (or press Shift+F10 on Windows, Ctrl+R on macOS).
You should see Spring Modulith detect the order module in the startup logs alongside product.
- Test placing an order by running this command in your terminal:
curl -X POST http://localhost:8080/api/orders \
-H "Content-Type: application/json" \
-d '{"customerEmail": "test@example.com", "items": [{"productId": 1, "quantity": 2, "price": 29.99}]}'
What should I see?
The response should be a JSON object with an id, customerEmail, status set to PLACED, and a totalAmount of 59.98.
In your IntelliJ console, look for a log line containing OrderPlacedEvent. This confirms the event was published. Because no listener exists yet (you will build the Inventory module in the next step), the event remains in the event_publication table as pending.
Getting a 401 or 403 response?
Spring Security is on the classpath but not fully configured yet (that happens in Step 5). If your requests are being rejected, add these two lines to your application.yml under the spring: block to set temporary credentials:
security:
user:
name: user
password: password
Then add -u user:password to your curl command. You will remove this workaround when you build proper JWT authentication in Step 5.
Help me fix my 401/403 error on the Order endpoints
Application fails to start?
Make sure all seven files in the order package are saved and have the correct package declaration: package com.example.ecommerce.order;. The most common cause is a missing PlaceOrderRequest.java file, which OrderService depends on.
Help me debug my Spring Boot startup failure
✔️ Awesome, I've got everything!
Great. Double check that all your files are saved and your application restarts without errors.
ⓧ I'd like to double check the full code
Here are all seven files in the order package. Compare yours against these:
package com.example.ecommerce.order;
import java.util.List;
public record OrderPlacedEvent(Long orderId, List<OrderItemDetail> items) {
public record OrderItemDetail(Long productId, int quantity) {}
}
package com.example.ecommerce.order;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "customer_email", nullable = false)
private String customerEmail;
@Column(nullable = false)
private String status;
@Column(name = "total_amount", nullable = false)
private BigDecimal totalAmount;
@Column(name = "created_at")
private Instant createdAt;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
protected Order() {}
public Order(String customerEmail, BigDecimal totalAmount) {
this.customerEmail = customerEmail;
this.totalAmount = totalAmount;
this.status = "PLACED";
this.createdAt = Instant.now();
}
public void addItem(OrderItem item) {
items.add(item);
item.setOrder(this);
}
public Long getId() { return id; }
public String getCustomerEmail() { return customerEmail; }
public String getStatus() { return status; }
public BigDecimal getTotalAmount() { return totalAmount; }
public Instant getCreatedAt() { return createdAt; }
public List<OrderItem> getItems() { return items; }
}
package com.example.ecommerce.order;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entity
@Table(name = "order_items")
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "product_id", nullable = false)
private Long productId;
@Column(nullable = false)
private int quantity;
@Column(nullable = false)
private BigDecimal price;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
@JsonIgnore
private Order order;
protected OrderItem() {}
public OrderItem(Long productId, int quantity, BigDecimal price) {
this.productId = productId;
this.quantity = quantity;
this.price = price;
}
public void setOrder(Order order) { this.order = order; }
public Long getId() { return id; }
public Long getProductId() { return productId; }
public int getQuantity() { return quantity; }
public BigDecimal getPrice() { return price; }
}
package com.example.ecommerce.order;
import org.springframework.data.jpa.repository.JpaRepository;
interface OrderRepository extends JpaRepository<Order, Long> {
}
package com.example.ecommerce.order;
import java.math.BigDecimal;
import java.util.List;
public record PlaceOrderRequest(String customerEmail, List<ItemRequest> items) {
public record ItemRequest(Long productId, int quantity, BigDecimal price) {}
}
package com.example.ecommerce.order;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final ApplicationEventPublisher eventPublisher;
public OrderService(OrderRepository orderRepository, ApplicationEventPublisher eventPublisher) {
this.orderRepository = orderRepository;
this.eventPublisher = eventPublisher;
}
@Transactional
public Order placeOrder(String customerEmail, List<PlaceOrderRequest.ItemRequest> items) {
BigDecimal total = items.stream()
.map(item -> item.price().multiply(BigDecimal.valueOf(item.quantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
Order order = new Order(customerEmail, total);
for (PlaceOrderRequest.ItemRequest item : items) {
order.addItem(new OrderItem(item.productId(), item.quantity(), item.price()));
}
order = orderRepository.save(order);
List<OrderPlacedEvent.OrderItemDetail> eventItems = items.stream()
.map(item -> new OrderPlacedEvent.OrderItemDetail(item.productId(), item.quantity()))
.toList();
eventPublisher.publishEvent(new OrderPlacedEvent(order.getId(), eventItems));
return order;
}
public Order getOrder(Long id) {
return orderRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Order not found: " + id));
}
}
package com.example.ecommerce.order;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
public Order placeOrder(@RequestBody PlaceOrderRequest request) {
return orderService.placeOrder(request.customerEmail(), request.items());
}
@GetMapping("/{id}")
public Order getOrder(@PathVariable Long id) {
return orderService.getOrder(id);
}
}
Your Order module is complete and publishing events. Right now those events sit in the database with nobody listening. In the next step, you will build the Inventory module that subscribes to OrderPlacedEvent and automatically reduces stock.
Build the Inventory Module with Event Listening
Your order module is publishing events, but nothing is listening yet. The event_publication table shows pending events with no completion date, which means the events are being tracked but never processed.
In this step, you'll build the inventory module that subscribes to those events. When an order is placed, the inventory module will automatically reduce stock for each ordered product. The order module never calls the inventory module directly.
In this step, get ready to:
- Create the InventoryItem entity and seed test data on startup.
- Wire up an event listener that reacts to OrderPlacedEvent asynchronously.
- Expose an endpoint to check current stock levels and verify end-to-end event delivery.
Create the InventoryItem entity and seed test data
The inventory module needs to track how much stock exists for each product. You'll create an entity that maps to the inventory_items table and a startup component that pre-populates some test inventory.
- In IntelliJ, right-click the com.example.ecommerce package in the src/main/java directory.
- Select New then Package and name it inventory.
- Create a new Java class inside the inventory package called InventoryItem and paste the following code:
package com.example.ecommerce.inventory;
import jakarta.persistence.*;
@Entity
@Table(name = "inventory_items")
public class InventoryItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "product_id", nullable = false, unique = true)
private Long productId;
@Column(nullable = false)
private int quantity;
protected InventoryItem() {}
public InventoryItem(Long productId, int quantity) {
this.productId = productId;
this.quantity = quantity;
}
public void reduceStock(int amount) {
if (this.quantity < amount) {
throw new RuntimeException("Insufficient stock for product: " + productId);
}
this.quantity -= amount;
}
public Long getId() { return id; }
public Long getProductId() { return productId; }
public int getQuantity() { return quantity; }
}
What does this code do?
The InventoryItem entity tracks stock for a single product. The productId column is marked unique because each product has exactly one inventory record.
The reduceStock() method encapsulates the stock-reduction logic. It throws a RuntimeException if someone tries to order more than what's available.
- Create a new Java interface in the same inventory package called InventoryRepository and paste the following code:
package com.example.ecommerce.inventory;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
interface InventoryRepository extends JpaRepository<InventoryItem, Long> {
Optional<InventoryItem> findByProductId(Long productId);
}
Why is this interface package-private?
The InventoryRepository uses default (package-private) visibility, not public. This is intentional. In Spring Modulith, only classes in a module's root package are part of its public API. Making the repository package-private prevents other modules from accessing it directly.
- Create a new Java class in the inventory package called DataInitializer and paste the following code:
package com.example.ecommerce.inventory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
@Component
class DataInitializer implements ApplicationRunner {
private final InventoryRepository inventoryRepository;
DataInitializer(InventoryRepository inventoryRepository) {
this.inventoryRepository = inventoryRepository;
}
@Override
public void run(ApplicationArguments args) {
if (inventoryRepository.count() == 0) {
inventoryRepository.save(new InventoryItem(1L, 100));
inventoryRepository.save(new InventoryItem(2L, 50));
inventoryRepository.save(new InventoryItem(3L, 200));
}
}
}
What does DataInitializer do?
This component implements ApplicationRunner, so Spring Boot calls its run() method automatically after the application starts.
It checks if the inventory table is empty and seeds three items: product 1 with 100 units, product 2 with 50 units, and product 3 with 200 units. The count() == 0 guard ensures it only runs once, even if you restart the application.
- Save all three files.
Seeing red squiggles in IntelliJ?
If IntelliJ shows unresolved imports, try clicking File then Invalidate Caches / Restart. Also confirm the class files are inside src/main/java/com/example/ecommerce/inventory/ and not in a sub-package.
help me fix unresolved imports in my inventory module
Create the event handler that listens for OrderPlacedEvent
This is where modules truly decouple. Instead of the order module calling inventory methods directly, the inventory module listens for the OrderPlacedEvent and processes it asynchronously in its own transaction.
- Create a new Java class in the inventory package called InventoryEventHandler and paste the following code:
package com.example.ecommerce.inventory;
import com.example.ecommerce.order.OrderPlacedEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Component;
@Component
class InventoryEventHandler {
private static final Logger log = LoggerFactory.getLogger(InventoryEventHandler.class);
private final InventoryRepository inventoryRepository;
InventoryEventHandler(InventoryRepository inventoryRepository) {
this.inventoryRepository = inventoryRepository;
}
@ApplicationModuleListener
void onOrderPlaced(OrderPlacedEvent event) {
log.info("Received OrderPlacedEvent for order: {}", event.orderId());
for (OrderPlacedEvent.OrderItemDetail item : event.items()) {
InventoryItem inventory = inventoryRepository.findByProductId(item.productId())
.orElseThrow(() -> new RuntimeException("No inventory for product: " + item.productId()));
inventory.reduceStock(item.quantity());
inventoryRepository.save(inventory);
log.info("Reduced stock for product {} by {}", item.productId(), item.quantity());
}
}
}
What does @ApplicationModuleListener do?
The @ApplicationModuleListener annotation is syntactic sugar for @Async combined with @TransactionalEventListener using REQUIRES_NEW propagation. This means the listener runs asynchronously in its own transaction, completely decoupled from the order transaction.
If the listener fails (for example, insufficient stock), the original order transaction is not rolled back. The Event Publication Registry tracks the failure and can re-deliver the event on restart.
Why does InventoryEventHandler import from the order module?
The OrderPlacedEvent record lives in the order package's root, making it part of the order module's public API. Spring Modulith allows cross-module references only to a module's public types. The event record is the contract between modules.
- Save InventoryEventHandler.java.
Cannot resolve OrderPlacedEvent?
Make sure OrderPlacedEvent.java in the order package has public visibility on the record declaration. Package-private classes cannot be imported from another module.
help me fix a cross-module import error with OrderPlacedEvent
Create the inventory controller and verify end-to-end event delivery
You need an endpoint to check stock levels so you can verify the event listener actually reduced inventory after an order was placed.
- Create a new Java class in the inventory package called InventoryController and paste the following code:
package com.example.ecommerce.inventory;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/inventory")
public class InventoryController {
private final InventoryRepository inventoryRepository;
public InventoryController(InventoryRepository inventoryRepository) {
this.inventoryRepository = inventoryRepository;
}
@GetMapping("/{productId}")
public InventoryItem getStock(@PathVariable Long productId) {
return inventoryRepository.findByProductId(productId)
.orElseThrow(() -> new RuntimeException("No inventory for product: " + productId));
}
}
What does this endpoint do?
The GET /api/inventory/{productId} endpoint returns the current stock level for a given product. You'll use this to confirm that stock decreases after placing an order.
- Save InventoryController.java.
- Restart your application by clicking the green Run button in IntelliJ (or press Ctrl+Shift+F10 on Windows, Ctrl+R on macOS).
In the startup logs, you should see Spring Modulith now detecting the inventory module alongside product, order, and auth.
- Check the initial stock for product 1 by running this command in your terminal:
curl -s http://localhost:8080/api/inventory/1
You should see a JSON response showing "quantity":100 for product 1.
- Now place an order for 3 units of product 1 by running:
curl -s -X POST http://localhost:8080/api/orders \
-H "Content-Type: application/json" \
-d '{"customerEmail":"test@example.com","items":[{"productId":1,"quantity":3,"price":29.99}]}'
- Wait 1-2 seconds for the async event to process, then check the stock again:
curl -s http://localhost:8080/api/inventory/1
The quantity should now show 97 (reduced from 100 by 3 units). You never called the inventory module directly. The order module published an event, and the inventory module processed it independently.
- Verify the event was completed in the registry by connecting to your PostgreSQL database:
docker exec -it $(docker ps -q --filter ancestor=postgres:16) psql -U ecommerce -d ecommerce -c "SELECT id, listener_id, completion_date FROM event_publication ORDER BY publication_date DESC LIMIT 5;"
You should see that the completion_date column now has a timestamp for your event. This confirms the Event Publication Registry tracked the event from publication through to successful completion.
What is the Event Publication Registry proving here?
In the previous step, the event_publication table showed events with a NULL completion_date because no listener existed yet. Now that InventoryEventHandler processes the event successfully, the registry marks it as completed.
If the listener had thrown an exception, the completion_date would remain NULL and the event would be re-delivered on the next application restart (because you set republish-outstanding-events-on-restart: true in application.yml).
Stock not decreasing after placing an order?
Check the application logs for the message Received OrderPlacedEvent for order:. If you do not see it, make sure @EnableAsync is present on your EcommerceApplication class. Without it, @ApplicationModuleListener cannot execute asynchronously.
Also confirm the DataInitializer ran at startup by checking that GET /api/inventory/1 returns data before you place an order. If it returns an error, the inventory table is empty.
help me debug why my event listener is not reducing inventory stock
✔️ Awesome, I've got everything!
Great. Double check you've saved all five files in the inventory package.
ⓧ I'd like to double check the full code
Here are all the inventory module files for comparison:
package com.example.ecommerce.inventory;
import jakarta.persistence.*;
@Entity
@Table(name = "inventory_items")
public class InventoryItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "product_id", nullable = false, unique = true)
private Long productId;
@Column(nullable = false)
private int quantity;
protected InventoryItem() {}
public InventoryItem(Long productId, int quantity) {
this.productId = productId;
this.quantity = quantity;
}
public void reduceStock(int amount) {
if (this.quantity < amount) {
throw new RuntimeException("Insufficient stock for product: " + productId);
}
this.quantity -= amount;
}
public Long getId() { return id; }
public Long getProductId() { return productId; }
public int getQuantity() { return quantity; }
}
package com.example.ecommerce.inventory;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
interface InventoryRepository extends JpaRepository<InventoryItem, Long> {
Optional<InventoryItem> findByProductId(Long productId);
}
package com.example.ecommerce.inventory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
@Component
class DataInitializer implements ApplicationRunner {
private final InventoryRepository inventoryRepository;
DataInitializer(InventoryRepository inventoryRepository) {
this.inventoryRepository = inventoryRepository;
}
@Override
public void run(ApplicationArguments args) {
if (inventoryRepository.count() == 0) {
inventoryRepository.save(new InventoryItem(1L, 100));
inventoryRepository.save(new InventoryItem(2L, 50));
inventoryRepository.save(new InventoryItem(3L, 200));
}
}
}
package com.example.ecommerce.inventory;
import com.example.ecommerce.order.OrderPlacedEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Component;
@Component
class InventoryEventHandler {
private static final Logger log = LoggerFactory.getLogger(InventoryEventHandler.class);
private final InventoryRepository inventoryRepository;
InventoryEventHandler(InventoryRepository inventoryRepository) {
this.inventoryRepository = inventoryRepository;
}
@ApplicationModuleListener
void onOrderPlaced(OrderPlacedEvent event) {
log.info("Received OrderPlacedEvent for order: {}", event.orderId());
for (OrderPlacedEvent.OrderItemDetail item : event.items()) {
InventoryItem inventory = inventoryRepository.findByProductId(item.productId())
.orElseThrow(() -> new RuntimeException("No inventory for product: " + item.productId()));
inventory.reduceStock(item.quantity());
inventoryRepository.save(inventory);
log.info("Reduced stock for product {} by {}", item.productId(), item.quantity());
}
}
}
package com.example.ecommerce.inventory;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/inventory")
public class InventoryController {
private final InventoryRepository inventoryRepository;
public InventoryController(InventoryRepository inventoryRepository) {
this.inventoryRepository = inventoryRepository;
}
@GetMapping("/{productId}")
public InventoryItem getStock(@PathVariable Long productId) {
return inventoryRepository.findByProductId(productId)
.orElseThrow(() -> new RuntimeException("No inventory for product: " + productId));
}
}
Your inventory module is fully wired up and reacting to order events. Next, you'll lock down all these API endpoints with JWT authentication so only authorized users can place orders or check stock.
Secure the API with JWT Authentication
Your modules are communicating through events. Orders trigger inventory reductions automatically, all without any direct method coupling between modules.
There is a problem, though. Right now, anyone with your server address can hit your API endpoints freely. In this step, you will add JWT authentication so only registered users with valid tokens can access the protected routes.
In this step, get ready to:
- Create a User entity and token service for generating signed JWTs.
- Configure Spring Security to protect all /api/** endpoints with JWT validation.
- Build register and login endpoints that issue tokens to authenticated users.
Create the User entity and token service
The auth module needs two foundational pieces. First, a User entity to store credentials in the database. Second, a TokenService that generates RSA-signed JWTs the client will send with every request.
- In IntelliJ, right-click the com.example.ecommerce.auth package in the Project view.
- Select New then Java Class.
- Name the class User and paste the following code:
package com.example.ecommerce.auth;
import jakarta.persistence.*;
@Entity
@Table(name = "app_users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String username;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String role;
protected User() {}
public User(String username, String password, String role) {
this.username = username;
this.password = password;
this.role = role;
}
public Long getId() { return id; }
public String getUsername() { return username; }
public String getPassword() { return password; }
public String getRole() { return role; }
}
What does this code do?
The @Table(name = "app_users") annotation avoids a conflict with PostgreSQL's reserved users keyword.
Each user has a username, a hashed password, and a role (like USER). The protected no-arg constructor is required by JPA but prevents accidental creation without credentials.
- Create another class in the same auth package named UserRepository and paste this code:
package com.example.ecommerce.auth;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
}
Why is UserRepository package-private?
The interface keyword without public means only classes within the auth package can use this repository. This enforces Spring Modulith's module boundary. Other modules cannot directly query user data.
- Create a third class in the auth package named TokenService and paste this code:
package com.example.ecommerce.auth;
import org.springframework.security.oauth2.jwt.*;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
@Service
public class TokenService {
private final JwtEncoder jwtEncoder;
public TokenService(JwtEncoder jwtEncoder) {
this.jwtEncoder = jwtEncoder;
}
public String generateToken(String username, String role) {
Instant now = Instant.now();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("ecommerce-modulith")
.issuedAt(now)
.expiresAt(now.plus(1, ChronoUnit.HOURS))
.subject(username)
.claim("role", role)
.build();
return jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
}
}
What does TokenService do?
The generateToken method builds a JWT claims set with the username as the subject, a 1-hour expiry, and a custom role claim. It then encodes the claims using Spring Security's JwtEncoder (which signs the token with an RSA private key).
The JwtEncoder bean does not exist yet. You will create it in the next substep inside SecurityConfig.
- Save all three files.
- You will not be able to start the application yet because TokenService needs a JwtEncoder bean that does not exist. The next substep provides it.
Seeing red squiggles in IntelliJ?
If IntelliJ shows unresolved import errors, make sure your pom.xml still includes spring-boot-starter-oauth2-resource-server and spring-boot-starter-security. Then right-click the project and select Maven then Reload Project.
help me resolve unresolved imports in my auth module
Configure Spring Security with JWT validation
The SecurityConfig class is the heart of your auth module. It generates an RSA key pair at startup, wires the JWT encoder and decoder beans, and defines which endpoints are public versus protected.
- Create a new class in the auth package named SecurityConfig and paste the following code:
package com.example.ecommerce.auth;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.web.SecurityFilterChain;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.util.UUID;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final RSAPublicKey publicKey;
private final RSAPrivateKey privateKey;
public SecurityConfig() {
KeyPair keyPair = generateRsaKeyPair();
this.publicKey = (RSAPublicKey) keyPair.getPublic();
this.privateKey = (RSAPrivateKey) keyPair.getPrivate();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**").permitAll()
.requestMatchers("/api/**").authenticated()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> {}))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
@Bean
public JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withPublicKey(this.publicKey).build();
}
@Bean
public JwtEncoder jwtEncoder() {
RSAKey rsaKey = new RSAKey.Builder(this.publicKey)
.privateKey(this.privateKey)
.keyID(UUID.randomUUID().toString())
.build();
JWKSource<SecurityContext> jwkSource = new ImmutableJWKSet<>(new JWKSet(rsaKey));
return new NimbusJwtEncoder(jwkSource);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public UserDetailsService userDetailsService(UserRepository userRepository) {
return username -> {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
return org.springframework.security.core.userdetails.User
.withUsername(user.getUsername())
.password(user.getPassword())
.roles(user.getRole())
.build();
};
}
private static KeyPair generateRsaKeyPair() {
try {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
return generator.generateKeyPair();
} catch (Exception e) {
throw new RuntimeException("Failed to generate RSA key pair", e);
}
}
}
What does SecurityConfig do?
- The constructor generates a 2048-bit RSA key pair in memory. The private key signs tokens. The public key verifies them.
- securityFilterChain permits all requests to /auth/** (so users can register and login without a token), requires authentication for /api/**, configures OAuth2 Resource Server with JWT, disables CSRF (stateless APIs do not need it), and sets session management to STATELESS.
- jwtDecoder uses NimbusJwtDecoder.withPublicKey() to verify incoming tokens.
- jwtEncoder wraps the RSA key pair in a JWK Set so NimbusJwtEncoder can sign new tokens.
- userDetailsService loads user records from UserRepository so Spring Security can verify credentials during login.
Why an in-memory RSA key pair?
Generating the key pair at startup keeps this project simple. In production, you would load keys from a file, environment variable, or secret vault so tokens remain valid across restarts.
- Save SecurityConfig.java.
- Stop the running application (click the red square in IntelliJ's Run panel) and restart it with the green play button.
- Open a terminal and test that the API is now protected by running:
curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/products
- You should see 401 printed. This confirms Spring Security is rejecting unauthenticated requests to the protected route.
Still seeing 200 or getting a connection error?
If you see 200, make sure you restarted the application after creating SecurityConfig.java. Spring does not hot-reload configuration classes.
If you get a connection refused error, check the IntelliJ console. The application may have failed to start due to a missing import or typo in SecurityConfig.
help me debug why my SecurityConfig is not enforcing JWT authentication
✔️ Awesome, I've got everything!
Great. Double check that SecurityConfig.java is saved and the app is running.
ⓧ I'd like to double check the full code
package com.example.ecommerce.auth;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.web.SecurityFilterChain;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.util.UUID;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final RSAPublicKey publicKey;
private final RSAPrivateKey privateKey;
public SecurityConfig() {
KeyPair keyPair = generateRsaKeyPair();
this.publicKey = (RSAPublicKey) keyPair.getPublic();
this.privateKey = (RSAPrivateKey) keyPair.getPrivate();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**").permitAll()
.requestMatchers("/api/**").authenticated()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> {}))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
@Bean
public JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withPublicKey(this.publicKey).build();
}
@Bean
public JwtEncoder jwtEncoder() {
RSAKey rsaKey = new RSAKey.Builder(this.publicKey)
.privateKey(this.privateKey)
.keyID(UUID.randomUUID().toString())
.build();
JWKSource<SecurityContext> jwkSource = new ImmutableJWKSet<>(new JWKSet(rsaKey));
return new NimbusJwtEncoder(jwkSource);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public UserDetailsService userDetailsService(UserRepository userRepository) {
return username -> {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
return org.springframework.security.core.userdetails.User
.withUsername(user.getUsername())
.password(user.getPassword())
.roles(user.getRole())
.build();
};
}
private static KeyPair generateRsaKeyPair() {
try {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
return generator.generateKeyPair();
} catch (Exception e) {
throw new RuntimeException("Failed to generate RSA key pair", e);
}
}
}
Create the authentication controller
With security configured, you need public endpoints that let users register an account and obtain a JWT. The AuthController handles both operations.
- Create a new class in the auth package named AuthController and paste the following code:
package com.example.ecommerce.auth;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/auth")
public class AuthController {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final TokenService tokenService;
public AuthController(UserRepository userRepository, PasswordEncoder passwordEncoder, TokenService tokenService) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.tokenService = tokenService;
}
@PostMapping("/register")
public ResponseEntity<Map<String, String>> register(@RequestBody AuthRequest request) {
if (userRepository.findByUsername(request.username()).isPresent()) {
return ResponseEntity.badRequest().body(Map.of("error", "Username already exists"));
}
User user = new User(request.username(), passwordEncoder.encode(request.password()), "USER");
userRepository.save(user);
return ResponseEntity.ok(Map.of("message", "User registered successfully"));
}
@PostMapping("/login")
public ResponseEntity<Map<String, String>> login(@RequestBody AuthRequest request) {
User user = userRepository.findByUsername(request.username())
.orElseThrow(() -> new RuntimeException("Invalid credentials"));
if (!passwordEncoder.matches(request.password(), user.getPassword())) {
throw new RuntimeException("Invalid credentials");
}
String token = tokenService.generateToken(user.getUsername(), user.getRole());
return ResponseEntity.ok(Map.of("token", token));
}
public record AuthRequest(String username, String password) {}
}
What does this code do?
- POST /auth/register checks if the username is taken, hashes the password with BCrypt, saves the user with role USER, and returns a success message.
- POST /auth/login looks up the user, verifies the raw password against the stored hash, then generates and returns a signed JWT.
- The AuthRequest record is a simple DTO that Jackson deserializes from the JSON body.
- Save AuthController.java.
- Restart the application.
- Register a test user by running:
curl -X POST http://localhost:8080/auth/register \
-H "Content-Type: application/json" \
-d '{"username":"testuser","password":"secret123"}'
- You should see a JSON response: {"message":"User registered successfully"}.
- Log in with the same user to get a token by running:
curl -X POST http://localhost:8080/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"testuser","password":"secret123"}'
- You should see a JSON response containing a token field with a long encoded string. Copy that token value.
- Access a protected endpoint using the token by running (replace the placeholder with your actual token):
curl http://localhost:8080/api/products \
-H "Authorization: Bearer your-token-here"
- You should see a 200 response with a JSON array (it may be empty if you have not created products since restarting with security active).
- Confirm that requests without the token still return 401 by running:
curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/products
- You should see 401 printed. JWT authentication is working.
Getting 401 even with the token?
Make sure the Authorization header value starts with Bearer (capital B, followed by a space) and then the full token string with no extra whitespace.
If you restarted the application between obtaining the token and using it, the RSA key pair regenerated and your old token is now invalid. Log in again to get a fresh token.
help me debug why my JWT token is being rejected by Spring Security
✔️ Awesome, I've got everything!
Great. Make sure all your auth module files are saved.
ⓧ I'd like to double check the full code
package com.example.ecommerce.auth;
import jakarta.persistence.*;
@Entity
@Table(name = "app_users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String username;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String role;
protected User() {}
public User(String username, String password, String role) {
this.username = username;
this.password = password;
this.role = role;
}
public Long getId() { return id; }
public String getUsername() { return username; }
public String getPassword() { return password; }
public String getRole() { return role; }
}
package com.example.ecommerce.auth;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
}
package com.example.ecommerce.auth;
import org.springframework.security.oauth2.jwt.*;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
@Service
public class TokenService {
private final JwtEncoder jwtEncoder;
public TokenService(JwtEncoder jwtEncoder) {
this.jwtEncoder = jwtEncoder;
}
public String generateToken(String username, String role) {
Instant now = Instant.now();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("ecommerce-modulith")
.issuedAt(now)
.expiresAt(now.plus(1, ChronoUnit.HOURS))
.subject(username)
.claim("role", role)
.build();
return jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
}
}
package com.example.ecommerce.auth;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.web.SecurityFilterChain;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.util.UUID;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final RSAPublicKey publicKey;
private final RSAPrivateKey privateKey;
public SecurityConfig() {
KeyPair keyPair = generateRsaKeyPair();
this.publicKey = (RSAPublicKey) keyPair.getPublic();
this.privateKey = (RSAPrivateKey) keyPair.getPrivate();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**").permitAll()
.requestMatchers("/api/**").authenticated()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> {}))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
@Bean
public JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withPublicKey(this.publicKey).build();
}
@Bean
public JwtEncoder jwtEncoder() {
RSAKey rsaKey = new RSAKey.Builder(this.publicKey)
.privateKey(this.privateKey)
.keyID(UUID.randomUUID().toString())
.build();
JWKSource<SecurityContext> jwkSource = new ImmutableJWKSet<>(new JWKSet(rsaKey));
return new NimbusJwtEncoder(jwkSource);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public UserDetailsService userDetailsService(UserRepository userRepository) {
return username -> {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
return org.springframework.security.core.userdetails.User
.withUsername(user.getUsername())
.password(user.getPassword())
.roles(user.getRole())
.build();
};
}
private static KeyPair generateRsaKeyPair() {
try {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
return generator.generateKeyPair();
} catch (Exception e) {
throw new RuntimeException("Failed to generate RSA key pair", e);
}
}
}
package com.example.ecommerce.auth;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/auth")
public class AuthController {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final TokenService tokenService;
public AuthController(UserRepository userRepository, PasswordEncoder passwordEncoder, TokenService tokenService) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.tokenService = tokenService;
}
@PostMapping("/register")
public ResponseEntity<Map<String, String>> register(@RequestBody AuthRequest request) {
if (userRepository.findByUsername(request.username()).isPresent()) {
return ResponseEntity.badRequest().body(Map.of("error", "Username already exists"));
}
User user = new User(request.username(), passwordEncoder.encode(request.password()), "USER");
userRepository.save(user);
return ResponseEntity.ok(Map.of("message", "User registered successfully"));
}
@PostMapping("/login")
public ResponseEntity<Map<String, String>> login(@RequestBody AuthRequest request) {
User user = userRepository.findByUsername(request.username())
.orElseThrow(() -> new RuntimeException("Invalid credentials"));
if (!passwordEncoder.matches(request.password(), user.getPassword())) {
throw new RuntimeException("Invalid credentials");
}
String token = tokenService.generateToken(user.getUsername(), user.getRole());
return ResponseEntity.ok(Map.of("token", token));
}
public record AuthRequest(String username, String password) {}
}
Your API is locked down. Only authenticated users with a valid JWT can access the product, order, and inventory endpoints. The entire e-commerce backend is now a secured modular monolith with event-driven communication between modules.
Secret mission
Module Verification and Integration Testing
Your modular monolith runs, but how do you know the module boundaries are actually enforced? Without automated verification, a developer could accidentally import an internal class from another module and silently break the architecture. In this mission, you will add tests that prove your module structure is sound and that events flow correctly between isolated modules.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally with Docker for the PostgreSQL database. There are no cloud resources or ongoing costs to worry about.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- PostgreSQL Docker container and pgdata volume.
- The ecommerce-modulith project folder on your Desktop.
- Docker Desktop (if installed for this project).
Keep everything running
No action needed. Choose this if you are still actively building or plan to extend the project soon.
- Your PostgreSQL container continues to run and retain all data in the pgdata volume.
- Your Spring Boot application can be restarted anytime from IntelliJ IDEA.
- Docker Desktop stays installed on your machine for future projects.
Pause - I'll come back to this later
Shut down running processes to free up memory, but keep all your files and data intact so you can resume later.
- Stop the Spring Boot application in IntelliJ IDEA by clicking the red square Stop button in the Run panel.
- Stop the Docker container by running this command from your project folder:
docker compose down
This stops and removes the container but preserves the pgdata volume. Your database data survives. When you come back, run docker compose up -d to start fresh.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes all data permanently.
- Stop the Spring Boot application in IntelliJ IDEA by clicking the red square Stop button in the Run panel.
- Stop the Docker container and remove the pgdata volume by running:
docker compose down -v
- Delete the project folder from your Desktop:
macOS
rm -rf ~/Desktop/ecommerce-modulith
Windows
rmdir /s /q %USERPROFILE%\Desktop\ecommerce-modulith
- (Optional) Uninstall Docker Desktop if you installed it solely for this project:
macOS
- Open Docker Desktop.
- Click the Troubleshoot icon in the top-right corner.
- Select Uninstall, then confirm by selecting Uninstall again.
- Move the Docker app to the Trash.
Windows
- Open Settings from the Windows Start menu.
- Select Apps, then Apps & features.
- Find Docker Desktop in the list and select Uninstall.
- Confirm by selecting Uninstall.
Well Done!
Well Done!
Nice work! You've just built a production-ready modular monolith e-commerce backend using Spring Modulith.
This architecture pattern sits in the sweet spot between tangled monoliths and complex microservices. You now have the skills to design loosely-coupled modules with guaranteed event delivery inside a single deployable application.
You've learned how to:
- Build a modular monolith with 4 Spring Modulith application modules (product, order, inventory, auth) that enforce clear boundaries between business domains.
- Implement event-driven communication using @ApplicationModuleListener and the Event Publication Registry to guarantee events are never lost, even if a listener fails.
- Secure all API endpoints with self-issued RSA-signed JWTs using Spring Security 6 OAuth2 Resource Server with NimbusJwtEncoder and NimbusJwtDecoder.
- Secret Mission: Verified module architecture with ApplicationModules.verify() and tested modules in isolation with @ApplicationModuleTest.
Ready to quiz yourself?