Spring Modulith in Practice: Building Scalable Modular Monoliths
The software industry faces a constant dilemma: start with a monolith and risk creating a "Big Ball of Mud" (indecipherable spaghetti code) or start with microservices and be swallowed by operational complexity on day one. This is where the Modular Monolith shines and Spring Modulith is the definitive tool in the Java ecosystem to ensure this architecture is enforced. It allows us to apply Domain-Driven Design (DDD) concepts and keep code isolated and testable.
1. Domain Structuring (Packages as Modules)
In Spring Modulith, a top-level package (just below your main Application class) is considered an independent logical module.
The golden rule is: only the classes at the module's root are public to other modules. Everything inside sub-packages (like internal) should be encapsulated.
src/main/java/com/alexsousadev/app
├── Application.java
├── order <-- Order Module
│ ├── OrderService.java (Public: Module API)
│ ├── OrderCompletedEvent.java
│ └── internal <-- Implementation details
│ ├── Order.java
│ └── OrderRepository.java
└── payment <-- Payment Module
├── PaymentService.java
└── internal
└── PaymentProviderClient.java
If PaymentService tries to directly import OrderRepository (which is in the internal package), Spring Modulith will block it during architecture tests.
2. Decoupled Event-Driven Communication
In a traditional monolith, OrderService would inject PaymentService directly via the constructor, creating tight coupling. In Spring Modulith, the best practice is event-driven communication.
Order Module publishing an event:
package com.alexsousadev.app.order;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
private final ApplicationEventPublisher events;
public OrderService(ApplicationEventPublisher events) {
this.events = events;
}
@Transactional
public void checkout(String orderId) {
// Logic to save the order to the database...
// Publish the event for anyone listening
events.publishEvent(new OrderCompletedEvent(orderId));
}
}
Payment Module listening to the event: Instead of using @EventListener, we use @ApplicationModuleListener. This magical annotation executes the event asynchronously and guarantees it is only triggered after the order's database transaction commits successfully.
package com.alexsousadev.app.payment;
import com.alexsousadev.app.order.OrderCompletedEvent;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Service;
@Service
public class PaymentService {
@ApplicationModuleListener
void on(OrderCompletedEvent event) {
System.out.println("Processing payment for order: " + event.orderId());
// Billing logic...
}
}
3. The Guardian of Boundaries (Architecture Tests)
Designing a clean architecture is useless if a developer breaks the rules in a new Pull Request. Spring Modulith allows you to create an automated test that fails your CI/CD pipeline if someone breaches module encapsulation or creates circular dependencies.
Just add a simple unit test:
package com.alexsousadev.app;
import org.junit.jupiter.api.Test;
import org.springframework.modulith.core.ApplicationModules;
class ArchitectureTests {
@Test
void verifiesModularStructure() {
ApplicationModules modules = ApplicationModules.of(Application.class);
// Validates encapsulation rules and dependency cycles
modules.verify();
}
}
Extra Tip: You can use
modules.createDocumenter().writeModulesAsPlantUml()within this same test to generate automatically updated architecture diagrams (C4 Model) on every build!
Conclusion
Using Spring Modulith is the natural next step for anyone studying Clean Architecture and DDD. It forces you to think in Bounded Contexts and protects your application boundaries, leaving your code perfectly primed to be split into microservices in the future only if (and when) truly necessary.