Event-Driven Architecture — When and Why (Not Just Kafka)
Understand event-driven architecture beyond the buzzwords — events vs commands, messaging patterns, when to use it, and when synchronous calls are better.
Event-driven architecture (EDA) is one of those patterns that’s easy to sell and hard to live with. “Services communicate through events! Everything is decoupled! Scale infinitely!” Then you spend three months debugging eventual consistency bugs and building dead letter queue handlers.
EDA is powerful. But it’s not always the right call. This post covers what it actually is, when it helps, and when you’re better off with a simple REST call.
What is an event?
An event is a record of something that happened. Past tense. Immutable. Facts.
{
"type": "OrderPlaced",
"orderId": "ord-123",
"customerId": "cust-456",
"total": 89.99,
"timestamp": "2025-06-15T10:30:00Z"
}
An event says “this happened.” It doesn’t ask anyone to do anything. The OrderPlaced event doesn’t say “send a confirmation email” or “update inventory.” Other services decide what to do with it.
This is the key distinction:
| Command | Event | |
|---|---|---|
| Tense | Imperative (“do this”) | Past (“this happened”) |
| Direction | Targeted at a specific service | Broadcast to whoever cares |
| Coupling | Sender knows the receiver | Sender doesn’t know who listens |
| Example | SendEmail(to, subject, body) | OrderPlaced(orderId, total) |
Commands create coupling — the sender depends on the receiver. Events invert that — the receiver depends on the sender’s event schema, but the sender is oblivious to receivers.
The pattern
In a synchronous architecture:
Order Service → Inventory Service → Payment Service → Email Service
Each service calls the next. If Email Service is down, the chain breaks. If you add a Loyalty Points service, you modify Order Service to call it too.
In an event-driven architecture:
Order Service → publishes OrderPlaced event
↓
Message Broker (Kafka, RabbitMQ, etc.)
↓
┌───────────────┼───────────────┐
↓ ↓ ↓
Inventory Payment Email
Service Service Service
Order Service publishes OrderPlaced. It doesn’t know or care who listens. Adding a Loyalty Points service? Just subscribe to OrderPlaced. No change to Order Service.
Event types
Domain events
Business-meaningful things that happened:
OrderPlaced, PaymentProcessed, UserRegistered, ItemShipped
These are the core of EDA. They represent state changes in your domain.
Integration events
Events published for cross-service communication. Often a simplified version of domain events — stripped of internal details:
// Internal domain event (rich)
data class OrderPlacedInternal(
val orderId: String,
val items: List<OrderItem>,
val customerId: String,
val shippingAddress: Address,
val paymentMethod: PaymentMethod,
val internalNotes: String
)
// Integration event (published externally)
data class OrderPlaced(
val orderId: String,
val customerId: String,
val total: Double,
val itemCount: Int
)
Don’t leak internal implementation through events. Other services don’t need to know your database schema.
Change Data Capture (CDC) events
Events derived from database changes — typically using tools like Debezium:
{
"before": null,
"after": { "id": 123, "status": "active", "name": "Alice" },
"source": { "table": "users" },
"op": "c"
}
CDC is useful for keeping read models in sync without modifying the source service. But CDC events are low-level — they expose database structure, not business intent.
Messaging patterns
Publish-Subscribe (pub/sub)
One publisher, many subscribers. Each subscriber gets every message independently.
OrderPlaced → Topic
├── Inventory Consumer (own offset)
├── Email Consumer (own offset)
└── Analytics Consumer (own offset)
Use for: broadcasting events to multiple independent consumers.
Point-to-Point (queue)
One sender, one receiver. Messages are consumed once.
ProcessPayment → Queue → Payment Worker
Use for: task distribution, work queues, commands.
Request-Reply over messaging
Async request with a correlated response:
Service A → Request Queue → Service B
Service A ← Reply Queue ← Service B
(correlated by requestId)
Use sparingly. If you need request-reply, a synchronous HTTP call is usually simpler.
Kafka vs RabbitMQ vs other brokers
| Feature | Kafka | RabbitMQ |
|---|---|---|
| Model | Distributed log | Message queue |
| Retention | Configurable (days/forever) | Until consumed |
| Ordering | Per partition | Per queue |
| Replay | Yes (re-read from any offset) | No (once consumed, gone) |
| Throughput | Very high | High |
| Use case | Event streaming, audit log | Task queues, RPC |
| Complexity | Higher (ZooKeeper/KRaft, partitions) | Lower |
Kafka is best for: event sourcing, audit trails, high-throughput streaming, cases where you need replay.
RabbitMQ is best for: task queues, routing, RPC patterns, simpler operational needs.
For most event-driven architectures, either works. Pick based on your team’s experience and operational comfort.
When EDA helps
1. Decoupling services
When Service A shouldn’t know about Services B, C, and D. Publish an event, let interested services subscribe.
2. Handling spikes
A burst of orders at 10x normal rate. With sync calls, downstream services fail. With a message broker, the queue absorbs the spike and consumers process at their own pace.
3. Audit and replay
Events are immutable records. You can replay them to rebuild state, debug issues, or populate new services. Try doing that with REST calls.
4. Cross-team boundaries
Team A owns the Order service. Team B owns the Notification service. With events, Team B subscribes to OrderPlaced without needing Team A to add a REST call. No coordination required.
5. Eventually consistent read models
CQRS pattern — write to a normalized database, publish events, build denormalized read models for fast queries. See the CQRS series for a deep dive.
When EDA hurts
1. Simple CRUD
If your service is “get data from the database and return it,” events add complexity for no benefit.
2. When you need immediate consistency
“Show the user their updated profile immediately after saving.” With events, there’s a delay between writing and the read model updating. If you need synchronous consistency, use a synchronous call.
3. When ordering matters globally
Events within a partition are ordered. Across partitions, they’re not. If you need global ordering across all events, messaging gets complicated fast.
4. Debugging
Synchronous call: request → response → done. Easy to trace. Event-driven: event published → broker → consumer A processes → publishes another event → consumer B processes → … Good luck following that without distributed tracing (Jaeger, Zipkin).
5. Small teams
EDA introduces infrastructure (broker, monitoring, DLQ handling) and cognitive overhead (eventual consistency, idempotency). A team of 3 doesn’t need this.
The hard parts
Eventual consistency
When Service A publishes OrderPlaced, the Inventory service might not process it for seconds or minutes. During that gap, the inventory count is stale. Your UI needs to handle this — show “processing” states, don’t assume instant updates.
Idempotency
Messages can be delivered more than once (at-least-once delivery is the default in Kafka). Your consumers must handle duplicates:
fun handleOrderPlaced(event: OrderPlaced) {
// Check if already processed
if (processedEvents.contains(event.orderId)) {
return // skip duplicate
}
// Process the event
inventoryService.reserveItems(event.orderId, event.items)
// Mark as processed
processedEvents.add(event.orderId)
}
Dead letter queues
When a consumer can’t process a message (bad data, bug, dependency down), it goes to a dead letter queue (DLQ). You need monitoring, alerting, and a strategy for replaying DLQ messages.
Schema evolution
Events are contracts. You can’t just rename a field — existing consumers will break. Use a schema registry (Avro, Protobuf) or follow additive-only changes (add fields, don’t remove or rename).
Monitoring
You need to track: consumer lag (how far behind are consumers?), DLQ depth (how many failed messages?), throughput (events per second), and end-to-end latency (time from publish to process).
Making the decision
Do you need multiple services to react to the same action?
→ Yes → Consider events
Do you need to absorb traffic spikes?
→ Yes → Consider events
Do you need an audit trail or replay capability?
→ Yes → Consider events
Is it a simple request-response between two services?
→ Yes → Use HTTP/gRPC
Does the caller need an immediate response?
→ Yes → Use HTTP/gRPC
Is your team small and the system simple?
→ Yes → Start synchronous, add events when the pain appears
The best architectures use both. Synchronous calls for queries and immediate operations. Events for state changes that multiple services care about. Don’t pick one and force everything through it.
Summary
Event-driven architecture decouples services by replacing direct calls with events. It excels at handling spikes, enabling independent teams, and building audit trails. But it introduces complexity — eventual consistency, idempotency, dead letter queues, and distributed debugging.
Start with synchronous. Add events where the decoupling benefit is clear. Don’t adopt EDA because it sounds modern — adopt it because your system needs it.