Event-Driven Architecture
Event-driven architecture (EDA) flips the traditional request/response model on its head: instead of services calling each other directly, they publish facts about what happened and react to facts published by others. Apache Kafka is the de-facto backbone for EDA because it gives you a durable, replayable, high-throughput log that decouples producers from consumers in time, space, and scale. Getting this right is the difference between a system that absorbs failure gracefully and a distributed monolith that collapses when one service is slow.
Events as the integration contract
In a synchronous architecture the API signature is the contract. In EDA, the event schema is the contract. A producer emits an immutable record describing something that already happened — OrderPlaced, PaymentCaptured, InventoryReserved — and has no knowledge of who consumes it. Consumers subscribe to topics and decide independently how to react.
Events should be named in the past tense and carry enough context for downstream services to act without calling back. Model them as immutable records.
public record OrderPlaced(
String orderId,
String customerId,
List<LineItem> items,
BigDecimal total,
Instant occurredAt) {
public record LineItem(String sku, int quantity, BigDecimal unitPrice) {}
}
Publishing is a one-line operation with KafkaTemplate. Keying by orderId guarantees all events for one order land on the same partition and are processed in order.
@Service
public class OrderEventPublisher {
private final KafkaTemplate<String, OrderPlaced> kafkaTemplate;
public OrderEventPublisher(KafkaTemplate<String, OrderPlaced> kafkaTemplate) {
this.kafkaTemplate = kafkaTemplate;
}
public void publish(OrderPlaced event) {
kafkaTemplate.send("orders.placed", event.orderId(), event);
}
}
A consumer in a completely separate service reacts without the producer ever knowing it exists:
@Component
public class InventoryListener {
@KafkaListener(topics = "orders.placed", groupId = "inventory-service")
public void onOrderPlaced(OrderPlaced event) {
// Reserve stock for each line item, then emit InventoryReserved
reserveStock(event.items());
}
}
How services communicate through topics
The topic is the integration point. One service writes; many independent consumer groups read the same events for different purposes.
┌──────────────────────────┐
┌────────────┐ │ Kafka topic │ ┌──────────────────┐
│ Order │ write │ orders.placed │ read │ Inventory svc │
│ Service ├──────►│ [evt][evt][evt][evt]... ├──────►│ (reserve stock) │
└────────────┘ │ │ └──────────────────┘
│ │ read ┌──────────────────┐
│ ├──────►│ Notification │
│ │ │ (email/SMS) │
│ │ read ┌──────────────────┐
│ ├──────►│ Analytics svc │
└──────────────────────────┘ └──────────────────┘
Adding the analytics service required zero changes to the order service — that is the core payoff of EDA.
Choreography vs orchestration
There are two ways to coordinate a multi-step business process across services.
Choreography is fully decentralized: each service listens for events and emits its own, and the workflow emerges from the chain of reactions. Orchestration introduces a central coordinator (an orchestrator or saga manager) that issues commands and tracks the overall state of the process.
| Aspect | Choreography | Orchestration |
|---|---|---|
| Control | Distributed across services | Centralized coordinator |
| Coupling | Lowest — services only know events | Higher — orchestrator knows the flow |
| Visibility | Flow is implicit, harder to trace | Flow is explicit and queryable |
| Adding a step | Add a new consumer, no edits | Edit the orchestrator |
| Best for | Simple, loosely related reactions | Complex flows with rollback/compensation |
Tip: Start with choreography for simple fan-out reactions. Reach for orchestration (a saga) once a process spans many steps and needs compensating actions or a clear audit of where it is.
Benefits
- Decoupling — producers and consumers deploy, scale, and fail independently. Neither needs the other to be online.
- Scalability — partitions let you scale consumers horizontally; a slow consumer never blocks the producer.
- Replayability — because Kafka retains the log, you can reset a consumer group’s offsets to rebuild state or backfill a brand-new service from history.
- Extensibility — new capabilities subscribe to existing events without touching upstream code.
Challenges
EDA is not free. The trade-offs are real and must be designed for, not discovered in production.
- Eventual consistency — there is a window where services disagree about state. Your UI and APIs must tolerate “in progress” states rather than assuming everything is instantly consistent.
- Debugging and tracing — a single user action becomes a fan of asynchronous events. Propagate a correlation ID in headers and invest in distributed tracing early.
- Duplicate and out-of-order delivery — Kafka guarantees at-least-once by default, so consumers must be idempotent.
- Schema evolution — since the event is the contract, breaking changes break consumers. Use a schema registry with backward-compatible evolution rules.
Propagating a correlation ID keeps a logical transaction traceable across hops:
@KafkaListener(topics = "orders.placed", groupId = "inventory-service")
public void onOrderPlaced(
ConsumerRecord<String, OrderPlaced> record,
@Header(name = "correlationId", required = false) String correlationId) {
MDC.put("correlationId", correlationId);
try {
reserveStock(record.value().items());
} finally {
MDC.clear();
}
}
When event-driven architecture fits
EDA shines when you have multiple services that need to react to the same business facts, when workloads are bursty and need buffering, when you want an audit trail of everything that happened, or when teams need to ship independently. It is overkill for a simple CRUD app with one service and a single database, and it is the wrong tool when a caller genuinely needs a synchronous answer right now — request/response over REST or gRPC is still correct for read-after-write queries.
Best Practices
- Name events as immutable, past-tense facts (
PaymentCaptured), not commands (CapturePayment). - Make every consumer idempotent so at-least-once delivery and replays are safe.
- Key messages by the entity ID to preserve per-entity ordering within a partition.
- Govern schemas with a registry and enforce backward compatibility before publishing breaking changes.
- Propagate a correlation ID in headers and adopt distributed tracing from day one.
- Design APIs and UIs to embrace eventual consistency instead of fighting it.
- Default to choreography for simple flows; escalate to an orchestrated saga only when you need compensation and explicit visibility.