Resilience, Messaging & Observability

Event-Driven Microservices

18 min Lesson 6 of 12

Event-Driven Microservices

In a traditional request/response architecture one service calls another and waits for a reply. This coupling is fine for simple CRUD but becomes brittle at scale: the caller is unavailable whenever the callee is unavailable, latency stacks up through call chains, and a slow downstream service cascades failure upstream. Event-driven architecture breaks that coupling. Instead of calling a service directly you publish an event describing something that happened, and any number of interested services react to it independently. The producer does not know — or care — who is listening.

The key idea: In an event-driven system communication is temporal (the producer and consumer do not need to be alive at the same time) and topological (the producer does not reference the consumer by address). This unlocks independent deployability, horizontal scaling, and resilience.

Events vs Commands vs Queries

Before writing code it is worth distinguishing three message shapes:

  • Command — a directive aimed at one specific service: "ProcessPayment for order 42." The sender knows the recipient and expects it to act.
  • Query — a request for data: "What is the balance for account 7?" Synchronous, response-bearing.
  • Event — a notification that something has happened: "OrderPlaced for order 42." The publisher does not dictate what happens next. Events are immutable facts in past tense.

Events are the building block of event-driven microservices. Design your event names as past-tense facts (OrderPlaced, PaymentSucceeded, InventoryReserved) and your consumers decide independently how to react.

The Spring Event Bus (In-Process)

Spring ships a synchronous, in-process event mechanism built into ApplicationContext. It is not a message broker — events do not cross process boundaries — but it is a clean way to decouple components within a single service and to learn the pattern before adding infrastructure.

// Event — a plain Java record (Spring Boot 3) public record OrderPlacedEvent(Long orderId, String customerId, java.math.BigDecimal total) {} // Publisher — any Spring bean @Service @RequiredArgsConstructor public class OrderService { private final ApplicationEventPublisher publisher; private final OrderRepository repo; @Transactional public Order placeOrder(CreateOrderRequest req) { Order order = repo.save(Order.from(req)); publisher.publishEvent(new OrderPlacedEvent(order.getId(), order.getCustomerId(), order.getTotal())); return order; } } // Consumer — any Spring bean @Component public class NotificationListener { @EventListener public void onOrderPlaced(OrderPlacedEvent event) { System.out.println("Send confirmation email to customer " + event.customerId()); } }

Annotate the listener method with @EventListener and Spring injects the event by type. Add @Async alongside it to execute the listener in a separate thread pool — but note that with in-process async events a publisher crash still loses unpublished events.

Crossing Process Boundaries with a Message Broker

For real microservices you need events to travel between JVM processes. A message broker (RabbitMQ, Apache Kafka, AWS SNS/SQS) acts as durable intermediary. Spring Cloud Stream provides a unified programming model that works with any supported binder.

Add the RabbitMQ binder to pom.xml:

<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-stream-rabbit</artifactId> </dependency>

With Spring Cloud Stream you model messaging as ordinary Java functions. A supplier produces events; a consumer reacts to them; a function transforms them. Spring Cloud Stream binds these to broker channels via configuration.

// Producer service — order-service/src/main/java/…/OrderEventPublisher.java @Configuration public class OrderEventPublisher { // A supplier that streams orders — in practice you would push to a StreamBridge // for imperative publishing from a @Service method. } // In OrderService, use StreamBridge for imperative publishing: @Service @RequiredArgsConstructor public class OrderService { private final StreamBridge streamBridge; private final OrderRepository repo; @Transactional public Order placeOrder(CreateOrderRequest req) { Order order = repo.save(Order.from(req)); streamBridge.send("order-placed-out-0", new OrderPlacedEvent(order.getId(), order.getCustomerId(), order.getTotal())); return order; } }
# application.yml — order-service spring: cloud: stream: bindings: order-placed-out-0: destination: orders.placed # exchange / topic name on the broker content-type: application/json

The consuming service declares a plain Consumer<T> bean:

// Consumer service — notification-service/src/main/java/…/OrderEventHandlers.java @Configuration public class OrderEventHandlers { @Bean public Consumer<OrderPlacedEvent> handleOrderPlaced() { return event -> { // send email, push notification, etc. System.out.println("Notifying customer " + event.customerId() + " for order " + event.orderId()); }; } }
# application.yml — notification-service spring: cloud: stream: bindings: handleOrderPlaced-in-0: destination: orders.placed # must match the producer's destination group: notification-service # consumer group — only one instance processes each message content-type: application/json
Always set a group on consumer bindings. Without a group, every instance of the service receives every message (broadcast semantics). With a group, the broker load-balances across instances — exactly what you want for work queues.

The Outbox Pattern — Guaranteeing At-Least-Once Delivery

The most dangerous anti-pattern in event-driven systems is publishing an event after committing a database transaction in separate steps. If the service crashes between the commit and the broker call, the event is lost forever — a phantom order with no notification.

The Transactional Outbox Pattern solves this: write the event to an outbox table in the same database transaction as the business data. A separate polling process (or Debezium CDC) reads unprocessed rows and publishes them to the broker, then marks them as sent.

// 1. In the same @Transactional method, persist both the aggregate and the outbox row: @Transactional public Order placeOrder(CreateOrderRequest req) { Order order = repo.save(Order.from(req)); OutboxEvent outbox = new OutboxEvent(); outbox.setAggregateType("Order"); outbox.setEventType("OrderPlaced"); outbox.setPayload(serialize(new OrderPlacedEvent(order.getId(), order.getCustomerId(), order.getTotal()))); outboxRepo.save(outbox); // same transaction — atomic! return order; } // 2. A @Scheduled poller (or Debezium) reads the outbox and publishes: @Scheduled(fixedDelay = 500) @Transactional public void publishPendingEvents() { outboxRepo.findTop100ByPublishedFalse().forEach(e -> { streamBridge.send("order-placed-out-0", e.getPayload()); e.setPublished(true); }); }
Idempotent consumers are non-negotiable. The outbox guarantees at-least-once delivery — a crash during the poller can replay the same event. Every consumer must detect and ignore duplicates (store processed event IDs in a deduplication table, or use idempotent operations such as upserts).

Event Versioning and Schema Evolution

Events are a public API. Consumers you do not control may depend on the shape of OrderPlacedEvent. Evolving that shape without breaking consumers requires discipline:

  • Additive changes are safe — add optional fields; existing consumers ignore unknown fields (with Jackson's FAIL_ON_UNKNOWN_PROPERTIES = false, which is Spring Boot's default).
  • Removing or renaming fields is breaking — deprecate first, wait for all consumers to migrate, then remove.
  • Version your event types — use a version field or route breaking changes to a new topic (orders.placed.v2).

Security Considerations

Event-driven systems have a broader attack surface than direct REST calls:

  • Authenticate producers — configure SASL/TLS on the broker. Anyone who can write to orders.placed can inject fraudulent events.
  • Validate every event — treat incoming messages with the same scepticism as HTTP requests. A malformed payload can crash a consumer; an injected payload can corrupt data.
  • Do not embed sensitive data — avoid putting PII or payment card data in events. Publish a reference (an order ID) and let the consumer fetch the sensitive details from the owning service via a secured API call.
  • Propagate trace context — include the W3C traceparent header in message metadata so distributed tracing (covered in Lesson 8) can correlate the full flow across services.

Summary

Event-driven microservices replace brittle synchronous calls with durable, broker-mediated events. Within a service use Spring's ApplicationEventPublisher and @EventListener to decouple components. Across services, Spring Cloud Stream with a RabbitMQ or Kafka binder provides a function-based model where you write plain Java Supplier, Function, and Consumer beans. Use the Transactional Outbox Pattern to guarantee at-least-once delivery without dual-write failure. Design consumers to be idempotent, version your event schemas additively, and always secure broker access. In Lesson 7 you will go deeper into Kafka's log-based streaming semantics.