Skip to content
ArticlesBack EndJava

Building Modular Monoliths with Spring Modulith and Spring Boot 4

16 min readsalahtobok
Building Modular Monoliths with Spring Modulith and Spring Boot 4

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.

Architectural comparison between Traditional Monolith, Microservices, and Modular Monolith
Figure 1: Architectural spectrum comparing Traditional Monoliths, Microservices, and the balanced Modular Monolith approach.

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.

Spring Modulith package structure and domain boundary encapsulation rules
Figure 2: Package structure enforcing public module APIs and private internal encapsulation in Spring Modulith.

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 @NamedInterface annotation 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.

Sequence diagram of event-driven inter-module communication using ApplicationModuleListener
Figure 3: Asynchronous inter-module event communication using Spring Modulith ApplicationModuleListener.

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.

Technical architecture diagram of Spring Modulith Event Publication Registry, atomic database persistence, and asynchronous listener execution
Figure 4: The Event Publication Registry atomically persists events during the business transaction and marks them completed upon listener success.

Figure 4 illustrates this robust transactional lifecycle:

  1. Atomic Persistence: During order creation, Spring Modulith intercepts the event and writes an entry into the EVENT_PUBLICATION table within the same local database transaction as the order record.
  2. Listener Dispatch: Once the transaction commits, Spring Modulith dispatches the event to registered @ApplicationModuleListener methods.
  3. Completion Hook: When the listener finishes successfully, the registry updates the event’s COMPLETION_DATE column in the database.
  4. 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.

Spring Modulith ArchUnit verification test and automated documentation generation architecture
Figure 5: Spring Modulith static bytecode analysis verifying module isolation and exporting living PlantUML documentation.

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:

  1. ArchUnit Modularity Verification: Validates module boundaries and rules statically in milliseconds using ApplicationModules.of(...).verify().
  2. Unit Tests for Listeners: Test event reaction logic in isolation with Mockito. For example, verify that InventoryService.on(event) calls inventoryRepository.save() without bootstrapping Spring.
  3. Web Slice Tests (@WebMvcTest): Test REST API endpoints independently with MockMvc and @MockitoBean to 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)

Official References

Comments