Skip to main content

05 - Communication Patterns: Synchronous, Asynchronous, and Event-Driven

05 - Communication Patterns: Synchronous, Asynchronous and Event-Driven: Introduction: Inter-Module Communication Communication between modules is the beating heart of...

What do you think about this article?

At a Glance

05 - Communication Patterns: Synchronous, Asynchronous and Event-Driven: Introduction: Inter-Module Communication Communication between modules is the beating heart of...

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.

Frequently asked questions

What is the main advantage of using a modular monolith over microservices in terms of communication?

A modular monolith allows for choosing between synchronous in-process communication and asynchronous event-based communication, giving more control over coupling, latency, and debuggability.

How does an in-process event bus implement the publish-subscribe pattern within the same JVM?

An in-process event bus implements the publish-subscribe pattern by allowing a module to publish an event, which is then reacted to by zero or more subscribed modules without the producer knowing the consumers.

What are some benefits of using asynchronous messaging with RabbitMQ over an in-process event bus?

Asynchronous messaging with RabbitMQ offers additional guarantees such as message persistence, dead letter queues, automatic retries, and distribution across multiple instances for scenarios requiring advanced decoupling, failure resilience, and retry capability.

What is the purpose of a Dead Letter Queue (DLQ) in asynchronous flows?

A Dead Letter Queue (DLQ) is used to move messages that cannot be processed after all retries are exhausted, allowing an operator or automated process to analyze and potentially retry failed messages.

Sources and further reading

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects