Skip to content

What Is a Modular Monolith (Modulith) in Spring Boot? A Practical Guide

Purpose

This post explains the modular monolith architecture in Spring Boot and shows how to implement it with Spring Modulith.

Our team faced a dilemma when starting a new e-commerce project:

Architecture Decision Log
Option 1: Traditional Monolith
+ Simple deployment
+ Easy debugging
- Becomes "big ball of mud" over time
- No clear boundaries between features
Option 2: Microservices
+ Independent scaling
+ Clear service boundaries
- Distributed system complexity
- Network latency issues
- Need DevOps expertise
Option 3: Modular Monolith
+ Single deployment unit
+ Enforced module boundaries
+ Can evolve to microservices
? How does this actually work?

I chose Option 3. Here’s what I learned.

Environment

  • Spring Boot 3.4.x
  • Spring Modulith 1.3.0
  • Java 21
  • Maven 3.9.x

The Problem: Why Neither Monolith Nor Microservices Worked

I’ve built all three architectures: monoliths, microservices, and modular monoliths. Each has trade-offs.

Traditional Monolith Problems

Our first project started as a monolith. Six months later, the codebase looked like this:

Monolith Package Structure (The Mess)
com.example.ecommerce/
├── controller/
│ ├── OrderController.java
│ ├── ProductController.java
│ ├── UserController.java
│ └── PaymentController.java # All mixed together
├── service/
│ ├── OrderService.java # Calls ProductService, UserService, PaymentService
│ ├── ProductService.java # Calls OrderService (circular dependency!)
│ ├── UserService.java
│ └── PaymentService.java
├── repository/
│ └── AllRepositoriesMixed.java # No ownership
├── model/
│ └── AllEntities.java # Everything visible to everything
└── util/
└── SharedUtils.java # God class

Problems I encountered:

  • OrderService directly called ProductService methods - no interface, just tight coupling
  • A change to User entity broke OrderService tests (hidden dependency)
  • Three different teams touched the same files, causing constant merge conflicts
  • No clear ownership - who owns the “Order” domain?

Microservices Overkill

For the next project, I tried microservices immediately. That was worse.

Microservices Complexity
Order Service ─────┐
│ HTTP call (200ms latency)
v
Inventory Service ─┐
│ HTTP call (150ms latency)
v
Payment Service ───┐
│ HTTP call (100ms latency)
v
Email Service
Total latency: 450ms for a single order flow

Issues I faced:

  • Distributed transactions for order processing (Saga pattern was complex)
  • Service discovery setup (Kubernetes, Consul, or Eureka?)
  • Monitoring across 8 services (Prometheus, Grafana, distributed tracing)
  • A junior developer pushed to prod and broke inter-service communication
  • Debugging required checking logs in 4 different services

One Reddit comment hit home:

“Don’t start with microservices too soon. It’s often not beneficial.”

The same post warned about “architecture drift” - microservices that become coupled monoliths over time because teams bypass service boundaries.

The Solution: Modular Monolith with Spring Modulith

I needed something between these extremes. Spring Modulith provides:

  1. Enforced boundaries at compile/test time
  2. Single deployment unit (no distributed complexity)
  3. Clear module interfaces (each module is potentially extractable)
  4. Event-driven communication (loose coupling between modules)

Step 1: Define Module Boundaries Based on Business Capabilities

Instead of organizing by technical layers, I organized by business domains:

Modular Monolith Package Structure
com.example.ecommerce/
├── order/ # Order Module (bounded context)
│ ├── package-info.java # Module definition
│ ├── OrderService.java # Public API only
│ ├── OrderCreatedEvent.java # Events published
│ └── internal/ # Hidden from other modules
│ ├── OrderRepository.java
│ ├── OrderEntity.java
│ └── OrderProcessor.java
├── inventory/ # Inventory Module
│ ├── package-info.java
│ ├── InventoryService.java
│ └── internal/
│ ├── InventoryRepository.java
│ └── StockChecker.java
├── payment/ # Payment Module
│ ├── package-info.java
│ ├── PaymentService.java
│ └── internal/
│ ├── PaymentGateway.java
│ └── TransactionLog.java
├── notification/ # Notification Module
│ ├── package-info.java
│ ├── NotificationService.java
│ └── internal/
│ ├── EmailSender.java
│ └── SmsSender.java
└── shared/ # Shared infrastructure (not a business module)
├── events/ # Event infrastructure
└── config/ # Common configuration

Key principle: Each module’s internal package is invisible to other modules. Spring Modulith enforces this.

Step 2: Add Spring Modulith Dependencies

pom.xml
<dependencies>
<!-- Spring Modulith Core -->
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-core</artifactId>
<version>1.3.0</version>
</dependency>
<!-- Spring Modulith Test Support (boundary verification) -->
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-test</artifactId>
<version>1.3.0</version>
<scope>test</scope>
</dependency>
</dependencies>

Step 3: Define Module Metadata

Each module needs a package-info.java to declare its existence and dependencies:

