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:
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:
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 classProblems 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.
Order Service ─────┐ │ HTTP call (200ms latency) vInventory Service ─┐ │ HTTP call (150ms latency) vPayment Service ───┐ │ HTTP call (100ms latency) vEmail Service
Total latency: 450ms for a single order flowIssues 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:
- Enforced boundaries at compile/test time
- Single deployment unit (no distributed complexity)
- Clear module interfaces (each module is potentially extractable)
- 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:
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 configurationKey principle: Each module’s internal package is invisible to other modules. Spring Modulith enforces this.
Step 2: Add Spring Modulith Dependencies
<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:
@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:
@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:
@SpringBootTestclass 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:
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' moduleThe 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:
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;
@Servicepublic 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<OrderDto> 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:
package com.example.ecommerce.order;
public record OrderCreatedEvent(Long orderId, Long userId) {}Inventory module listens to this event:
package com.example.ecommerce.inventory.internal;
import org.springframework.modulith.events.ApplicationModuleListener;import org.springframework.stereotype.Component;import com.example.ecommerce.order.OrderCreatedEvent;
@Componentclass 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:
+------------------+ +------------------+| ORDER | | INVENTORY || | | || OrderService |---->| InventoryService || OrderCreatedEvent| | |+------------------+ +------------------+ | | v v+------------------+ +------------------+| PAYMENT | | NOTIFICATION || | | || PaymentService | | NotificationSvc |+------------------+ +------------------+
Events: OrderCreatedEvent -> Inventory, Payment, Notification PaymentCompletedEvent -> Order, NotificationThis diagram shows the actual module relationships based on event subscriptions.
Why This Approach Works
Benefits Over Traditional 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 couplingBenefits Over Microservices
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 overheadThe 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:
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:
// Order module has OrderEntity with userId column// User module reads the same userId from OrderEntity directly
// This creates hidden coupling!@Repositorypublic 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:
// Order module exposes:public interface OrderService { Long countOrdersByUser(Long userId);}
// User module calls the API, not the database directlyMistake 3: Making Modules Too Granular
I initially created 15 modules for a simple application:
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:
modules: - user (contains auth, profile, settings) - order (contains creation, fulfillment, history) - inventory - paymentSplit only when a module genuinely needs independent deployment.
Mistake 4: Exposing Internal Classes
I exposed too many classes from modules:
package com.example.ecommerce.order;
// This should be in internal/!public class OrderEntity { ... } // Visible to all modules
// Other modules can now see implementation detailsOnly expose DTOs and service interfaces:
package com.example.ecommerce.order;
public class OrderService { ... } // Public APIpublic record OrderDto(...) {} // Data transfer objectpublic 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:
- Independent scaling needed: Payment processing needs more instances than User management
- Team size grows: A dedicated team for Inventory module
- Deployment cadence differs: Order module changes frequently, others rarely
- Technology mismatch: Payment module needs Python for ML-based fraud detection
Extraction path:
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 queueThe module structure prepared this extraction - Payment already has clear boundaries.
Practical Implementation Tips
Use Spring Modulith’s Documentation Generation
Add to application.yml:
spring: modulith: documentation: enabled: true output-dir: target/modulith-docsRunning tests generates:
- Module relationship diagrams
- Event flow documentation
- Dependency matrices
Run Boundary Tests in CI Pipeline
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/ ├── 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:
- Start with domain-driven module boundaries - organize by business capabilities, not technical layers
- Use Spring Modulith verification tests - they catch boundary violations at build time
- Communicate via events - modules should not call each other’s internals directly
- Keep internal packages hidden - only expose DTOs and service interfaces
- 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:
- 👨💻 Spring Modulith Official Documentation
- 👨💻 Modular Monolith Architecture Patterns
- 👨💻 Reddit: When to Use Modular Monolith vs Microservices
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments