Microservices have dominated software architecture discussions for over a decade. Many organizations adopted microservices to solve scaling and organizational challenges. However, distributed microservice architectures introduce severe operational complexity. Teams suddenly face network latency, distributed transactions, complex container orchestration, and difficult debugging.
Because of these operational costs, the software industry is embracing a practical alternative: the Modular Monolith. A modular monolith gives developers the best of both worlds. You build a single deployment unit that contains strictly separated and encapsulated domain modules. You gain clear domain boundaries without paying the price of network overhead or distributed systems management.
Spring Modulith is the official Spring framework project that makes modular monoliths easy to build, test, and document. In this comprehensive guide, we explore how to build a production-grade modular monolith using Spring Modulith and Spring Boot 4 on Java 25. We implement an end-to-end e-commerce system with order and inventory modules to examine encapsulation, transactional events, and automated architectural verification.
The Architectural Spectrum: Monoliths vs. Microservices vs. Modular Monoliths
Software architecture exists on a continuum. Choosing an architectural style requires balancing operational complexity against organizational scalability.

As illustrated in Figure 1, software architectures typically fall into three major categories:
1. The Traditional Monolith (“Big Ball of Mud”)
Traditional monolithic applications start with good intentions. However, standard Java package access rules make encapsulation difficult. When every class is declared public, developers easily inject services across packages. Over time, code turns into tightly coupled spaghetti.
A change in the inventory system can unexpectedly break the billing or order processing logic. Deployments become scary, and automated tests slow down significantly.
2. Microservices
Microservices solve coupling by placing physical boundaries between domains. Each service runs in its own process, manages its own database, and communicates across HTTP or message brokers. While this allows independent deployments, it creates substantial operational overhead.
Teams must manage API gateways, service discovery, distributed tracing, and eventual consistency. Implementing simple workflows requires distributed patterns such as the Saga Pattern or complex messaging infrastructure. For many business applications, this distributed tax outweighs the benefits.
3. The Modular Monolith
A modular monolith provides a pragmatic middle path. It deploys as a single runnable artifact (a single Spring Boot executable JAR). Internally, the code is organized into strictly isolated domain modules.
Communication between modules happens in-memory with zero network latency. If a business domain later outgrows the monolith, its clean boundaries make extracting it into a microservice straightforward and low-risk.
Spring Modulith Core Concepts & Boundary Encapsulation
Spring Modulith establishes conventions for organizing Spring Boot projects into well-structured modules. It extends standard Spring application contexts with structural rules and automated validation.

Figure 2 shows how Spring Modulith models an application. Each direct subpackage located immediately below the root application class is treated as an independent application module.
Public APIs vs. Internal Implementation
Spring Modulith enforces clear visibility rules across packages:
- Module API Package: Any class declared in the root package of a module (such as
com.webcodein.ecommerce.order) is considered public. Other modules are allowed to reference these types. - Internal Packages: Any nested subpackage (such as
com.webcodein.ecommerce.order.internal) is treated as private. Spring Modulith prohibits other modules from accessing classes inside internal packages. - Named Interfaces: You can define custom exposed packages using the
@NamedInterfaceannotation to group related public contracts cleanly. - Explicit Module Dependencies: Modules can declare allowed dependencies explicitly using
@ApplicationModule(allowedDependencies = {"inventory"}). Any dependency outside this list causes an immediate verification failure.
In standard Java projects, developers often rely on the public access modifier because classes need to be accessed by Spring configuration. Unfortunately, this makes those classes visible to every package in the application. Spring Modulith bridges this gap by enforcing package boundaries at build time through static analysis.
These boundaries align naturally with the principles of Domain-Driven Design (DDD) and Clean Architecture in Spring Boot 4.
System Prerequisites
To follow along with Spring Modulith in production, ensure your development environment satisfies these requirements:
- Java 25 (or 21 LTS): Modern record types, pattern matching, and virtual thread execution.
- Spring Boot 4.0.0: Modernized Jakarta EE persistence, web, and Actuator foundations.
- Spring Modulith 1.3+ / 2.0: Core module scanning, transactional event publication registry, and ArchUnit verification.
- Maven 3.9+: Dependency resolution and test execution runner.
Asynchronous Inter-Module Communication via Events
When one module needs another module to perform work, developers often inject services directly. For example, the OrderService might inject InventoryService. This direct dependency creates tight coupling.
If the inventory service changes or fails, order placement is directly impacted. Spring Modulith strongly promotes event-driven communication to preserve loose coupling between modules.