src/main/java/com/example/ecommerce/order/package-info.java
@org.springframework.modulith.Module(
type = ModuleType.OPEN // Allows controlled access
)
package com.example.ecommerce.order;
import org.springframework.modulith.ModuleType;

For modules with explicit dependencies:

src/main/java/com/example/ecommerce/payment/package-info.java
@org.springframework.modulith.Module(
type = ModuleType.OPEN,
allowedDependencies = {"order"} // Payment depends on Order events
)
package com.example.ecommerce.payment;

Step 4: Write Boundary Verification Tests

This is the key feature. Spring Modulith automatically detects boundary violations:

src/test/java/com/example/ecommerce/ModularityTests.java
@SpringBootTest
class ModularityTests {
@Test
void verifiesModularStructure() {
ApplicationModules modules = ApplicationModules.of(EcommerceApplication.class);
// This fails if any module violates its boundaries
modules.verify();
}
@Test
void documentsModuleStructure() {
ApplicationModules modules = ApplicationModules.of(EcommerceApplication.class);
// Generates documentation of module relationships
modules.printModules();
}
}

When I accidentally accessed order.internal.OrderRepository from inventory package:

Test Failure Output
org.springframework.modulith.ModuleViolationException:
Module 'inventory' illegally accesses module 'order':
- com.example.ecommerce.inventory.InventoryService
-> com.example.ecommerce.order.internal.OrderRepository
Allowed dependencies for 'inventory': []
Actual violation: Accessing internal package of 'order' module

The test caught this before it reached production. This enforcement is what makes modular monolith different from “just packages.”

Step 5: Implement Module Services with Clear APIs

Each module exposes only what other modules need:

src/main/java/com/example/ecommerce/order/OrderService.java
package com.example.ecommerce.order;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.context.ApplicationEventPublisher;
import com.example.ecommerce.order.internal.OrderRepository;
import com.example.ecommerce.order.internal.OrderEntity;
@Service
public class OrderService {
private final OrderRepository repository;
private final ApplicationEventPublisher eventPublisher;
public OrderService(OrderRepository repository,
ApplicationEventPublisher eventPublisher) {
this.repository = repository;
this.eventPublisher = eventPublisher;
}
// Public API - other modules can call this
@Transactional
public OrderDto createOrder(CreateOrderRequest request) {
OrderEntity order = new OrderEntity(request.items(), request.userId());
OrderEntity saved = repository.save(order);
// Publish event for other modules to react
eventPublisher.publishEvent(
new OrderCreatedEvent(saved.getId(), saved.getUserId())
);
return new OrderDto(saved.getId(), saved.getStatus());
}
// Public API - query capability
public Optional&lt;OrderDto&gt; findById(Long orderId) {
return repository.findById(orderId)
.map(o -> new OrderDto(o.getId(), o.getStatus()));
}
}

Note: OrderRepository and OrderEntity are in the internal package. Other modules cannot see them.

Step 6: Cross-Module Communication via Events

Modules should not call each other directly. They communicate through events:

src/main/java/com/example/ecommerce/order/OrderCreatedEvent.java
package com.example.ecommerce.order;
public record OrderCreatedEvent(Long orderId, Long userId) {}

Inventory module listens to this event:

src/main/java/com/example/ecommerce/inventory/internal/OrderEventListener.java
package com.example.ecommerce.inventory.internal;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Component;
import com.example.ecommerce.order.OrderCreatedEvent;
@Component
class OrderEventListener {
private final InventoryService inventoryService;
OrderEventListener(InventoryService inventoryService) {
this.inventoryService = inventoryService;
}
@ApplicationModuleListener
void onOrderCreated(OrderCreatedEvent event) {
// Reserve inventory for the order
inventoryService.reserveForOrder(event.orderId());
}
}

Using @ApplicationModuleListener instead of @EventListener ensures:

  • The event is processed within the Inventory module’s transaction boundary
  • Spring Modulith tracks the event flow between modules

Step 7: Module Diagram Visualization

Spring Modulith generates documentation:

Module Structure Diagram (Generated)
+------------------+ +------------------+
| ORDER | | INVENTORY |
| | | |
| OrderService |---->| InventoryService |
| OrderCreatedEvent| | |
+------------------+ +------------------+
| |
v v
+------------------+ +------------------+
| PAYMENT | | NOTIFICATION |
| | | |
| PaymentService | | NotificationSvc |
+------------------+ +------------------+
Events:
OrderCreatedEvent -> Inventory, Payment, Notification
PaymentCompletedEvent -> Order, Notification

This diagram shows the actual module relationships based on event subscriptions.

Why This Approach Works

Benefits Over Traditional Monolith

Comparison: Monolith vs Modular Monolith
Traditional Monolith:
- OrderService calls ProductService.java directly
- Hidden dependencies through shared entities
- Tests break when unrelated code changes
Modular Monolith:
- OrderService listens to ProductChangedEvent
- Dependencies declared in package-info.java
- Boundary tests prevent accidental coupling

