Introduction: Inter-Module Communication
Communication between modules is the beating heart of a modular monolith. Unlike microservices, where communication occurs exclusively over the network (HTTP, gRPC, message broker), in a modular monolith we have the advantage of choosing between synchronous in-process communication and asynchronous event-based communication, each with specific trade-offs in terms of coupling, latency, and debuggability.
In this article we will analyze the three main communication patterns: direct method calls, in-process event bus, and asynchronous messaging. We will see how to implement them with Spring Boot and when to choose one over another based on context.
What You Will Learn in This Article
- Synchronous communication: method calls through interfaces, advantages and limits
- In-process Event Bus: pub-sub within the same JVM
- Asynchronous messaging: RabbitMQ and Kafka for advanced decoupling
- Mediator Pattern: decoupling dependencies between modules
- Complete CQRS: separating commands and queries
- Consistency: strong consistency vs eventual consistency
- Failure handling: retry, dead letter queue, circuit breaker
- Debugging and observability in asynchronous flows
Pattern 1: Synchronous Communication (Direct Method Calls)
The simplest pattern: a module directly calls a method on another module's public interface. The call occurs in-process, with no network overhead, at the same latency as a standard Java method call (nanoseconds).
Advantages
- Simplicity: no additional infrastructure, no message broker
- Strong consistency: the call is part of the same transaction
- Direct debugging: complete stack trace, breakpoints, step-through
- Type safety: the compiler verifies contracts between modules
Disadvantages
- Temporal coupling: the caller is blocked until the callee responds
- Cascading failures: if the called module fails, the caller fails too
- Direct dependencies: the calling module knows the callee's interface
// Synchronous communication: Order calls Catalog via interface
@Service
class OrderServiceImpl implements OrderModuleApi {
// Explicit dependency on Catalog module API
private final CatalogModuleApi catalogModule;
private final UserModuleApi userModule;
@Override
@Transactional
public OrderDto createOrder(CreateOrderCommand cmd) {
// Synchronous call 1: verify user
UserDto user = userModule.findById(cmd.userId())
.orElseThrow(() -> new UserNotFoundException(cmd.userId()));
// Synchronous call 2: verify products
List<ProductDto> products = cmd.productIds().stream()
.map(id -> catalogModule.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id)))
.toList();
// Local logic of the Order module
Order order = Order.create(user.id(), products);
orderRepository.save(order);
return order.toDto();
// Everything happens within the same ACID transaction
}
}
Pattern 2: In-Process Event Bus
The in-process event bus implements the publish-subscribe pattern within the same JVM. A module publishes an event, and zero or more subscribed modules react to it. The producer does not know the consumers: decoupling is complete.
The Spring Framework provides a native in-process event bus through
ApplicationEventPublisher and the @EventListener
and @TransactionalEventListener annotations.
Advantages
- Decoupling: the producer does not know the consumers
- Extensibility: new consumers can be added without modifying the producer
- No infrastructure: works within the same JVM without external brokers
Disadvantages
- More complex debugging: the flow is non-linear, events need tracing
- Execution order: listener order is not guaranteed
- Event loss: if the application crashes, in-memory events are lost
// In-process Event Bus with Spring Events
// 1. Event definition (in the module's api package)
public record OrderCreatedEvent(
UUID orderId,
UUID userId,
Money total,
List<UUID> productIds,
Instant timestamp
) {}
// 2. Event publication (in the Order module)
@Service
class OrderServiceImpl {
private final ApplicationEventPublisher eventPublisher;
@Transactional
public OrderDto createOrder(CreateOrderCommand cmd) {
Order order = Order.create(cmd);
orderRepository.save(order);
// Publish event AFTER the transaction commits
eventPublisher.publishEvent(new OrderCreatedEvent(
order.getId(),
order.getUserId(),
order.getTotal(),
order.getProductIds(),
Instant.now()
));
return order.toDto();
}
}
// 3. Consumers in different modules
// Payment module: process payment
@Service
class PaymentEventHandler {
@TransactionalEventListener(phase = AFTER_COMMIT)
void handleOrderCreated(OrderCreatedEvent event) {
paymentService.initiatePayment(
event.userId(), event.total()
);
}
}
// Inventory module: reserve stock
@Service
class InventoryEventHandler {
@TransactionalEventListener(phase = AFTER_COMMIT)
void handleOrderCreated(OrderCreatedEvent event) {
for (UUID productId : event.productIds()) {
inventoryService.reserveStock(productId);
}
}
}
// Notification module: send confirmation
@Service
class NotificationEventHandler {
@TransactionalEventListener(phase = AFTER_COMMIT)
void handleOrderCreated(OrderCreatedEvent event) {
notificationService.sendOrderConfirmation(
event.userId(), event.orderId()
);
}
}
TransactionalEventListener vs EventListener
@EventListener executes the listener during the producer's
transaction. If the listener fails, the original transaction is rolled back.
@TransactionalEventListener(phase = AFTER_COMMIT) executes the listener
after the transaction commits, ensuring the event is processed only if the
original transaction succeeded.
Pattern 3: Mediator Pattern
The Mediator Pattern introduces an intermediary coordinating communication between modules. Instead of directly injecting target module interfaces, a module sends commands or queries to the mediator, which routes them to the appropriate handler.
// Mediator Pattern: completely decouples modules
// Mediator interface
public interface Mediator {
<R> R send(Command<R> command);
<R> R query(Query<R> query);
}
// Command: CreateOrder returns OrderDto
public record CreateOrderCommand(
UUID userId, List<UUID> productIds
) implements Command<OrderDto> {}
// Handler in the Order module
@Component
class CreateOrderHandler implements CommandHandler<CreateOrderCommand, OrderDto> {
@Override
public OrderDto handle(CreateOrderCommand cmd) {
Order order = Order.create(cmd.userId(), cmd.productIds());
orderRepository.save(order);
return order.toDto();
}
}
// Mediator implementation
@Component
class MediatorImpl implements Mediator {
private final Map<Class<?>, CommandHandler<?, ?>> handlers;
@SuppressWarnings("unchecked")
public <R> R send(Command<R> command) {
CommandHandler<Command<R>, R> handler =
(CommandHandler<Command<R>, R>) handlers.get(command.getClass());
if (handler == null) {
throw new NoHandlerException(command.getClass());
}
return handler.handle(command);
}
}
// Usage: the caller does not know the target module
@RestController
class OrderController {
private final Mediator mediator;
@PostMapping("/orders")
OrderDto createOrder(@RequestBody CreateOrderRequest request) {
return mediator.send(new CreateOrderCommand(
request.userId(), request.productIds()
));
}
}
Pattern 4: Asynchronous Messaging with RabbitMQ
For scenarios requiring advanced decoupling, failure resilience, and retry capability, an external message broker like RabbitMQ offers additional guarantees over the in-process event bus: message persistence, dead letter queues, automatic retries, and distribution across multiple instances.
// Asynchronous messaging with RabbitMQ and Spring AMQP
// Configuration
@Configuration
class RabbitConfig {
@Bean
public TopicExchange orderExchange() {
return new TopicExchange("order.events");
}
@Bean
public Queue paymentQueue() {
return QueueBuilder.durable("payment.order-created")
.withArgument("x-dead-letter-exchange", "order.events.dlx")
.build();
}
@Bean
public Binding paymentBinding() {
return BindingBuilder
.bind(paymentQueue())
.to(orderExchange())
.with("order.created");
}
}
// Producer in the Order module
@Service
class OrderEventPublisher {
private final RabbitTemplate rabbitTemplate;
public void publishOrderCreated(OrderCreatedEvent event) {
rabbitTemplate.convertAndSend(
"order.events", // exchange
"order.created", // routing key
event // payload
);
}
}
// Consumer in the Payment module
@Component
class PaymentOrderListener {
@RabbitListener(queues = "payment.order-created")
public void handleOrderCreated(OrderCreatedEvent event) {
try {
paymentService.processPayment(
event.userId(), event.total()
);
} catch (Exception e) {
// Message is sent to DLQ after 3 retries
throw new AmqpRejectAndDontRequeueException(e);
}
}
}
CQRS: Separating Commands and Queries
The CQRS (Command Query Responsibility Segregation) pattern completely separates the command path (write) from the query path (read). Commands pass through module APIs and modify state. Queries access denormalized projections optimized for reading.
// CQRS: Command side (write)
// Passes through module APIs with validation
@Service
class OrderCommandService {
@Transactional
public UUID createOrder(CreateOrderCommand cmd) {
// Validation, domain logic, persistence
Order order = Order.create(cmd);
orderRepo.save(order);
events.publish(new OrderCreatedEvent(order));
return order.getId();
}
}
// CQRS: Query side (read)
// Accesses denormalized views, no domain logic
@Service
class OrderQueryService {
private final OrderReadModelRepository readModelRepo;
// Fast query: the view already contains all needed data
public OrderDetailsView getOrderDetails(UUID orderId) {
return readModelRepo.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
}
// List query with filters
public Page<OrderSummaryView> searchOrders(
OrderSearchCriteria criteria, Pageable pageable) {
return readModelRepo.search(criteria, pageable);
}
}
// Denormalized view: contains data from multiple modules
@Entity
public class OrderDetailsView {
private UUID orderId;
private String customerName; // from User module
private String customerEmail; // from User module
private List<OrderItemView> items;
private String productNames; // from Catalog module
private BigDecimal total;
private String paymentMethod; // from Payment module
private String paymentStatus; // from Payment module
}
Failure Handling
In asynchronous flows, failures are inevitable. Here are the main patterns for handling them:
Retry with Exponential Backoff
If a handler fails, retry with increasing intervals: 1s, 2s, 4s, 8s. After a maximum number of attempts, the message is moved to a Dead Letter Queue (DLQ) for manual analysis.
Dead Letter Queue
Messages that cannot be processed after all retries are moved to a dedicated queue. An operator or automated process can analyze and retry failed messages.
Circuit Breaker
If a target module fails repeatedly, the circuit breaker stops calls for a period, avoiding overloading a module already in trouble. After a timeout, the circuit breaker gradually retries.
Debugging and Observability
Asynchronous flows are harder to debug than synchronous calls. Here are the fundamental practices:
- Correlation ID: each flow has a unique ID traversing all events and calls
- Structured Logging: structured logs with correlation ID, source module, target module
- Event Store: saves all published events for traceability and replay
- Health Check: verifies all modules and brokers are operational
Next Article
In the next article we will tackle deployment and scaling of the modular monolith: blue-green deployment strategies, feature flags for gradual releases, smart auto-scaling based on metrics, and when to extract a module as a microservice for isolated scalability.