As shown in Figure 3, the order module publishes an event when an order is created. The inventory module listens for that event and updates stock levels independently.
The @ApplicationModuleListener Annotation
Spring Framework provides standard @EventListener and @TransactionalEventListener annotations. Spring Modulith introduces @ApplicationModuleListener, which is an opinionated meta-annotation.
Under the hood, @ApplicationModuleListener combines three critical behaviors:
- @Async: Executes the listener method on a background thread so the original transaction completes without delay.
- @Transactional(propagation = REQUIRES_NEW): Executes the listener within its own isolated database transaction.
- @TransactionalEventListener(phase = AFTER_COMMIT): Guarantees the listener runs only after the publishing transaction has successfully committed to the database.
If the publishing transaction rolls back, the event is never processed. This prevents phantom operations and data inconsistencies across modules.
The Event Publication Registry & Transactional Outbox
What happens if an asynchronous listener fails or the server crashes right after the order is committed? In standard Spring event systems, in-flight in-memory events are permanently lost.
Spring Modulith includes the Event Publication Registry. It implements the transactional outbox pattern automatically without requiring an external message broker like Kafka.

Figure 4 illustrates this robust transactional lifecycle:
- Atomic Persistence: During order creation, Spring Modulith intercepts the event and writes an entry into the
EVENT_PUBLICATIONtable within the same local database transaction as the order record. - Listener Dispatch: Once the transaction commits, Spring Modulith dispatches the event to registered
@ApplicationModuleListenermethods. - Completion Hook: When the listener finishes successfully, the registry updates the event’s
COMPLETION_DATEcolumn in the database. - Crash Resilience: If the server restarts before the listener completes, uncompleted publications remain in the database. Spring Modulith automatically reschedules unfulfilled events on application startup.
Database Schema & Incomplete Publications Management
The EVENT_PUBLICATION table tracks the state of every event listener invocation. It contains critical columns:
- ID: Unique primary key identifier for the publication record.
- EVENT_TYPE: Fully qualified Java class name of the published event (for example,
com.webcodein.ecommerce.order.OrderPlacedEvent). - PUBLICATION_DATE: Timestamp when the event was recorded within the publishing transaction.
- SERIALIZED_EVENT: Complete JSON payload of the event object.
- LISTENER_ID: Identifier of the targeted listener bean and method. If three separate listeners subscribe to the same event, three publication rows are created.
- COMPLETION_DATE: Timestamp when the listener successfully acknowledged the event. Null while pending.
Spring Modulith provides management beans for inspecting and re-submitting unfulfilled events. You can inject IncompleteEventPublications to write admin dashboards or scheduled background jobs:
package com.webcodein.ecommerce;
import org.springframework.modulith.events.IncompleteEventPublications;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Duration;
@Component
public class EventRegistryMaintenance {
private final IncompleteEventPublications incompleteEvents;
public EventRegistryMaintenance(IncompleteEventPublications incompleteEvents) {
this.incompleteEvents = incompleteEvents;
}
@Scheduled(fixedRate = 60000)
public void resubmitStaleEvents() {
// Automatically republish events older than 1 minute that failed or timed out
incompleteEvents.resubmitIncompletePublicationsOlderThan(Duration.ofMinutes(1));
}
}
For high-throughput distributed applications requiring external streaming, see our companion article on the Kafka Transactional Outbox Pattern in Spring Boot 4.
Step-by-Step Implementation: Building an E-Commerce Monolith
Let us implement a complete working e-commerce application. We will build two modules: order and inventory.
Step 1: Maven Dependencies
Add the Spring Modulith Bill of Materials (BOM) and starter dependencies to your pom.xml:
<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.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-events-api</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</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>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-bom</artifactId>
<version>1.3.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
Step 2: The Order Module
The order module defines the domain model, public event record, service, and REST controller. We place them in com.webcodein.ecommerce.order.
First, define the domain event as an immutable Java 25 record:
package com.webcodein.ecommerce.order;
public record OrderPlacedEvent(Long orderId, String productCode, int quantity) {}
Next, implement the JPA Order entity and repository:
package com.webcodein.ecommerce.order;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String productCode;
private int quantity;
private String status;
public Order() {}
public Order(String productCode, int quantity) {
this.productCode = productCode;
this.quantity = quantity;
this.status = "PENDING";
}
public Long getId() { return id; }
public String getProductCode() { return productCode; }
public int getQuantity() { return quantity; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
}
Create the package-private OrderRepository:
package com.webcodein.ecommerce.order;
import org.springframework.data.jpa.repository.JpaRepository;
interface OrderRepository extends JpaRepository<Order, Long> {}
Implement the OrderService, which publishes the domain event inside a Spring transaction:
package com.webcodein.ecommerce.order;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final ApplicationEventPublisher events;
public OrderService(OrderRepository orderRepository, ApplicationEventPublisher events) {
this.orderRepository = orderRepository;
this.events = events;
}
@Transactional
public Order placeOrder(String productCode, int quantity) {
Order order = new Order(productCode, quantity);
order = orderRepository.save(order);
events.publishEvent(new OrderPlacedEvent(order.getId(), productCode, quantity));
return order;
}
}
Finally, expose the REST endpoint via OrderController:
package com.webcodein.ecommerce.order;
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("/api/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
public Order placeOrder(@RequestBody OrderRequest request) {
return orderService.placeOrder(request.productCode(), request.quantity());
}
}
record OrderRequest(String productCode, int quantity) {}
Step 3: The Inventory Module
The inventory module lives in com.webcodein.ecommerce.inventory. It maintains product inventory and reacts to incoming orders.
Define the InventoryItem entity:
package com.webcodein.ecommerce.inventory;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "inventory_items")
public class InventoryItem {
@Id
private String productCode;
private int stockQuantity;
public InventoryItem() {}
public InventoryItem(String productCode, int stockQuantity) {
this.productCode = productCode;
this.stockQuantity = stockQuantity;
}
public String getProductCode() { return productCode; }
public int getStockQuantity() { return stockQuantity; }
public void reduceStock(int amount) {
if (this.stockQuantity < amount) {
throw new IllegalArgumentException("Not enough stock for product: " + productCode);
}
this.stockQuantity -= amount;
}
}
Create the package-private InventoryRepository:
package com.webcodein.ecommerce.inventory;
import org.springframework.data.jpa.repository.JpaRepository;
interface InventoryRepository extends JpaRepository<InventoryItem, String> {}
Implement the InventoryService with the @ApplicationModuleListener:
package com.webcodein.ecommerce.inventory;
import com.webcodein.ecommerce.order.OrderPlacedEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Service;
@Service
public class InventoryService {
private static final Logger log = LoggerFactory.getLogger(InventoryService.class);
private final InventoryRepository inventoryRepository;
public InventoryService(InventoryRepository inventoryRepository) {
this.inventoryRepository = inventoryRepository;
}
@ApplicationModuleListener
void on(OrderPlacedEvent event) {
log.info("Received OrderPlacedEvent for order: {}", event.orderId());
inventoryRepository.findById(event.productCode()).ifPresent(item -> {
item.reduceStock(event.quantity());
inventoryRepository.save(item);
log.info("Reduced stock for product: {}, remaining: {}", event.productCode(), item.getStockQuantity());
});
}
}
Architectural Verification with ArchUnit and Living Documentation
Convention without automated enforcement is wishful thinking. Spring Modulith integrates with ArchUnit to verify module boundaries during continuous integration.

Figure 5 shows the verification pipeline. During standard unit testing, ArchUnit scans class files and verifies that no code breaches defined module boundaries.
Writing ModularityTests
Create a test class in com.webcodein.ecommerce. A single method call inspects the entire project:
package com.webcodein.ecommerce;
import org.junit.jupiter.api.Test;
import org.springframework.modulith.core.ApplicationModules;
import org.springframework.modulith.docs.Documenter;
class ModularityTests {
ApplicationModules modules = ApplicationModules.of(EcommerceApplication.class);
@Test
void verifiesModularStructure() {
modules.verify();
}
@Test
void createModuleDocumentation() {
new Documenter(modules)
.writeModulesAsPlantUml()
.writeIndividualModulesAsPlantUml();
}
}
When you run modules.verify(), Spring Modulith checks that:
- No module accesses internal classes or package-private components of another module.
- There are no circular references between modules.
- All module event listeners and publishers conform to clean interaction contracts.
For more custom structural constraints, read our guide on Enforcing Architecture Rules with ArchUnit in Spring Boot.
Living Documentation & Module Canvases
The Documenter class automatically generates architectural documentation directly from your compiled code. When createModuleDocumentation() runs, it exports diagrams and markdown files into target/modulith-docs/:
- components.puml: A top-level PlantUML component diagram visualizing all application modules and their inter-module event relationships.
- module-order.puml & module-inventory.puml: Granular diagrams detailing the internal components, public API classes, and exposed event types for each module.
- canvases.md: Comprehensive Domain-Driven Design (DDD) module canvases summarizing aggregate roots, services, published events, and listener handlers.
Because these artifacts are generated from bytecode during every Maven test run, your technical documentation is always synchronized with production reality.
Comprehensive Testing Strategy for Modules
Testing in a modular monolith is significantly faster and easier than testing microservices. You do not need to orchestrate multiple containers or mock complex HTTP networks. We recommend three complementary testing tiers:
- ArchUnit Modularity Verification: Validates module boundaries and rules statically in milliseconds using
ApplicationModules.of(...).verify(). - Unit Tests for Listeners: Test event reaction logic in isolation with Mockito. For example, verify that
InventoryService.on(event)callsinventoryRepository.save()without bootstrapping Spring. - Web Slice Tests (@WebMvcTest): Test REST API endpoints independently with
MockMvcand@MockitoBeanto verify HTTP status codes and JSON serialization contracts.
Comparative Analysis: Architecture Styles Evaluated
Selecting the right architecture requires comparing key technical dimensions objectively:
| Dimension | Modular Monolith (Spring Modulith) | Microservices | Traditional Monolith |
|---|---|---|---|
| Deployment Unit | Single executable JAR | Dozens of independent container services | Single executable JAR or WAR |
| Inter-Module Latency | Sub-millisecond (in-memory JVM execution) | High (network hops, serialization, HTTP/gRPC) | Sub-millisecond (direct in-memory calls) |
| Boundary Enforcement | Strict automated ArchUnit tests at build time | Physical network separation | None (rapid architectural erosion) |
| Data Consistency | Local atomic transactions & outbox registry | Eventual consistency & distributed sagas | Shared database across all packages |
| Operational Overhead | Minimal (one CI/CD pipeline, single database) | High (Kubernetes, service mesh, API gateways) | Minimal (single deployment process) |
| Refactoring Cost | Low (IDE refactoring within same codebase) | High (breaking API changes across repositories) | High (unraveling coupled dependencies) |
For teams with small to medium engineering groups, the modular monolith provides the cleanest path to sustainable velocity without unnecessary infrastructure overhead.
Real-World Enterprise Use Cases
Spring Modulith delivers immediate architectural value in several common enterprise scenarios:
1. Greenfield Enterprise Applications
When starting a new project, domain boundaries are uncertain and evolve quickly. Building microservices too early often leads to wrong service boundaries. A modular monolith allows teams to move fast within a single repository while keeping modules clean. If a module later needs independent scaling, it can be extracted with minimal friction.
2. Core Banking & Financial Ledgers
Financial ledgers require strict ACID guarantees and audit compliance. Splitting payments, accounts, and ledger modules into distributed microservices introduces complex failure states. With Spring Modulith, each financial domain remains encapsulated, while critical operations benefit from atomic local database commits.
3. High-Volume E-Commerce Backends
E-commerce workflows involve order processing, inventory reservations, customer loyalty points, and shipping notifications. Using @ApplicationModuleListener, the checkout process completes in milliseconds. Secondary actions (such as sending confirmation emails) execute asynchronously without blocking the checkout flow.
Architectural Advantages and Limitations
Every architectural choice involves trade-offs. Here is an objective assessment of Spring Modulith:
Advantages
- Fast Developer Feedback: Tests execute in seconds on local developer machines without spinning up Docker clusters.
- Zero Network Failures: In-memory communication eliminates connection timeouts, circuit breaking, and network retries.
- Automated Living Documentation: Generates accurate PlantUML component models directly from bytecode.
- Simplified Operations: A single container image, one database connection pool, and one monitoring dashboard.
Limitations
- Shared Process Lifecycle: A fatal JVM out-of-memory error takes down all modules simultaneously.
- Shared Technology Stack: All modules must share the same Java and Spring Boot runtime versions.
- Vertical Scaling Bottlenecks: If one module requires extreme CPU or memory resources, the entire application must scale together.
Common Mistakes and Production Best Practices
When architecting applications with Spring Modulith, watch out for these three common pitfalls:
Pitfall 1: Leaking JPA Entities Across Module Boundaries
The Mistake: Passing a JPA entity from one module into another module’s service or event. This couples database schema structures and causes lazy-loading exceptions across transactions.
The Fix: Never expose JPA entities in public module APIs. Always publish immutable Java 25 records containing only primitive types or domain identifiers:
// WRONG: Exposing JPA entity
public record OrderPlacedEvent(Order order) {}
// CORRECT: Exposing immutable scalar values
public record OrderPlacedEvent(Long orderId, String productCode, int quantity) {}
Pitfall 2: Direct Synchronous Service Injection
The Mistake: Injecting InventoryService directly into OrderService, and injecting OrderService into InventoryService. This creates a circular dependency that breaks modular verification.
The Fix: Decouple the modules using Spring domain events. The publishing module only depends on Spring’s ApplicationEventPublisher, never on the consuming module’s classes.
Pitfall 3: Missing Persistent Outbox in Production
The Mistake: Relying on default in-memory event publication in high-stakes production systems. If the JVM terminates unexpectedly, unfulfilled event listeners never run.
The Fix: Include the Spring Modulith JDBC or JPA event publication starter in your production dependencies:
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-jdbc</artifactId>
</dependency>
This automatically provisions the EVENT_PUBLICATION schema and guarantees at-least-once delivery semantics for all domain events.
Practical Conclusion
Spring Modulith provides a compelling, mature approach to enterprise backend development. By elevating package structures to verified architectural boundaries, Spring Boot 4 empowers development teams to build clean, maintainable systems that scale effectively without distributed complexity.
Through automated boundary verification, asynchronous transactional events, and outbox persistence, Spring Modulith proves that you do not need microservices to build modular, production-ready enterprise software.
Frequently Asked Questions
How does Spring Modulith identify module boundaries?
By default, Spring Modulith considers every direct subpackage located immediately beneath the main application class (annotated with @SpringBootApplication) as a distinct application module. Classes in root module packages are public, while subpackages are private.
How is @ApplicationModuleListener different from @EventListener?
Standard @EventListener executes synchronously within the caller’s transaction. In contrast, @ApplicationModuleListener executes asynchronously in an isolated transaction only after the publishing transaction has committed successfully.
Can a Spring Modulith application be split into microservices later?
Yes. Because modules communicate exclusively via event records and public interfaces, extracting a module into an independent microservice requires only wrapping the public interface with an HTTP client or replacing internal events with Kafka messages.
Does Spring Modulith require ArchUnit in production?
No. ArchUnit is used exclusively during unit and integration test execution (in test scope). There is zero ArchUnit runtime overhead in production deployments.
How does Spring Modulith handle listener failures?
When the Event Publication Registry is configured with a database, failed or uncompleted events remain in the EVENT_PUBLICATION table. Spring Modulith can automatically re-dispatch uncompleted publications on application restart or via scheduled background jobs.
Companion Workshop Project
The complete, compilable companion code for this guide is available on GitHub. It includes the working e-commerce application, automated ArchUnit boundary tests, MockMvc controller slice tests, and Dockerfile:
Browse Companion Project on GitHub (architecture/spring-modulith-modular-monoliths-spring-boot-4)
