
Spring Boot Modulith
- 2 installs
- 21 repo stars
- Updated August 5, 2026
- joaquimscosta/arkhe-claude-plugins
Implements Spring Modulith 2.0 bounded contexts in Spring Boot 4 with package-based modules, event-driven communication, and enforced boundaries.
About
Structures a Spring Boot 4 modular monolith into bounded contexts using package-based application modules, @ApplicationModuleListener events, and enforced boundaries. A developer uses it to organize DDD modules and test them with the Scenario API.
- Package-based module boundaries as bounded contexts
- Event externalization to Kafka/AMQP and Scenario API testing
Spring Boot Modulith by the numbers
- 2 all-time installs (skills.sh)
- Ranked #77 of 89 Java & JVM skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/joaquimscosta/arkhe-claude-plugins --skill spring-boot-modulithAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 21 |
| Last updated | August 5, 2026 |
| Repository | joaquimscosta/arkhe-claude-plugins ↗ |
What it does
Implements Spring Modulith 2.0 bounded contexts in Spring Boot 4 with package-based modules, event-driven communication, and enforced boundaries.
Files
Spring Modulith for Bounded Contexts
Implements DDD bounded contexts as application modules with enforced boundaries and event-driven communication.
Core Concepts
| Concept | Description |
|---|---|
| Application Module | Package-based boundary = bounded context |
| Module API | Types in base package (public) |
| Internal | Types in sub-packages (encapsulated) |
| Events | Cross-module communication mechanism |
Module Structure
src/main/java/
├── com.example/
│ └── Application.java ← @SpringBootApplication
├── com.example.order/ ← Module: order
│ ├── OrderService.java ← Public API
│ ├── OrderCreated.java ← Public event
│ ├── package-info.java ← @ApplicationModule config
│ └── internal/ ← Encapsulated
│ ├── OrderRepository.java
│ └── OrderEntity.java
├── com.example.inventory/ ← Module: inventory
│ ├── InventoryService.java
│ └── internal/
└── com.example.shipping/ ← Module: shippingTypes in com.example.order = public API Types in com.example.order.internal = hidden from other modules
Quick Patterns
See EXAMPLES.md for complete working examples including:
- Module Configuration with @ApplicationModule
- Event Publishing with domain event records
- Event Handling with @ApplicationModuleListener (Java + Kotlin)
- Module Verification Test with PlantUML generation
- Event Externalization for Kafka/AMQP
Spring Boot 4 / Modulith 2.0 Specifics
- @ApplicationModuleListener combines
@Async+@Transactional(REQUIRES_NEW)+@TransactionalEventListener(AFTER_COMMIT) - Event Externalization with
@Externalizedannotation for Kafka/AMQP - JDBC event log ensures at-least-once delivery
Detailed References
- Examples: See EXAMPLES.md for complete working code examples
- Troubleshooting: See TROUBLESHOOTING.md for common issues and Boot 4 migration
- Workflow: See WORKFLOW.md for detailed step-by-step Modulith setup
- Module Structure: See references/MODULE-STRUCTURE.md for package conventions, named interfaces, dependency rules
- Event Patterns: See references/EVENTS.md for publishing, handling, externalization, testing with Scenario API
Related Skills
| Need | Skill |
|---|---|
| DDD concepts | domain-driven-design |
| Data layer per module | spring-boot-data-ddd |
| Module event testing | spring-boot-testing |
| REST APIs for modules | spring-boot-web-api |
Anti-Pattern Checklist
| Anti-Pattern | Fix |
|---|---|
| Direct bean injection across modules | Use events or expose API |
| Synchronous cross-module calls | Use @ApplicationModuleListener |
| Module dependencies not declared | Add allowedDependencies in @ApplicationModule |
| Missing verification test | Add ApplicationModules.verify() test |
| Internal types in public API | Move to .internal sub-package |
| Events without data | Include all data handlers need |
Critical Reminders
1. One module = one bounded context — Mirror DDD boundaries 2. Events are the integration mechanism — Not direct method calls 3. Verify in CI — ApplicationModules.verify() catches boundary violations 4. Reference by ID — Never direct object references across modules 5. Transaction per module — @ApplicationModuleListener ensures isolation
Spring Modulith Examples
Complete working examples for Spring Modulith 2.0 patterns.
Module Configuration
Package-info.java for declaring module boundaries and dependencies.
// package-info.java in com.example.order
@ApplicationModule(
allowedDependencies = {"inventory :: api", "customer"},
type = Type.OPEN // or CLOSED for strict encapsulation
)
package com.example.order;
import org.springframework.modulith.ApplicationModule;Key points:
- Place in module's base package (e.g.,
com.example.order) - Use
:: apisuffix to depend only on another module's public API Type.OPENexposes all types,Type.CLOSEDonly base package types
---
Event Publishing
Domain event with publishing service.
Domain Event (Module's Public API)
public record OrderSubmitted(
Long orderId,
CustomerId customerId,
List<OrderLine> lines,
Instant occurredAt
) {
public OrderSubmitted(Long orderId, CustomerId customerId, List<OrderLine> lines) {
this(orderId, customerId, lines, Instant.now());
}
}Publishing Service
@Service
@Transactional
public class OrderService {
private final OrderRepository repository;
private final ApplicationEventPublisher events;
public Order submit(Long orderId) {
Order order = repository.findById(orderId).orElseThrow();
order.submit();
Order saved = repository.save(order);
events.publishEvent(new OrderSubmitted(
saved.getId(),
saved.getCustomerId(),
saved.getLines()
));
return saved;
}
}Key points:
- Events are records for immutability
- Include all data handlers need (avoid lazy loading)
- Publish after save to ensure consistency
---
Event Handling (Cross-Module)
Async event handler in a different module.
Java
// In inventory module
@Component
public class InventoryEventHandler {
private final StockRepository stocks;
@ApplicationModuleListener
void on(OrderSubmitted event) {
// Runs async, in new transaction, after original commits
event.lines().forEach(line ->
stocks.decrementStock(line.productId(), line.quantity())
);
}
}Kotlin
@Component
class InventoryEventHandler(private val stocks: StockRepository) {
@ApplicationModuleListener
fun on(event: OrderSubmitted) {
event.lines.forEach { line ->
stocks.decrementStock(line.productId, line.quantity)
}
}
}Key points:
@ApplicationModuleListenercombines:@Async- non-blocking execution@Transactional(propagation = REQUIRES_NEW)- independent transaction@TransactionalEventListener(phase = AFTER_COMMIT)- runs after publisher commits
---
Module Verification Test
CI-friendly test to enforce module boundaries.
class ModularityTests {
ApplicationModules modules = ApplicationModules.of(Application.class);
@Test
void verifyModuleStructure() {
modules.verify(); // Fails if boundaries violated
}
@Test
void generateDocumentation() {
new Documenter(modules)
.writeModulesAsPlantUml()
.writeIndividualModulesAsPlantUml();
}
}Key points:
- Run
verify()in CI to catch boundary violations early - Generate PlantUML diagrams for architecture documentation
- No Spring context needed - fast execution
---
Event Externalization
Publish events to external systems (Kafka, AMQP).
Event Annotation
@Externalized("orders::#{#this.customerId}")
public record OrderSubmitted(Long orderId, String customerId, ...) {}Configuration
# application.properties
spring.modulith.events.externalization.enabled=true
spring.modulith.events.jdbc.enabled=true # Event publication log for reliabilityKey points:
@Externalizedmarks events for external publication- Routing key expression (
#{#this.customerId}) determines topic/queue - JDBC publication log ensures at-least-once delivery
---
Module Structure Example
Complete package layout for order module.
src/main/java/
├── com.example/
│ └── Application.java ← @SpringBootApplication
├── com.example.order/ ← Module: order
│ ├── OrderService.java ← Public API
│ ├── OrderCreated.java ← Public event
│ ├── package-info.java ← @ApplicationModule config
│ └── internal/ ← Encapsulated
│ ├── OrderRepository.java
│ └── OrderEntity.java
├── com.example.inventory/ ← Module: inventory
│ ├── InventoryService.java
│ └── internal/
└── com.example.shipping/ ← Module: shippingRules:
- Types in
com.example.order= public API (other modules can use) - Types in
com.example.order.internal= hidden from other modules - One module = one bounded context in DDD terms
Event-Driven Module Communication
Event publishing, handling, and testing patterns for Spring Modulith.
Table of Contents
- Event Design
- Domain Event Structure
- Event Naming Conventions
- Event Publishing
- From Service
- From Aggregate (AbstractAggregateRoot)
- Event Handling
- @ApplicationModuleListener
- Multiple Handlers
- Conditional Handling
- Event Externalization
- Mark Event for External Publication
- Configuration
- Custom Event Routing
- Event Publication Log
- Incomplete Publication Handling
- Testing with Scenario API
- Basic Event Testing
- State Change Verification
- Publishing Events Directly
- Timeout Configuration
- Error Handling
- Handler Failure
- Dead Letter Handling
- Event Versioning
- Best Practices
Event Design
Domain Event Structure
// Immutable record with all data handlers need
public record OrderSubmitted(
Long orderId,
CustomerId customerId,
Money totalAmount,
List<OrderLineDto> lines,
Instant occurredAt
) {
// Convenience constructor
public OrderSubmitted(Long orderId, CustomerId customerId, Money totalAmount, List<OrderLineDto> lines) {
this(orderId, customerId, totalAmount, lines, Instant.now());
}
// Nested DTO for aggregate data
public record OrderLineDto(ProductId productId, int quantity, Money unitPrice) {}
}data class OrderSubmitted(
val orderId: Long,
val customerId: CustomerId,
val totalAmount: Money,
val lines: List<OrderLineDto>,
val occurredAt: Instant = Instant.now()
) {
data class OrderLineDto(
val productId: ProductId,
val quantity: Int,
val unitPrice: Money
)
}Event Naming Conventions
- Past tense:
OrderSubmitted,PaymentProcessed,InventoryReserved - Include aggregate ID and relevant data
- Self-contained: handler shouldn't need to query back
Event Publishing
From Service
@Service
@Transactional
public class OrderService {
private final OrderRepository orders;
private final ApplicationEventPublisher events;
public Order submit(Long orderId) {
Order order = orders.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
order.submit();
Order saved = orders.save(order);
// Publish after save
events.publishEvent(new OrderSubmitted(
saved.getId(),
saved.getCustomerId(),
saved.getTotal(),
mapLines(saved.getLines())
));
return saved;
}
}From Aggregate (AbstractAggregateRoot)
@Entity
public class Order extends AbstractAggregateRoot<Order> {
public void submit() {
if (status != OrderStatus.DRAFT) {
throw new IllegalStateException("Cannot submit non-draft order");
}
this.status = OrderStatus.SUBMITTED;
// Register event - published when repository.save() called
registerEvent(new OrderSubmitted(this.id, this.customerId, this.total, mapLines()));
}
}
// Service just saves - events published automatically
@Transactional
public Order submit(Long orderId) {
Order order = orders.findById(orderId).orElseThrow();
order.submit();
return orders.save(order); // Events dispatched here
}Event Handling
@ApplicationModuleListener
Combines:
@Async— Non-blocking@Transactional(propagation = REQUIRES_NEW)— New transaction@TransactionalEventListener(phase = AFTER_COMMIT)— After publisher commits
@Component
public class InventoryEventHandler {
private final StockRepository stocks;
private final StockReservationService reservations;
@ApplicationModuleListener
void on(OrderSubmitted event) {
// Runs after order transaction commits
// In its own transaction
// Async (non-blocking for caller)
event.lines().forEach(line -> {
stocks.decrementStock(line.productId(), line.quantity());
reservations.create(event.orderId(), line.productId(), line.quantity());
});
}
@ApplicationModuleListener
void on(OrderCancelled event) {
reservations.releaseForOrder(event.orderId());
}
}Multiple Handlers
// Notification module
@Component
public class NotificationEventHandler {
@ApplicationModuleListener
void sendConfirmation(OrderSubmitted event) {
emailService.sendOrderConfirmation(event.customerId(), event.orderId());
}
}
// Analytics module
@Component
public class AnalyticsEventHandler {
@ApplicationModuleListener
void trackOrder(OrderSubmitted event) {
analytics.track("order_submitted", Map.of(
"orderId", event.orderId(),
"amount", event.totalAmount().amount()
));
}
}Conditional Handling
@ApplicationModuleListener
void on(OrderSubmitted event) {
if (event.totalAmount().amount().compareTo(BigDecimal.valueOf(1000)) > 0) {
// Only for high-value orders
fraudDetection.analyze(event);
}
}Event Externalization
Mark Event for External Publication
@Externalized("orders::#{#this.orderId}")
public record OrderSubmitted(Long orderId, CustomerId customerId, ...) {}Routing key: orders::123 (for Kafka/RabbitMQ partitioning)
Configuration
# Enable externalization
spring.modulith.events.externalization.enabled=true
# Use JDBC event publication log (recommended)
spring.modulith.events.jdbc.enabled=true
spring.modulith.events.jdbc.schema-initialization.enabled=true
# Kafka
spring.modulith.events.kafka.enabled=true
spring.kafka.bootstrap-servers=localhost:9092
# RabbitMQ
spring.modulith.events.amqp.enabled=true
spring.rabbitmq.host=localhostCustom Event Routing
@Configuration
public class EventExternalizationConfig {
@Bean
EventExternalizationConfiguration eventConfig() {
return EventExternalizationConfiguration.externalizing()
.select(annotatedAsExternalized())
.mapping(OrderSubmitted.class, event -> new OrderDTO(
event.orderId(),
event.totalAmount().amount()
))
.routeKey(OrderSubmitted.class, event ->
"orders." + event.customerId().value())
.build();
}
}Event Publication Log
Ensures at-least-once delivery with JDBC-backed log.
-- Auto-created schema
CREATE TABLE event_publication (
id UUID PRIMARY KEY,
listener_id VARCHAR(255),
event_type VARCHAR(255),
serialized_event TEXT,
publication_date TIMESTAMP,
completion_date TIMESTAMP
);Incomplete Publication Handling
@Component
public class IncompleteEventResubmitter {
private final IncompleteEventPublications publications;
@Scheduled(fixedRate = 60000) // Every minute
public void resubmitIncomplete() {
publications.resubmitIncompletePublicationsOlderThan(Duration.ofMinutes(5));
}
}Testing with Scenario API
Basic Event Testing
@ApplicationModuleTest
class OrderModuleTest {
@Autowired
OrderService orders;
@Test
void orderSubmissionPublishesEvent(Scenario scenario) {
// Given
Long orderId = createTestOrder();
// When/Then
scenario.stimulate(() -> orders.submit(orderId))
.andWaitForEventOfType(OrderSubmitted.class)
.matching(event -> event.orderId().equals(orderId))
.toArriveAndVerify(event -> {
assertThat(event.totalAmount()).isNotNull();
assertThat(event.lines()).isNotEmpty();
});
}
}State Change Verification
@Test
void inventoryUpdatedOnOrderSubmission(Scenario scenario) {
// Given
ProductId productId = ProductId.generate();
stockRepository.save(new Stock(productId, 100));
Long orderId = createOrderWithProduct(productId, 5);
// When/Then
scenario.stimulate(() -> orders.submit(orderId))
.andWaitForEventOfType(OrderSubmitted.class)
.toArriveAndVerify(event -> {
Stock stock = stockRepository.findByProductId(productId);
assertThat(stock.getQuantity()).isEqualTo(95);
});
}Publishing Events Directly
@Test
void inventoryHandlesOrderSubmitted(Scenario scenario) {
// Given
ProductId productId = ProductId.generate();
stockRepository.save(new Stock(productId, 100));
// When - publish event directly
OrderSubmitted event = new OrderSubmitted(
1L,
CustomerId.generate(),
Money.of(100),
List.of(new OrderLineDto(productId, 10, Money.of(10)))
);
// Then
scenario.publish(event)
.andWaitForStateChange(() -> stockRepository.findByProductId(productId))
.andVerify(stock -> assertThat(stock.getQuantity()).isEqualTo(90));
}Timeout Configuration
scenario.stimulate(() -> orders.submit(orderId))
.andWaitForEventOfType(OrderSubmitted.class)
.toArriveWithin(Duration.ofSeconds(5))
.andVerify(event -> { ... });Error Handling
Handler Failure
If a handler fails, the event publication log retains the event for reprocessing.
@ApplicationModuleListener
void on(OrderSubmitted event) {
try {
inventoryService.reserve(event);
} catch (InsufficientStockException e) {
// Publish compensating event
events.publishEvent(new InventoryReservationFailed(
event.orderId(),
e.getProductId(),
e.getRequestedQuantity()
));
}
}Dead Letter Handling
@Configuration
public class EventConfig {
@Bean
EventPublicationRegistry eventPublicationRegistry(
EventPublicationRepository repository
) {
return new DefaultEventPublicationRegistry(repository) {
@Override
protected void onPublicationFailure(EventPublication publication, Throwable cause) {
log.error("Event publication failed: {}", publication.getEvent(), cause);
alerting.notify("Event processing failed", cause);
}
};
}
}Event Versioning
For evolving event schemas:
// V1
public record OrderSubmittedV1(Long orderId, BigDecimal amount) {}
// V2 - added customerId
public record OrderSubmitted(
Long orderId,
CustomerId customerId,
BigDecimal amount
) {
// Migration from V1
public static OrderSubmitted fromV1(OrderSubmittedV1 v1, CustomerId customerId) {
return new OrderSubmitted(v1.orderId(), customerId, v1.amount());
}
}Best Practices
1. Events are contracts — Don't change structure without versioning 2. Include all needed data — Handlers shouldn't query back 3. One handler per concern — Separate inventory, notifications, analytics 4. Idempotent handlers — Events may be redelivered 5. Test with Scenario API — Verify event flow, not just publication 6. Monitor publication log — Alert on stuck events
Module Structure & Boundaries
Package conventions and dependency rules for Spring Modulith.
Table of Contents
- Package Layout
- Standard Structure
- What Goes Where
- Module Configuration
- @ApplicationModule
- Module Types
- Named Interfaces
- Dependency Rules
- Allowed Patterns
- Forbidden Patterns (fail verification)
- Module Detection
- Explicit Module Definition
- Module Verification
- Basic Verification
- Verification Checks
- Verification Output
- Documentation Generation
- Cross-Cutting Concerns
- Shared Configuration
- Module-Specific Configuration
- Integration with DDD Layers
- Testing Module Boundaries
- Bootstrap Modes
Package Layout
Standard Structure
com.example/
├── Application.java ← Main class
│
├── order/ ← Module: order
│ ├── OrderService.java ← API: public service
│ ├── Order.java ← API: public type (if exposed)
│ ├── OrderCreated.java ← API: public event
│ ├── package-info.java ← Module configuration
│ │
│ ├── internal/ ← Implementation details
│ │ ├── OrderRepository.java
│ │ ├── OrderEntity.java
│ │ └── OrderMapper.java
│ │
│ └── web/ ← Still internal (sub-package)
│ └── OrderController.java
│
├── inventory/ ← Module: inventory
│ ├── InventoryService.java
│ └── internal/
│
└── shared/ ← Shared kernel (use sparingly)
└── Money.javaWhat Goes Where
| Location | Contains | Visibility |
|---|---|---|
order/ (base) | Services, events, DTOs for API | Public to all modules |
order/internal/ | Repositories, entities, mappers | Hidden |
order/web/ | Controllers | Hidden (sub-package) |
shared/ | Value objects used across modules | Public (minimize this) |
Module Configuration
@ApplicationModule
// package-info.java
@ApplicationModule(
displayName = "Order Management",
allowedDependencies = {
"inventory :: api", // Only inventory's API, not internals
"customer", // All of customer module
"shared" // Shared kernel
},
type = Type.CLOSED // Strict encapsulation
)
package com.example.order;Module Types
| Type | Behavior |
|---|---|
OPEN (default) | All modules can depend on this |
CLOSED | Only explicitly allowed dependencies |
Named Interfaces
Expose subsets of a module:
// package-info.java in com.example.order
@ApplicationModule
@NamedInterface("api")
package com.example.order;
// package-info.java in com.example.order.spi
@NamedInterface("spi")
package com.example.order.spi;Now other modules can depend on specific interfaces:
// In shipping module
@ApplicationModule(
allowedDependencies = {"order :: api"} // Only order's API, not SPI
)
package com.example.shipping;Dependency Rules
Allowed Patterns
order → shared ✓ (shared kernel)
order → inventory ✓ (if declared)
order.internal → order ✓ (internal uses own API)Forbidden Patterns (fail verification)
order → inventory.internal ✗ (accessing internals)
order → shipping ✗ (if not declared)
inventory → order ✗ (circular, if order → inventory)Module Detection
Modulith automatically detects modules as direct sub-packages of the main application class package.
@SpringBootApplication
public class Application { } // In com.example
// Detected modules:
// - com.example.order
// - com.example.inventory
// - com.example.shippingExplicit Module Definition
For non-standard layouts:
@SpringBootApplication
@Modulithic(
sharedModules = "shared",
additionalPackages = "com.example.legacy"
)
public class Application { }Module Verification
Basic Verification
class ModularityTests {
@Test
void verifyModuleStructure() {
ApplicationModules.of(Application.class).verify();
}
}Verification Checks
1. No cycles — Module A → B → A forbidden 2. No internal access — Can't use .internal types from other modules 3. Declared dependencies only — For CLOSED modules 4. Event types are public — Events must be in module's API package
Verification Output
# Successful
Verifying module structure...
✓ order
✓ inventory
✓ shipping
All modules verified successfully!
# Failed
Verifying module structure...
✗ order
- Uses internal type: inventory.internal.StockEntity
- Undeclared dependency: shippingDocumentation Generation
@Test
void generateDocs() {
ApplicationModules modules = ApplicationModules.of(Application.class);
new Documenter(modules)
.writeModulesAsPlantUml() // Overview diagram
.writeIndividualModulesAsPlantUml() // Per-module diagrams
.writeModuleCanvases(); // Module canvases
}Generates:
target/modulith-docs/components.puml— Module overviewtarget/modulith-docs/module-order.puml— Order module detailtarget/modulith-docs/module-order.adoc— Order module canvas
Cross-Cutting Concerns
Shared Configuration
// In shared module or main package
@Configuration
public class SharedConfig {
@Bean
public Clock clock() {
return Clock.systemUTC();
}
}Module-Specific Configuration
// In order/internal/
@Configuration
class OrderConfig {
@Bean
OrderService orderService(OrderRepository repo, ApplicationEventPublisher events) {
return new OrderService(repo, events);
}
}Integration with DDD Layers
com.example.order/ ← Bounded Context
├── OrderFacade.java ← Application Service (API)
├── OrderSubmitted.java ← Domain Event (API)
│
├── domain/ ← Domain Layer (internal)
│ ├── Order.java ← Aggregate Root
│ ├── OrderLine.java ← Entity
│ ├── Money.java ← Value Object
│ └── OrderRepository.java ← Repository Interface
│
├── application/ ← Application Layer (internal)
│ ├── SubmitOrderHandler.java
│ └── OrderQueryService.java
│
└── infrastructure/ ← Infrastructure Layer (internal)
├── JpaOrderRepository.java
└── OrderController.javaNote: Even with DDD layers, only OrderFacade and OrderSubmitted are in the base package (API). Everything else is internal.
Testing Module Boundaries
@ApplicationModuleTest(mode = BootstrapMode.DIRECT_DEPENDENCIES)
class OrderModuleTest {
@Autowired
OrderService orderService; // From this module
@Autowired
InventoryService inventoryService; // Direct dependency
// ShippingService NOT available - not a direct dependency
}Bootstrap Modes
| Mode | Loads |
|---|---|
STANDALONE | Current module only |
DIRECT_DEPENDENCIES | Module + direct dependencies |
ALL_DEPENDENCIES | Module + full dependency tree |
Spring Modulith Troubleshooting
Common issues and solutions for Spring Modulith 2.0.
Common Issues
Issue: Module Verification Failures
Symptom: ApplicationModules.verify() throws exception about illegal dependencies
Cause: Module accessing internal types of another module
Solution:
1. Check the error message for specific violation:
Module 'order' depends on non-exposed type
com.example.inventory.internal.StockEntity of module 'inventory'2. Options to fix:
- Move type to module's public API (base package)
- Expose via
allowedDependenciesin@ApplicationModule - Use events instead of direct references
// Option A: Expose the type
// Move StockEntity from internal/ to com.example.inventory/
// Option B: Allow dependency
@ApplicationModule(
allowedDependencies = {"inventory"} // Full access
// or
allowedDependencies = {"inventory :: api"} // API only
)
package com.example.order;
// Option C: Use events (recommended)
// Publish InventoryReserved event instead of direct call---
Issue: Events Not Being Published
Symptom: @ApplicationModuleListener never triggered
Cause: Event not published or transaction not committed
Solution:
1. Ensure event is published within transaction:
@Service
@Transactional // Must be present
public class OrderService {
public Order submit(Long orderId) {
Order order = repository.findById(orderId).orElseThrow();
order.submit();
Order saved = repository.save(order);
// Publish AFTER save
events.publishEvent(new OrderSubmitted(saved.getId()));
return saved;
}
}2. Check listener annotation:
// Wrong - @EventListener runs synchronously, before commit
@EventListener
void on(OrderSubmitted event) { ... }
// Correct - @ApplicationModuleListener runs after commit
@ApplicationModuleListener
void on(OrderSubmitted event) { ... }3. Verify event class is accessible:
// Event must be in publishing module's public API
// (base package, not internal/)
package com.example.order; // Correct
public record OrderSubmitted(...) {}
// NOT in
package com.example.order.internal; // Can't be seen by other modules---
Issue: @ApplicationModuleListener Not Triggered
Symptom: Handler method exists but never executes
Cause: Async processing disabled or transaction rollback
Solution:
1. Enable async processing:
@Configuration
@EnableAsync // Required for @ApplicationModuleListener
public class AsyncConfig {}2. Check for transaction rollback:
// If publishing transaction rolls back, events are discarded
@Transactional
public void submit(Long orderId) {
events.publishEvent(new OrderSubmitted(orderId));
throw new RuntimeException(); // Rollback = no event!
}3. Verify event publication log (if using JDBC):
SELECT * FROM event_publication WHERE completed = false;---
Issue: Circular Module Dependencies
Symptom: Verification error about cyclic dependency
Cause: Module A depends on Module B and Module B depends on Module A
Solution:
Use events to break the cycle:
// Instead of direct call from Order to Inventory AND Inventory to Order:
// Order module publishes event
events.publishEvent(new OrderSubmitted(orderId));
// Inventory module listens
@ApplicationModuleListener
void on(OrderSubmitted event) {
// Reserve stock, then publish
events.publishEvent(new StockReserved(event.orderId()));
}
// Order module listens (if needed)
@ApplicationModuleListener
void on(StockReserved event) {
// Continue order processing
}---
Issue: Event Data Missing After Async Handler
Symptom: LazyInitializationException in event handler
Cause: Event contains entity reference instead of data
Solution:
Include all needed data in event:
// Wrong - entity reference causes lazy loading issues
public record OrderSubmitted(Order order) {} // Don't do this!
// Correct - include data directly
public record OrderSubmitted(
Long orderId,
String customerId,
List<OrderLineData> lines, // Data, not entities
Instant occurredAt
) {}
public record OrderLineData(
String productId,
int quantity,
BigDecimal price
) {
public static OrderLineData from(OrderLine line) {
return new OrderLineData(
line.getProductId(),
line.getQuantity(),
line.getPrice()
);
}
}---
Spring Boot 4 / Modulith 2.0 Migration Issues
Dependency Changes
<!-- Modulith 2.0 for Boot 4 -->
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Optional: JDBC event publication log -->
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-jdbc</artifactId>
</dependency>Event Externalization Configuration
# Modulith 2.0 configuration
spring:
modulith:
events:
externalization:
enabled: true
jdbc:
enabled: true
schema-initialization:
enabled: true # Auto-create event_publication table@ApplicationModuleListener Semantics
In Modulith 2.0, @ApplicationModuleListener explicitly combines:
@Async- always async@Transactional(propagation = REQUIRES_NEW)- new transaction@TransactionalEventListener(phase = AFTER_COMMIT)- after publisher commits
This is the same as Modulith 1.x but more clearly documented.
Spring Modulith Setup Workflow
Detailed step-by-step process for implementing bounded contexts as Spring Modulith modules.
---
Step 1: Define Module Packages
Structure your application into package-based modules.
1a. Package Conventions
Each top-level package under the application root is a module:
com.example/
Application.java <- @SpringBootApplication (root)
com.example.order/ <- Module: order
OrderService.java <- Public API (base package)
OrderCreated.java <- Public event (base package)
internal/ <- Encapsulated
OrderRepository.java
OrderEntity.java
com.example.inventory/ <- Module: inventory
com.example.shipping/ <- Module: shippingRules:
- Types in the base package (
com.example.order) = public API - Types in sub-packages (
com.example.order.internal) = internal, hidden from other modules @SpringBootApplicationmust be in the root package
1b. Configure Module Metadata (Optional)
Add package-info.java for explicit configuration:
@ApplicationModule(
allowedDependencies = {"inventory", "shipping::api"}
)
package com.example.order;
import org.springframework.modulith.ApplicationModule;1c. Named Interfaces
Expose specific internal packages as named interfaces:
@NamedInterface("api")
package com.example.order.api;Other modules can then depend on order::api without accessing all of order.
Output: Package structure with clear module boundaries.
---
Step 2: Configure Module Dependencies
Control which modules can access which APIs.
2a. Declare Dependencies
In package-info.java:
@ApplicationModule(
allowedDependencies = {"inventory", "shipping::api"}
)This means the order module can only depend on:
inventory(full public API)shipping::api(only the named interface)
2b. Verify Dependencies
Add a verification test:
@Test
void verifyModuleStructure() {
ApplicationModules modules = ApplicationModules.of(Application.class);
modules.verify();
}This test fails if any module accesses another module's internal types or undeclared dependencies.
2c. Generate Documentation
@Test
void generateModuleDocs() {
ApplicationModules modules = ApplicationModules.of(Application.class);
new Documenter(modules)
.writeModulesAsPlantUml()
.writeIndividualModulesAsPlantUml();
}Generates PlantUML diagrams showing module dependencies — useful for CI and architecture reviews.
Output: Configured module dependencies with verification test.
---
Step 3: Implement Event Communication
Use domain events for cross-module communication.
3a. Define Events as Records
// In order module's base package (public)
public record OrderPlaced(Long orderId, Long customerId, BigDecimal total) {}Events should be:
- Immutable (records)
- In the base package (so other modules can see them)
- Self-contained (include all data handlers need)
3b. Publish Events from Aggregates
// In the aggregate root
public void place() {
this.status = OrderStatus.PLACED;
registerEvent(new OrderPlaced(this.id, this.customerId, this.total));
}Events are dispatched after the transaction commits.
3c. Handle Events with @ApplicationModuleListener
// In inventory module
@Service
class InventoryEventHandler {
@ApplicationModuleListener
void on(OrderPlaced event) {
// Runs in its own transaction (REQUIRES_NEW)
// After the publishing transaction commits (AFTER_COMMIT)
// Asynchronously (@Async)
reserveInventory(event.orderId());
}
}@ApplicationModuleListener combines:
@Async— non-blocking@Transactional(REQUIRES_NEW)— isolated transaction@TransactionalEventListener(AFTER_COMMIT)— only after publisher commits
Output: Event-driven communication between modules.
---
Step 4: Add Module Verification Test
Ensure module boundaries are enforced in CI.
4a. Structure Verification
@SpringBootTest
class ModuleStructureTests {
@Test
void verifyModuleStructure() {
ApplicationModules modules = ApplicationModules.of(Application.class);
modules.verify();
}
}4b. Event Flow Verification with Scenario API
@ApplicationModuleTest
class OrderModuleTests {
@Test
void orderPlacement_publishesEvent(Scenario scenario) {
scenario.stimulate(() -> orderService.placeOrder(command))
.andWaitForEventOfType(OrderPlaced.class)
.matchingMappedValue(OrderPlaced::orderId, orderId)
.toArrive();
}
}The Scenario API lets you:
- Trigger an action (
stimulate) - Wait for an event (
andWaitForEventOfType) - Assert event properties (
matchingMappedValue) - Verify event completion (
toArrive)
4c. CI Integration
Add to your CI pipeline:
# In GitHub Actions or similar
- name: Verify module structure
run: ./gradlew test --tests '*ModuleStructureTests*'Output: Automated module boundary enforcement in CI.
---
Step 5: Event Externalization (Optional)
Externalize domain events to Kafka or AMQP for external consumers.
5a. Add Dependency
<!-- Kafka -->
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-events-kafka</artifactId>
</dependency>5b. Mark Events for Externalization
@Externalized("orders.events.placed::#{orderId()}")
public record OrderPlaced(Long orderId, Long customerId) {}The @Externalized annotation:
- First part: Kafka topic / AMQP routing key
- After
::: Key expression (for partitioning)
5c. Configure Event Publication
spring:
modulith:
events:
jdbc:
enabled: true # JDBC event log for at-least-once deliveryThe JDBC event log stores events before sending to the broker, ensuring at-least-once delivery even if the broker is temporarily unavailable.
5d. Event Publication Lifecycle
Domain event registered
-> Transaction commits
-> Event stored in JDBC log
-> Event sent to Kafka/AMQP
-> Event marked as published in log
-> Retry for failed publicationsOutput: Domain events flowing to external systems with guaranteed delivery.
---
Verification Checklist
After implementing Modulith:
- [ ] Each module is a top-level package under the application root
- [ ] Internal types are in sub-packages (not the base package)
- [ ]
ApplicationModules.verify()test passes - [ ] Cross-module communication uses events, not direct bean injection
- [ ] Events are records in the publishing module's base package
- [ ] Event handlers use
@ApplicationModuleListener - [ ] PlantUML diagrams generated for documentation