Message-Driven Architecture
IntermediateServices communicate by publishing and consuming messages from a broker (Kafka, RabbitMQ); producers and consumers evolve independently with no direct coupling.
Overview
In message-driven microservices, services communicate asynchronously via a message broker (Kafka, RabbitMQ, SQS). The producer publishes a message and moves on — it does not wait for a response. Consumers process messages independently and at their own pace. This temporal decoupling improves resilience (the consumer can be down without affecting the producer), scalability (add consumers to scale processing), and flexibility (new consumers can be added without modifying the producer). The trade-off is eventual consistency — the state across services converges over time rather than immediately.
Event-Driven with Kafka (Spring Cloud Stream)
Spring Cloud Stream abstracts the underlying broker. Define a function (Consumer, Supplier, or Function) and Spring wires it to a Kafka topic or RabbitMQ exchange via application.properties.
// Spring Cloud Stream — broker-agnostic messaging
// application.properties
spring.cloud.stream.bindings.orderPlaced-in-0.destination=orders
spring.cloud.stream.bindings.orderPlaced-in-0.group=inventory-service
spring.cloud.stream.kafka.binder.brokers=localhost:9092
// Consumer — receives OrderEvent from the "orders" topic
@Bean
public Consumer<OrderEvent> orderPlaced() {
return event -> {
log.info("Processing order {}", event.getOrderId());
inventoryService.reserve(event.getItems());
};
}
// Producer — publishes OrderEvent to the "orders" topic
@Bean
public Supplier<Flux<OrderEvent>> orderEvents() {
return () -> orderFlux; // reactive source
}
// Or imperatively — inject StreamBridge
@Service
public class OrderEventPublisher {
private final StreamBridge streamBridge;
public void publish(OrderEvent event) {
streamBridge.send("orderPlaced-out-0", event);
}
}Message Patterns: Events vs Commands
Two main messaging patterns: Events (something happened, broadcasted to all interested parties) and Commands (do this specific thing, directed at one receiver). Design systems around events for loose coupling; use commands for targeted actions.
// Event — "something happened" — broad cast, any service can react
// OrderService publishes; InventoryService, BillingService, ShippingService all consume
public record OrderPlaced(
String orderId, String customerId, List<Item> items, BigDecimal total,
Instant occurredAt
) {}
// Command — "do this" — directed at ONE specific service
public record ReserveInventory(
String commandId, String orderId, List<Item> items
) {}
// Design principle:
// ✓ Use events for state change notifications (domain events)
// → OrderPlaced, PaymentConfirmed, ShipmentDispatched
// ✓ Use commands when you need ONE service to do something
// → ReserveInventory sent from order-service to inventory-service
// Integration events (cross-service) vs Domain events (in-process):
// Domain event: processed synchronously within the same transaction
// Integration event: published to broker AFTER the transaction commits
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderPlaced(OrderPlaced event) {
// AFTER commit — safe to publish to broker
kafkaTemplate.send("orders", event.orderId(), event);
}Outbox Pattern for Reliable Publishing
Naive message publishing (save to DB + publish to broker) is not atomic — a crash between the two operations causes inconsistency. The Transactional Outbox Pattern writes events to an outbox table in the same DB transaction, then a relay process forwards them to the broker.
// Transactional Outbox Pattern
// Step 1 — save order AND outbox message in same transaction
@Transactional
public Order placeOrder(PlaceOrderRequest req) {
Order order = orderRepository.save(new Order(req));
// Write event to outbox table — SAME transaction as the order
outboxRepository.save(OutboxMessage.of(
"orders", // topic/exchange
order.getId(), // key
new OrderPlaced(order) // payload
));
return order;
}
// Step 2 — relay process (scheduled or CDC-based)
// Option A: polling relay
@Scheduled(fixedDelay = 1000)
@Transactional
public void relay() {
outboxRepository.findUnpublished().forEach(msg -> {
kafkaTemplate.send(msg.getTopic(), msg.getKey(), msg.getPayload());
msg.markPublished();
});
}
// Option B: Debezium CDC — reads MySQL binlog → publishes to Kafka
// No polling needed; sub-second latency
// outbox table changes → Debezium → Kafka topicKey Points to Remember
- 1Message-driven architecture decouples services temporally — producer does not wait for consumer.
- 2Events announce state changes (broad); Commands direct a specific action to one receiver.
- 3Publish domain events AFTER commit (TransactionalEventListener) to avoid phantom events on rollback.
- 4The Outbox Pattern atomically persists state + event in one DB transaction, then relays to broker.
- 5Spring Cloud Stream provides a broker-agnostic API — swap Kafka for RabbitMQ by changing config.
- 6Idempotent consumers are required because at-least-once delivery may redeliver messages.
Interview Questions
Sign in to ask AriaWhat is the difference between synchronous REST and message-driven communication?
What is the difference between a domain event and an integration event?
Why is the Transactional Outbox Pattern needed?
What is the difference between an event and a command in messaging?
How do you ensure idempotent message processing in a consumer?
Ask Aria about Message-Driven Architecture
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.