Spring Modulith Deep Dive
Spring Modulith helps you build well-structured, modular monoliths with Spring Boot. It enforces module boundaries at compile/test time, provides an event-driven communication model between modules, and generates architecture documentation — all while keeping the simplicity of a single deployable unit.
1. Why Modular Monolith?
Monolith Modular Monolith Microservices
┌──────────┐ ┌──────────────────┐ ┌─────┐ ┌─────┐ ┌─────┐
│ tangled │ │ ┌─────┐ ┌──────┐ │ │ Svc │ │ Svc │ │ Svc │
│ spaghetti│ → │ │Order│→│Inven-│ │ → │ A │ │ B │ │ C │
│ code │ │ │ │ │tory │ │ │ │ │ │ │ │
│ │ │ └──┬──┘ └──────┘ │ └──┬──┘ └──┬──┘ └──┬──┘
│ │ │ │ ┌──────┐ │ │ │ │
│ │ │ └───→│ Pay- │ │ └──────┴──────┘
│ │ │ │ment │ │ network calls
└──────────┘ │ └──────┘ │
└──────────────────┘
in-process, enforced
boundariesThe modular monolith provides the architectural discipline of microservices without the distributed systems complexity. You get clear module boundaries, independent testability, and a straightforward migration path to microservices when (and if) you need it.
2. Project Structure
com.example.shop/
├── ShopApplication.java # Main application class
├── order/ # Order module (package = module)
│ ├── Order.java # Public API (aggregate root)
│ ├── OrderService.java # Public API
│ ├── OrderCreatedEvent.java # Published event
│ ├── internal/ # Not accessible from other modules
│ │ ├── OrderRepository.java
│ │ ├── OrderLineItem.java
│ │ ├── OrderValidator.java
│ │ └── JpaOrderRepository.java
│ └── spi/ # Extension points for other modules
│ └── OrderCompletionListener.java
├── inventory/ # Inventory module
│ ├── InventoryService.java
│ ├── StockLevel.java
│ ├── internal/
│ │ ├── InventoryRepository.java
│ │ └── StockReservation.java
│ └── package-info.java
├── payment/ # Payment module
│ ├── PaymentService.java
│ ├── PaymentConfirmedEvent.java
│ └── internal/
│ ├── PaymentProcessor.java
│ ├── PaymentGatewayClient.java
│ └── PaymentRepository.java
├── notification/ # Notification module
│ ├── NotificationService.java
│ └── internal/
│ ├── EmailSender.java
│ ├── SmsSender.java
│ └── NotificationRepository.java
└── shared/ # Shared kernel (if needed)
├── Money.java
└── DomainEvent.java2.1 Dependencies
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- For documentation generation -->
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-docs</artifactId>
<scope>test</scope>
</dependency>
<!-- For observability -->
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-observability</artifactId>
</dependency>
<!-- For event externalization to Kafka -->
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-events-kafka</artifactId>
</dependency>3. Defining Modules
3.1 Convention-Based Modules
By default, every direct sub-package of the main application package is a module. Public types in the module's root package form the API. Types in the internal sub-package are internal and cannot be accessed by other modules.
// com.example.shop.order — this IS the module's public API
package com.example.shop.order;
// Public aggregate root — accessible from other modules
public class Order {
private Long id;
private Long customerId;
private OrderStatus status;
private List<OrderLineItem> items;
private Money totalAmount;
private Instant createdAt;
// Business methods that form the API
public void confirm() {
if (this.status != OrderStatus.PENDING) {
throw new IllegalStateException("Order must be PENDING to confirm");
}
this.status = OrderStatus.CONFIRMED;
}
public Money getTotalAmount() {
return items.stream()
.map(OrderLineItem::getSubtotal)
.reduce(Money.ZERO, Money::add);
}
// getters
}
// Public service — accessible from other modules
@Service
@Transactional
public class OrderService {
private final OrderRepository orderRepository;
private final ApplicationEventPublisher events;
OrderService(OrderRepository orderRepository,
ApplicationEventPublisher events) {
this.orderRepository = orderRepository;
this.events = events;
}
public Order createOrder(Long customerId, List<LineItemRequest> items) {
Order order = Order.create(customerId, items);
order = orderRepository.save(order);
events.publishEvent(new OrderCreatedEvent(
order.getId(), customerId, order.getTotalAmount()));
return order;
}
public Order findById(Long id) {
return orderRepository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
}
}// com.example.shop.order.internal — INTERNAL, not accessible from other modules
package com.example.shop.order.internal;
// This repository is internal — other modules cannot use it
interface OrderRepository extends JpaRepository<Order, Long> {
List<Order> findByCustomerIdAndStatus(Long customerId, OrderStatus status);
@Query("SELECT o FROM Order o WHERE o.createdAt >= :since AND o.status = :status")
List<Order> findRecentByStatus(@Param("since") Instant since,
@Param("status") OrderStatus status);
}
// Internal validation logic
@Component
class OrderValidator {
private final InventoryService inventoryService;
OrderValidator(InventoryService inventoryService) {
this.inventoryService = inventoryService;
}
void validate(Order order) {
if (order.getItems().isEmpty()) {
throw new InvalidOrderException("Order must have at least one item");
}
// Check inventory via the inventory module's public API
for (OrderLineItem item : order.getItems()) {
if (!inventoryService.isAvailable(item.getProductId(), item.getQuantity())) {
throw new InsufficientStockException(item.getProductId());
}
}
}
}3.2 Explicit Module Configuration
// package-info.java in the module package
@org.springframework.modulith.ApplicationModule(
allowedDependencies = { "inventory", "shared" } // restrict dependencies
)
package com.example.shop.order;// Named module with custom configuration
@org.springframework.modulith.ApplicationModule(
displayName = "Payment Processing",
allowedDependencies = { "order :: OrderService", "notification" }
)
package com.example.shop.payment;3.3 Named Interface (Exposing Specific Types)
// Only expose OrderService and Order to other modules, not everything public
@org.springframework.modulith.NamedInterface("api")
package com.example.shop.order;
// SPI package — extension points
@org.springframework.modulith.NamedInterface("spi")
package com.example.shop.order.spi;4. Inter-Module Events
Events are the primary mechanism for loose coupling between modules. Spring Modulith builds on Spring's ApplicationEventPublisher with transactional event publication and externalization.
4.1 Publishing Events
// Event record — simple, immutable
package com.example.shop.order;
public record OrderCreatedEvent(
Long orderId,
Long customerId,
Money totalAmount
) {}
public record OrderCompletedEvent(
Long orderId,
Long customerId,
Instant completedAt
) {}
public record OrderCancelledEvent(
Long orderId,
Long customerId,
String reason
) {}
// Publishing from a service
@Service
@Transactional
public class OrderService {
private final OrderRepository orderRepository;
private final ApplicationEventPublisher events;
OrderService(OrderRepository orderRepository,
ApplicationEventPublisher events) {
this.orderRepository = orderRepository;
this.events = events;
}
public Order createOrder(CreateOrderRequest request) {
Order order = Order.create(request.customerId(), request.items());
order = orderRepository.save(order);
// Publish event — other modules react to this
events.publishEvent(new OrderCreatedEvent(
order.getId(),
request.customerId(),
order.getTotalAmount()));
return order;
}
public void cancelOrder(Long orderId, String reason) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
order.cancel(reason);
orderRepository.save(order);
events.publishEvent(new OrderCancelledEvent(
orderId, order.getCustomerId(), reason));
}
}4.2 Listening to Events in Other Modules
// Inventory module reacts to order events
package com.example.shop.inventory.internal;
@Component
class OrderEventHandler {
private static final Logger log = LoggerFactory.getLogger(OrderEventHandler.class);
private final StockReservationService reservationService;
OrderEventHandler(StockReservationService reservationService) {
this.reservationService = reservationService;
}
@ApplicationModuleListener
void onOrderCreated(OrderCreatedEvent event) {
log.info("Reserving stock for order {}", event.orderId());
reservationService.reserveStock(event.orderId());
}
@ApplicationModuleListener
void onOrderCancelled(OrderCancelledEvent event) {
log.info("Releasing stock for cancelled order {}", event.orderId());
reservationService.releaseStock(event.orderId());
}
}
// Notification module reacts to events
package com.example.shop.notification.internal;
@Component
class OrderNotificationHandler {
private final EmailSender emailSender;
private final CustomerLookup customerLookup;
OrderNotificationHandler(EmailSender emailSender, CustomerLookup customerLookup) {
this.emailSender = emailSender;
this.customerLookup = customerLookup;
}
@ApplicationModuleListener
void onOrderCreated(OrderCreatedEvent event) {
String email = customerLookup.getEmail(event.customerId());
emailSender.send(email, "Order Confirmation",
"Your order #%d for %s has been placed."
.formatted(event.orderId(), event.totalAmount()));
}
@ApplicationModuleListener
void onOrderCompleted(OrderCompletedEvent event) {
String email = customerLookup.getEmail(event.customerId());
emailSender.send(email, "Order Delivered",
"Your order #%d has been delivered.".formatted(event.orderId()));
}
}
// Payment module reacts to events
package com.example.shop.payment.internal;
@Component
class OrderPaymentHandler {
private final PaymentProcessor paymentProcessor;
OrderPaymentHandler(PaymentProcessor paymentProcessor) {
this.paymentProcessor = paymentProcessor;
}
@ApplicationModuleListener
void onOrderCancelled(OrderCancelledEvent event) {
paymentProcessor.refund(event.orderId());
}
}4.3 Transactional Event Publication
Spring Modulith ensures events are published reliably using a transactional event log. Events are stored in a database table within the same transaction as the business operation, then published asynchronously.
@Configuration
class ModulithConfig {
// The event publication log stores incomplete events for retry
// No additional configuration needed — Spring Modulith auto-configures
// the event publication repository when spring-modulith-starter-jpa is present
}# application.yml
spring:
modulith:
events:
republish-outstanding-events-on-restart: true
completion-mode: UPDATE # UPDATE or DELETE
jdbc:
schema-initialization:
enabled: true # auto-create event publication tables4.4 Event Externalization (to Kafka)
// Externalize events to Kafka for other bounded contexts
@Configuration
class EventExternalizationConfig {
@Bean
ApplicationModuleInitializer eventExternalizationInitializer() {
return () -> {};
}
}spring:
modulith:
events:
externalization:
enabled: true// Mark events for externalization
@Externalized("order-events::#{orderId()}")
public record OrderCreatedEvent(
Long orderId,
Long customerId,
Money totalAmount
) {}
// The routing key after :: is a SpEL expression for the Kafka message key
@Externalized("order-events::#{orderId()}")
public record OrderCompletedEvent(
Long orderId,
Long customerId,
Instant completedAt
) {}5. Module Verification and Testing
5.1 Architecture Verification
@Test
void verifyModularStructure() {
ApplicationModules modules = ApplicationModules.of(ShopApplication.class);
// Verify no illegal dependencies between modules
modules.verify();
}
@Test
void printModuleStructure() {
ApplicationModules modules = ApplicationModules.of(ShopApplication.class);
// Prints the module structure to console
modules.forEach(System.out::println);
// Output:
// # Order
// > Logical name: order
// > Base package: com.example.shop.order
// > Spring beans:
// + ....OrderService
// o ....internal.OrderRepository
// o ....internal.OrderValidator
// > Published events:
// - OrderCreatedEvent
// - OrderCompletedEvent
// - OrderCancelledEvent
// > Listened-to events: none
// > Dependencies:
// - inventory (via InventoryService)
}5.2 Module Integration Tests
// Test the order module in isolation
@ApplicationModuleTest
class OrderModuleIntegrationTest {
@Autowired
private OrderService orderService;
@Autowired
private AssertablePublishedEvents events;
@Test
void creatingOrderPublishesEvent() {
// Given
CreateOrderRequest request = new CreateOrderRequest(
42L,
List.of(new LineItemRequest("SKU-001", 2, Money.of("29.99")))
);
// When
Order order = orderService.createOrder(request);
// Then
assertThat(order.getId()).isNotNull();
assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING);
// Verify event was published
events.assertThat()
.contains(OrderCreatedEvent.class)
.matching(e -> e.customerId().equals(42L))
.matching(e -> e.totalAmount().equals(Money.of("59.98")));
}
@Test
void cancellingOrderPublishesEvent() {
Order order = orderService.createOrder(new CreateOrderRequest(
42L, List.of(new LineItemRequest("SKU-001", 1, Money.of("10")))));
orderService.cancelOrder(order.getId(), "Customer request");
events.assertThat()
.contains(OrderCancelledEvent.class)
.matching(e -> e.orderId().equals(order.getId()))
.matching(e -> e.reason().equals("Customer request"));
}
}
// Test the inventory module reacting to order events
@ApplicationModuleTest
class InventoryModuleIntegrationTest {
@Autowired
private InventoryService inventoryService;
@Autowired
private Scenario scenario;
@Test
void reservesStockOnOrderCreated() {
// When an OrderCreatedEvent is published
scenario.publish(new OrderCreatedEvent(1L, 42L, Money.of("100")))
// Then we expect stock to be reserved
.andWaitForStateChange(() ->
inventoryService.getReservation(1L))
.andVerify(reservation -> {
assertThat(reservation).isNotNull();
assertThat(reservation.getOrderId()).isEqualTo(1L);
});
}
@Test
void releasesStockOnOrderCancelled() {
// Setup: create a reservation
scenario.publish(new OrderCreatedEvent(2L, 42L, Money.of("50")))
.andWaitForStateChange(() ->
inventoryService.getReservation(2L));
// When order is cancelled
scenario.publish(new OrderCancelledEvent(2L, 42L, "out of stock"))
.andWaitForStateChange(() ->
inventoryService.getReservation(2L) == null)
.andVerify(released -> assertThat(released).isTrue());
}
}6. Documentation Generation
@Test
void generateDocumentation() {
ApplicationModules modules = ApplicationModules.of(ShopApplication.class);
// Generate PlantUML component diagram
new Documenter(modules)
.writeModulesAsPlantUml() // component diagram
.writeIndividualModulesAsPlantUml() // per-module diagrams
.writeModuleCanvases( // module canvases
Documenter.CanvasOptions.defaults()
.withApiDoc(true));
// Output goes to target/spring-modulith-docs/
// - components.puml
// - module-order.puml
// - module-inventory.puml
// - module-payment.puml
// - module-notification.puml
// - module-order.adoc (AsciiDoc canvas)
}7. Observability
Spring Modulith adds automatic observability for module interactions.
@Configuration
class ObservabilityConfig {
// Auto-configured when spring-modulith-observability is on classpath
// Traces inter-module method calls and event publications
}# application.yml
management:
tracing:
sampling:
probability: 1.0 # sample all in dev
metrics:
tags:
application: shop
endpoints:
web:
exposure:
include: health,metrics,modulith
spring:
modulith:
observability:
enabled: true// Custom metrics for module health
@Component
class ModuleHealthIndicator implements HealthIndicator {
private final ApplicationModules modules;
ModuleHealthIndicator() {
this.modules = ApplicationModules.of(ShopApplication.class);
}
@Override
public Health health() {
try {
modules.verify();
return Health.up()
.withDetail("modules", modules.stream()
.map(m -> m.getName())
.toList())
.build();
} catch (Exception e) {
return Health.down(e).build();
}
}
}8. Migration Path to Microservices
Spring Modulith is designed for incremental extraction. When a module proves it needs independent deployment, the migration steps are well-defined.
8.1 Step 1: Events Become Messages
// Before: in-process event
events.publishEvent(new OrderCreatedEvent(orderId, customerId, total));
// After: externalized to Kafka (just add @Externalized)
@Externalized("order-events::#{orderId()}")
public record OrderCreatedEvent(Long orderId, Long customerId, Money totalAmount) {}8.2 Step 2: Module API Becomes REST/gRPC
// Before: direct method call within monolith
@Component
class OrderInventoryAdapter {
private final InventoryService inventoryService; // in-process
boolean checkStock(Long productId, int quantity) {
return inventoryService.isAvailable(productId, quantity);
}
}
// After: HTTP client to extracted microservice
@Component
class OrderInventoryAdapter {
private final RestClient inventoryClient;
OrderInventoryAdapter(@Value("${inventory.service.url}") String baseUrl) {
this.inventoryClient = RestClient.builder()
.baseUrl(baseUrl)
.build();
}
boolean checkStock(Long productId, int quantity) {
StockResponse response = inventoryClient.get()
.uri("/api/inventory/{productId}/availability?quantity={qty}",
productId, quantity)
.retrieve()
.body(StockResponse.class);
return response.available();
}
}8.3 Step 3: Separate Database
// Module already has its own repository and entities
// Migration: point the extracted service to its own database
// Use change data capture or dual writes during migration9. Comparison: Modular Monolith vs Microservices Day 1
┌────────────────────────┬─────────────────────┬──────────────────────┐
│ Aspect │ Modular Monolith │ Microservices Day 1 │
├────────────────────────┼─────────────────────┼──────────────────────┤
│ Deployment │ Single artifact │ N artifacts │
│ Latency (inter-module) │ In-process (ns) │ Network (ms) │
│ Transactions │ ACID, simple │ Saga, eventual │
│ Data consistency │ Strong │ Eventual │
│ Debugging │ Single process │ Distributed tracing │
│ Team size needed │ Small (2-8) │ Large (>10) │
│ Infra complexity │ Low │ High (K8s, mesh) │
│ Module boundaries │ Compile-time │ Network-enforced │
│ Refactoring │ IDE-assisted │ Cross-repo, risky │
│ Independent scaling │ No │ Yes │
│ Independent deploys │ No │ Yes │
│ Fault isolation │ Process-level │ Service-level │
│ Technology diversity │ Single stack │ Polyglot possible │
│ Time to first feature │ Fast │ Slow (infra setup) │
│ Migration to micro │ Straightforward │ N/A │
├────────────────────────┼─────────────────────┼──────────────────────┤
│ Start here when... │ Team < 10, unclear │ Known scaling needs, │
│ │ domain boundaries, │ large org, proven │
│ │ moving fast │ bounded contexts │
└────────────────────────┴─────────────────────┴──────────────────────┘10. Complete Example: Module Bootstrapping
// Main application
@SpringBootApplication
public class ShopApplication {
public static void main(String[] args) {
SpringApplication.run(ShopApplication.class, args);
}
}
// Order module — public types
package com.example.shop.order;
public record CreateOrderRequest(
Long customerId,
List<LineItemRequest> items
) {}
public record LineItemRequest(
String sku,
int quantity,
Money unitPrice
) {}
@Externalized("order-events::#{orderId()}")
public record OrderCreatedEvent(
Long orderId,
Long customerId,
Money totalAmount
) {}
@Service
@Transactional
public class OrderService {
private final OrderRepository repository;
private final ApplicationEventPublisher events;
OrderService(OrderRepository repository, ApplicationEventPublisher events) {
this.repository = repository;
this.events = events;
}
public Order createOrder(CreateOrderRequest request) {
Order order = Order.create(request);
order = repository.save(order);
events.publishEvent(new OrderCreatedEvent(
order.getId(), request.customerId(), order.getTotalAmount()));
return order;
}
@Transactional(readOnly = true)
public Order findById(Long id) {
return repository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
}
}
// Verification test
@Test
void modulesAreClean() {
ApplicationModules.of(ShopApplication.class).verify();
}Spring Modulith is the pragmatic choice for teams that want architectural discipline without the overhead of distributed systems. Start with a modular monolith, let the modules communicate through events, verify boundaries in tests, and extract to microservices only when a specific module has a proven need for independent deployment, scaling, or a different technology stack.