Designing Event-Driven Microservices with Spring Cloud Stream
Designing Event-Driven Microservices with Spring Cloud Stream
Event-driven architecture is the backbone of modern microservices, enabling loose coupling and scalability. After migrating a monolithic PHP application to event-driven Java microservices, here's what I learned about designing robust event-driven systems.
Why Event-Driven Architecture?
Traditional request-response microservices create tight coupling between services. Event-driven architecture solves this by:
- Decoupling — Services communicate through events, not direct calls
- Scalability — Process events independently at different speeds
- Resilience — Failed events can be retried without affecting other services
- Auditability — Every state change is an event that can be replayed
Spring Cloud Stream Overview
Spring Cloud Stream provides a opinionated approach to event-driven microservices:
spring:
cloud:
stream:
bindings:
input-orders:
destination: orders-topic
group: order-service
kafka:
binder:
brokers: localhost:9092
Key Patterns
1. Event Sourcing
Instead of storing current state, store all changes as events:
@EventSourcing
public class OrderService {
@Aggregate
public Order aggregate(OrderCreatedEvent event) {
return new Order(event.getOrderId(), event.getStatus());
}
@EventHandler
public void handle(OrderShippedEvent event) {
// Update order status
}
}
2. Sagas for Distributed Transactions
Coordinate long-running transactions across services:
@Saga
public class OrderSaga {
@Start
public OrderContext start(OrderCreatedEvent event) {
return new OrderContext(event.getOrderId());
}
@Step
public void processPayment(OrderContext context, @StepId("payment") PaymentStartedEvent event) {
paymentService.processPayment(context.getOrderId());
}
@Step
public void shipOrder(OrderContext context, @StepId("shipping") PaymentCompletedEvent event) {
shippingService.shipOrder(context.getOrderId());
}
}
3. CQRS (Command Query Responsibility Segregation)
Separate write and read models:
// Write side
@CommandHandler
public void handle(CreateOrderCommand command) {
Order order = new Order(command.getProductId(), command.getQuantity());
orderRepository.save(order);
eventPublisher.publish(new OrderCreatedEvent(order));
}
// Read side
@QueryHandler
public OrderDto query(GetOrderQuery query) {
return orderQueryRepository.findById(query.getOrderId());
}
Common Pitfalls
1. Event Versioning
Events evolve over time. Always use schema versioning:
public record OrderEventV2(
String orderId,
String productId,
int quantity,
String customerEmail,
String version // Always include version
) {}
2. Out-of-Order Events
Network issues can cause events to arrive out of order:
@StreamListener("input-orders")
public void handleOrderEvent(OrderEvent event) {
// Use sequence numbers to detect and reorder events
sequenceDetector.process(event.getSequenceNumber(), event);
}
3. Duplicate Events
Events can be delivered multiple times. Make handlers idempotent:
@EventHandler
public void handle(OrderCreatedEvent event) {
// Check if already processed
if (processedEvents.contains(event.getEventId())) {
return; // Idempotent
}
processedEvents.add(event.getEventId());
// Process event
}
Monitoring Event-Driven Systems
Track these metrics:
- Event throughput — Events processed per second
- Processing latency — Time from event creation to processing
- Dead letter queue size — Failed events awaiting manual intervention
- Consumer lag — Delay between event production and consumption
Technology Stack
- Spring Cloud Stream — Abstraction over message brokers
- Apache Kafka — Distributed event streaming platform
- Spring Kafka — Kafka integration for Spring Boot
- Avro/Protobuf — Binary serialization for events
- Schema Registry — Centralized schema management
Conclusion
Event-driven architecture requires careful design around event versioning, idempotency, and ordering. Spring Cloud Stream simplifies the implementation, but the architectural patterns remain complex. Start simple, add complexity as needed, and always prioritize idempotent event handlers.