Benefits Over Microservices

Comparison: Microservices vs Modular Monolith
Microservices:
- Network calls between services
- Distributed transactions
- Separate deployment pipelines
- Need service discovery, load balancing
Modular Monolith:
- In-memory event dispatch
- Single database transaction
- One deployment unit
- No infrastructure overhead

The key insight: I get bounded context separation without distributed system complexity.

Common Mistakes I Made

Mistake 1: Creating “Modules” Without Enforcement

Initially, I just created separate packages:

Wrong Approach (No Enforcement)
package com.example.ecommerce.order;
public class OrderService {
// Directly accessing inventory internal classes
InventoryRepository inventoryRepo; // This should be illegal!
}

Without Spring Modulith tests, this looks like modules but acts like a monolith. The verify() test catches this.

Mistake 2: Allowing Hidden Database Dependencies

I once created a shared database table that multiple modules accessed:

Wrong: Shared Database Dependency
// Order module has OrderEntity with userId column
// User module reads the same userId from OrderEntity directly
// This creates hidden coupling!
@Repository
public class UserStatsRepository {
@Query("SELECT COUNT(*) FROM orders WHERE user_id = ?")
Long countOrdersByUser(Long userId); // Reading from order's table!
}

Each module should own its data. If User module needs order count, Order module should expose an API:

Correct: Expose API Instead
// Order module exposes:
public interface OrderService {
Long countOrdersByUser(Long userId);
}
// User module calls the API, not the database directly

Mistake 3: Making Modules Too Granular

I initially created 15 modules for a simple application:

Too Many Modules
modules:
- user-authentication
- user-profile
- user-settings
- user-notifications # Over-splitting user domain
- order-creation
- order-fulfillment
- order-history # Over-splitting order domain
- ...

Better approach: Start with coarse-grained modules based on DDD bounded contexts:

Correct Module Granularity
modules:
- user (contains auth, profile, settings)
- order (contains creation, fulfillment, history)
- inventory
- payment

Split only when a module genuinely needs independent deployment.

Mistake 4: Exposing Internal Classes

I exposed too many classes from modules:

Wrong: Leaky Abstraction
package com.example.ecommerce.order;
// This should be in internal/!
public class OrderEntity { ... } // Visible to all modules
// Other modules can now see implementation details

Only expose DTOs and service interfaces:

Correct: Minimal Public API
package com.example.ecommerce.order;
public class OrderService { ... } // Public API
public record OrderDto(...) {} // Data transfer object
public record OrderCreatedEvent(...) {} // Event
// Everything else goes in internal/

When to Extract Modules to Microservices

The modular monolith is a stepping stone. I can extract modules when:

  1. Independent scaling needed: Payment processing needs more instances than User management
  2. Team size grows: A dedicated team for Inventory module
  3. Deployment cadence differs: Order module changes frequently, others rarely
  4. Technology mismatch: Payment module needs Python for ML-based fraud detection

Extraction path:

Module to Microservice Extraction
Phase 1: Modular Monolith
order/
inventory/
payment/
Phase 2: Extract Payment as separate service
order/ (still in monolith)
inventory/ (still in monolith)
payment-service/ (separate deployable, exposes REST API)
Phase 3: Replace direct calls with HTTP
order/ calls payment-service via REST client
Events become HTTP callbacks or message queue

The module structure prepared this extraction - Payment already has clear boundaries.

Practical Implementation Tips

Use Spring Modulith’s Documentation Generation

Add to application.yml:

src/main/resources/application.yml
spring:
modulith:
documentation:
enabled: true
output-dir: target/modulith-docs

Running tests generates:

  • Module relationship diagrams
  • Event flow documentation
  • Dependency matrices

Run Boundary Tests in CI Pipeline

.github/workflows/ci.yml
jobs:
test:
steps:
- name: Verify Module Boundaries
run: mvn test -Dtest=ModularityTests
- name: Fail on Boundary Violations
if: failure()
run: echo "Module boundaries violated - check package-info.java"

Keep Shared Module Minimal

Shared Module Contents
shared/
├── events/ # Event infrastructure
├── config/ # Common Spring configuration
├── exceptions/ # Domain exceptions used across modules
└── dto/ # DTOs for external API (not internal)
DO NOT put:
- Business entities (each module owns its data)
- Repository interfaces (belongs to specific module)
- Service utilities (each module handles its own)

Summary

In this post, I explained the modular monolith architecture and how Spring Modulith enforces it. The key points:

  1. Start with domain-driven module boundaries - organize by business capabilities, not technical layers
  2. Use Spring Modulith verification tests - they catch boundary violations at build time
  3. Communicate via events - modules should not call each other’s internals directly
  4. Keep internal packages hidden - only expose DTOs and service interfaces
  5. Design for potential extraction - each module should be independently deployable if needed

The modular monolith gives you the best starting point: simple deployment with clear boundaries. You can evolve to microservices when business needs demand it, not because architecture fashion dictates it.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments