Event-Driven Architecture
A producer records that something happened and stops caring who listens; consumers subscribe and react on their own clock — which buys decoupling and fan-out at the price of eventual consistency, duplicate delivery, ordering you must design for, and a "who owns the truth" question you must answer explicitly.
An order service that calls Email, Inventory and Analytics synchronously is coupled to all three: a slow analytics endpoint slows checkout, a down email service fails the order, and adding a fourth consumer means editing the order service. Publishing one OrderCreated event lets each consumer react independently, so checkout latency and availability depend only on the order service and the bus.
Producers, events, consumers
An event is an immutable fact, named in the past tense: OrderCreated { orderId, userId, total, occurredAt }. The producer appends it to a topic on an event bus (Kafka, RabbitMQ exchanges, SNS+SQS, Redis streams) and returns as soon as the bus has durably accepted it. Each consumer holds its own subscription and reads at its own pace: Email sends a confirmation, Inventory decrements stock, Analytics increments a counter. The producer does not know how many consumers exist, and a new one can be added by subscribing — nobody edits the order service.
The cost is that the three consumers are no longer in the same transaction as the order. For some milliseconds (or, if a consumer is down, minutes) the order exists but stock has not been reserved and the email has not gone out. That is eventual consistency, and every event-driven design must decide which reads may observe the gap and which must not. Compare the synchronous form in Request/Response vs Event-Driven.
Events versus commands
An event says something happened and has many possible listeners (OrderCreated). A command says do this and has exactly one intended handler (ReserveInventory). The difference is not naming taste: with an event, the producer holds no opinion about the outcome and cannot fail because a consumer failed; with a command, the sender expects a result and must handle rejection. Putting a command on a pub/sub topic gives you a request with no reply channel and no way to know whether anyone acted on it. Sending an event when you actually need a decision — "is there stock?" — hides a synchronous dependency inside an asynchronous shape.
The who owns the truth question follows directly. The Orders DB is the source of truth for orders; the event is a notification derived from it. A consumer may build its own copy (Analytics keeps an order count) but must treat it as a projection that can be rebuilt by replay, never as the authority. When two services disagree about an order, the answer is the owning service’s database, and the fix is to replay events into the consumer — not to patch the consumer’s table by hand. Event Sourcing inverts this and makes the log the truth; most systems should not.
| Event | Command | |
|---|---|---|
| Tense | Past: OrderCreated | Imperative: ReserveInventory |
| Handlers | Zero to many, unknown to the producer | Exactly one, known to the sender |
| Can the sender be told "no"? | No — the fact already happened | Yes — rejection is a normal outcome |
| Transport | Topic / pub/sub | Queue, or a synchronous call |
| Truth | Owned by the producer’s store | Owned by the handler after it acts |
Duplicates, ordering, replay
Every practical bus delivers at least once. The consumer processes an event, crashes before acknowledging, restarts, and receives it again; or the broker times out an ack and redelivers. "Exactly-once delivery" across a network is not a thing you can buy — the honest form is at-least-once delivery plus an idempotent consumer: the consumer records which event ids it has already applied and makes the second application a no-op. Inventory that decrements twice on a duplicate OrderCreated is a bug in the consumer, not the bus.
Ordering is only guaranteed within one partition or one queue; across partitions, OrderUpdated can arrive before OrderCreated. The partition key decides what stays ordered — see Kafka-Style Logs: Topics, Partitions, Offsets and the challenge where a key change reversed events. Consumers should also tolerate a stale event: check version or occurredAt and ignore anything older than what they already hold.
Replay is the payoff of a durable log: a new consumer, or one whose projection was corrupted, rewinds to offset 0 and rebuilds. It only works if consumers are idempotent (the same events will be applied again) and if schema evolution was disciplined: add optional fields, never rename or repurpose one, keep a schemaVersion, and register schemas so a producer cannot publish something no consumer can parse. An event published two years ago will be read again.
1async function onOrderCreated(evt: OrderCreated, db: Db): Promise<void> {2 await db.transaction(async (tx) => {3 // INSERT … ON CONFLICT DO NOTHING; returns 0 rows when already processed4 const inserted = await tx.insertIgnore('processed_events', { eventId: evt.id, consumer: 'inventory' })5 if (inserted === 0) return // duplicate delivery: effect already applied, ack and move on6 7 for (const line of evt.lines) {8 await tx.exec('UPDATE stock SET reserved = reserved + $1 WHERE sku = $2', [line.qty, line.sku])9 }10 })11 // ack only after the transaction committed; a crash before this line means redelivery, which is safe12}Key points
- An event is an immutable past-tense fact with zero-to-many consumers; a command is a request with exactly one handler. Do not disguise one as the other.
- Publishing decouples availability and latency: checkout no longer waits for, or fails because of, Email or Analytics.
- Every bus is at-least-once. Exactly-once is at-least-once plus an idempotent consumer keyed on the event id.
- Ordering holds only within one partition; choose the key that keeps what must be ordered together.
- The producer’s database owns the truth; consumer tables are projections that replay can rebuild.
OrderCreated: fan-out, duplicates, ordering
How data moves through it
One request or event, hop by hop.
- 1Checkout → Order Service:
POST /orders; the service validates and writes the order row. - 2Order Service → Orders DB: commit order plus an outbox row holding the
OrderCreatedpayload in one transaction. - 3Outbox relay → Event Bus: publish
OrderCreatedto topicorders, keyed byorderId; returns the 201 to the client before or after, by design. - 4Event Bus → Email / Inventory / Analytics: each subscription delivers the event independently and tracks its own position.
- 5Consumer → its own DB: apply the effect and insert the event id into
processed_eventsin one transaction, then ack.
When to use — and when not
- One fact has several independent reactions (email, stock, analytics, fraud) and the set grows over time.
- Consumers may be slow or down without that being allowed to fail the producer’s request.
- Bursty producers and steady consumers: the bus absorbs the spike, consumers drain it.
- Other teams need to react to your domain without you calling their APIs.
- The caller needs an answer now — "is this card valid?" is a synchronous question, not an event.
- The workflow needs strict ordering across entities or a single transaction; see Distributed Transactions and Saga Pattern before reaching for a bus.
- One producer, one consumer, low volume: a queue or a direct call is simpler than topics and subscriptions.
- The team cannot yet operate a broker, monitor consumer lag, or reason about duplicates — the bus will hide failures they cannot see.
Tradeoffs
Low request-path latency and excellent fan-out; paid for with eventual consistency, a broker to run, and consumers that must be idempotent and order-tolerant.
How it fails
- Consumer applies a duplicate
OrderCreatedand reserves stock twice — no idempotency check keyed on the event id. - A consumer falls hours behind and nobody notices because the producer is healthy; the symptom is stale inventory, not an error.
- Producer writes the order and then fails to publish (or publishes and fails to commit): the two stores diverge. The outbox pattern in Distributed Transactions fixes it.
- A renamed field in the event schema breaks a consumer that is replaying two-year-old history.
- Business logic scattered across nine consumers so that nobody can say what happens after an order — the event spaghetti that makes debugging need Distributed Tracing.
How it scales
- Producers scale trivially; the bus partitions topics so throughput scales with partition count.
- Consumers scale within a consumer group up to the number of partitions; beyond that, repartition.
- Retention and replay cost storage linearly in event volume; compaction keeps the latest per key when history is not needed.
- The limit is consumer throughput, not producer throughput; see Backpressure when the backlog grows without bound.
How it interacts with databases, queues, caches, APIs and external systems
- Database: the producer’s DB owns the truth; the outbox table bridges the commit and the publish atomically.
- Queue/bus: Kafka topics, RabbitMQ exchanges, SNS+SQS, or Redis streams (Redis: Data Structures, Not a Cache) carry the events; choose by retention and replay needs (Message Queues).
- Cache: consumers frequently invalidate or refresh cache entries on events instead of on TTL — see Caching Architecture.
- External systems: webhooks are events crossing an organisational boundary and need the same idempotency keys and retries.
- Observability: a
traceparentcarried in event headers lets a trace follow the fan-out across consumers.