PCSalt
YouTube GitHub
Back to Architecture
Architecture · 5 min read

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:

CommandEvent
TenseImperative (“do this”)Past (“this happened”)
DirectionTargeted at a specific serviceBroadcast to whoever cares
CouplingSender knows the receiverSender doesn’t know who listens
ExampleSendEmail(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

FeatureKafkaRabbitMQ
ModelDistributed logMessage queue
RetentionConfigurable (days/forever)Until consumed
OrderingPer partitionPer queue
ReplayYes (re-read from any offset)No (once consumed, gone)
ThroughputVery highHigh
Use caseEvent streaming, audit logTask queues, RPC
ComplexityHigher (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.