A customer places an order. The system has to charge the card, reserve stock, send a confirmation email, update analytics and notify the warehouse. The direct way to build it is one function that calls each of those in turn. It works, until the email service is slow, the analytics service is down, or a sixth step is added and the function grows again.
Event-driven architecture takes a different approach. The order service records that an order was placed and announces it. Everything else reacts. This guide explains how that works, what it costs, and when to use it.
What Is Event-Driven Architecture?
Event-driven architecture (EDA) is a design in which components communicate by producing and reacting to events. An event is a record that something happened, such as OrderPlaced or PaymentFailed.
The pieces are:
- Producer. The component that notices something happened and publishes an event.
- Event. A small, immutable message describing the fact.
- Broker. The infrastructure that receives events and delivers them, such as Kafka, RabbitMQ, or a cloud queue like Amazon SQS.
- Consumer. A component that subscribes to events and reacts.
The producer does not know who consumes its events, or whether anyone does.
Request-Driven vs Event-Driven
| Request-driven | Event-driven | |
|---|---|---|
| Style | A calls B and waits | A announces, B reacts later |
| Coupling | A knows about B | A knows only the event |
| Failure of B | A fails or must handle it | A is unaffected, B catches up later |
| Adding a new reaction | Change A | Add a new consumer |
| Flow of control | Easy to follow | Spread across components |
| Consistency | Immediate | Eventual |
A Small Example
Inside one process, Node.js has this idea built in:
import { EventEmitter } from 'node:events'
const bus = new EventEmitter()
// Consumers
bus.on('order.placed', (order) => sendConfirmationEmail(order))
bus.on('order.placed', (order) => reserveStock(order))
bus.on('order.placed', (order) => recordAnalytics(order))
// Producer
function placeOrder(order) {
saveOrder(order)
bus.emit('order.placed', order)
}
Adding a fourth reaction means adding another bus.on line. placeOrder does not change.
An in-process emitter has no durability. If the process dies, pending events vanish. Across services, a broker provides the persistence and delivery.
What an Event Looks Like
{
"id": "9b7a1c1e-6f3a-4f56-9d6e-7f3b8c1a2d10",
"type": "order.placed",
"occurredAt": "2026-10-31T09:14:22Z",
"version": 1,
"data": {
"orderId": "ord_1042",
"customerId": "cus_77",
"total": 8400,
"currency": "USD"
}
}
A unique id lets consumers detect duplicates. A version lets the shape evolve without breaking old consumers. Name events in the past tense, because they describe facts that already happened.
Common Patterns
Event notification. The event says something happened and carries little data, perhaps only an ID. Consumers call back to the source for details if they need them. It is simple and keeps events small, at the cost of extra calls.
Event-carried state transfer. The event includes the data consumers need, so they do not need to call back. It reduces coupling at runtime and makes events larger.
Publish/subscribe. One event reaches many independent consumers. It is the pattern behind the order example above.
Event sourcing. The system stores the sequence of events as the source of truth, and derives current state from it. It gives a complete history and is a larger commitment than the others. Most systems that use events do not use event sourcing.
CQRS. Writes and reads use separate models, often with events feeding the read side. It is useful when read and write needs differ greatly, and is frequently paired with event sourcing.
What You Gain
- Loose coupling. Producers and consumers evolve independently.
- Resilience. If a consumer is down, the broker holds events until it returns.
- Easy extension. A new feature subscribes to existing events without touching existing code.
- Handling bursts. A queue absorbs a spike, and consumers work through it at their own pace.
- An audit trail. Events are a record of what happened, and some brokers keep them for replay.
What It Costs
This part is often skipped, and it is where most teams get hurt.
Eventual consistency. After an order is placed, the stock count might be stale for a moment. The interface and the business rules have to tolerate that.
Delivery guarantees. Most brokers guarantee at-least-once delivery. A consumer will sometimes receive the same event twice, so handlers must be idempotent: processing an event twice must have the same effect as once.
async function handleOrderPlaced(event) {
const alreadyHandled = await processedEvents.has(event.id)
if (alreadyHandled) return
await reserveStock(event.data)
await processedEvents.add(event.id)
}
In a real system the check and the work belong in one transaction, or the work itself should be naturally idempotent.
Ordering. Events may arrive out of order across partitions or consumers. If order matters for one entity, route all its events through the same partition key.
Failure handling. When a handler keeps failing on one event, you need retries with backoff and a dead-letter queue where poison messages go for inspection, so they do not block everything else.
Harder debugging. There is no single stack trace for a request that crossed five consumers. Correlation IDs on every event and distributed tracing become necessary.
Publishing reliably. Saving to the database and publishing to the broker are two separate writes, and one can succeed while the other fails. The transactional outbox pattern solves it: write the event to a table in the same transaction as the data, and have a separate process publish from that table.
When to Use It
Event-driven design fits when:
- Several parts of the system react to the same fact.
- Work can happen after the user's request finishes, such as emails, reports and indexing.
- Traffic is bursty and you want to smooth it.
- Teams or services need to stay independent.
- You integrate with systems that already emit events, such as payment providers' webhooks.
It is a poor fit when:
- The system is small and one team owns it. A function call is simpler.
- The user needs the result of the step immediately, and the next screen depends on it.
- You need strong consistency across the steps.
- The team has no capacity to operate a broker and monitor consumers.
A sensible path for most products is a well-structured monolith first, then a job queue for background work, and a move to broader event-driven design when a clear need appears. For how isolation decisions shape a larger system, see our guide on multi-tenant SaaS architecture.
Common Mistakes
- Treating events as remote procedure calls. An event announces a fact. A message that says "do this now" is a command, and it should go to one specific handler.
- Non-idempotent consumers. Duplicates will arrive, and they will double-charge or double-send.
- Huge events. Large payloads slow the broker and couple consumers to the producer's internals.
- No schema versioning. One field rename breaks every consumer at once.
- No dead-letter queue. One bad message stalls a whole queue.
- Using events everywhere. Not every call needs to be asynchronous.
Summary
Event-driven architecture lets components react to facts instead of calling each other directly. It reduces coupling and handles spikes well, and it brings eventual consistency, duplicate delivery and harder debugging. Use it where several parts of a system need to react to the same thing, and keep it out of places where a plain function call does the job.
