Event-Driven Architecture Explained
Event-driven architecture is the practice of building systems where components communicate by announcing that something happened rather than by calling each other directly, and its appeal is real: a service that emits an OrderPlaced event does not need to know that an email service, an inventory service, and an analytics pipeline all care. You can add a fourth consumer without touching the producer. That decoupling is genuinely powerful, and it is also where most of the difficulty hides. The moment you replace a function call with an event, you inherit a distributed-systems problem: messages can be delivered twice, arrive out of order, or vanish; the producer and its database can disagree about whether an event was sent; and a bug now spans five services with no single stack trace to read. This post is about both halves — the patterns that make event-driven systems work, and the honest costs that decide whether you should build one at all. For the deep specialist topics it touches, I will point at the event sourcing and CQRS, outbox pattern, and Kafka posts rather than re-derive them here.
What an event actually is
The word “event” gets used loosely, so it is worth being precise, because the distinctions drive the architecture. An event is an immutable statement of fact about something that already happened, named in the past tense: OrderPlaced, PaymentCaptured, InventoryReserved. It is not addressed to anyone. The producer does not know or care who consumes it. Contrast that with a command — PlaceOrder, CapturePayment — which is an instruction directed at a specific handler that is expected to act. Events describe the past; commands request the future. Mixing them up is the single most common conceptual error in event-driven design: a system where “events” are really disguised commands ("SendEmailRequested") has all the operational cost of messaging with none of the decoupling, because the producer is still implicitly orchestrating its consumers.
A good event is immutable (facts do not change; you append a correcting event, you never edit history), self-contained (it carries the data a consumer needs, or a stable reference to fetch it), and named for the business meaning, not the technical mechanism.
|
|
The envelope fields matter as much as the payload. A unique event id is what makes idempotent consumers possible; occurred_at lets consumers reason about ordering; correlation_id is what lets you trace a single business transaction across every service it touches. Skimp on the envelope and you will rebuild it painfully later.
Three shapes: queues, pub/sub, and streams
“Messaging” covers three genuinely different delivery models, and choosing the wrong one causes lasting pain.
| Shape | Each message goes to | Reads are | Canonical tools | Use for |
|---|---|---|---|---|
| Work queue | exactly one of N workers | destructive (consumed) | RabbitMQ, SQS | distributing tasks, load leveling |
| Pub/sub | every subscriber | destructive per-subscriber | RabbitMQ fanout, SNS | notifying many services of an event |
| Event stream | every consumer group, replayable | non-destructive (log + offset) | Kafka, Pulsar | event history, replay, high throughput |
A work queue spreads tasks across a pool: ten workers, each message handled once, by whoever grabs it first. This is the model for “resize this image” or “send this email” — competing consumers sharing load. A pub/sub topic fans each message out to every subscriber, which is the model for true events: one OrderPlaced, delivered to email, inventory, and analytics independently. An event stream is the most powerful and most often misunderstood: it is an append-only, durable log that consumers read by advancing an offset, so the same event can be read by many independent consumer groups, and a brand-new consumer can replay history from the beginning. That replay-ability is what makes streams the substrate for event sourcing and for rebuilding a downstream store from scratch.
The honest version: a small system gluing a few services together is well served by a queue or a managed pub/sub topic, and reaching for Kafka because it is fashionable buys you operational weight you do not need. The Kafka vs Pulsar comparison and the managed-broker route via EventBridge cover that decision in depth.
Delivery semantics and the exactly-once myth
Every messaging system makes one of three delivery promises, and you must design for the one you actually have:
- At-most-once: messages may be lost but never duplicated. Fire-and-forget. Acceptable only for data you can afford to drop (some metrics, some telemetry).
- At-least-once: messages are never lost but may be delivered more than once. This is what almost every production system uses, because losing an order is worse than processing one twice — provided your consumers can tolerate the duplicates.
- Exactly-once: the holy grail, and mostly a marketing word. True end-to-end exactly-once delivery across a network is impossible in the general case; what systems like Kafka offer is exactly-once processing within a bounded scope (Kafka-to-Kafka, via idempotent producers and transactions), not magic that makes your external side effects exactly-once.
The practical consequence: assume at-least-once and make your consumers idempotent. Idempotency means processing the same event twice has the same effect as processing it once, and the standard implementation is to deduplicate on the event’s unique id:
|
|
A subtlety people miss: the dedupe check and the work should be atomic, or you can crash between “mark seen” and “do work” and silently drop an event. For money-touching flows, the durable version records the event id in the same database transaction as the side effect, so either both happen or neither does. The in-memory Redis version above is fine for “don’t send the email twice”; it is not fine for “don’t double-credit the account.”
Ordering, partitioning, and the cost of parallelism
The other guarantee people assume and rarely have is ordering. Most brokers only guarantee order within a single partition or queue, not across the whole topic. The instant you add a second consumer for throughput, two events that were emitted in order can be processed out of order — and MoneyWithdrawn processed before MoneyDeposited is a bug that only shows up under load.
The lever is the partition key. In a streaming system, events with the same key land in the same partition and are therefore processed in order relative to each other, while different keys spread across partitions for parallelism. Key by the entity whose order matters:
|
|
This is the fundamental trade-off of event-driven scale: you get parallelism between keys and ordering within a key, and you cannot have global ordering and high parallelism at the same time. Choosing the partition key is therefore an architectural decision, not a tuning detail — pick the granularity at which order actually matters (per-account, per-order, per-user) and no finer.
The dual-write problem and the outbox
Here is the bug that quietly afflicts most first-generation event-driven systems. A service handles a request by doing two things: writing to its database and publishing an event. These are two separate systems with no shared transaction, so any failure between them leaves you inconsistent.
ORDER SERVICE
┌───────────────────────────────────────────┐
│ 1. INSERT order ──▶ [ Postgres ] ✓ │
│ 2. publish event ──▶ [ Broker ] ✗ crash │
└───────────────────────────────────────────┘
Result: order exists, NO event emitted.
Inventory never reserved. Silent data divergence.
THE OUTBOX FIX (one transaction, two tables):
┌───────────────────────────────────────────┐
│ BEGIN │
│ INSERT order │
│ INSERT into outbox (the event) │
│ COMMIT ── atomic: both or neither ─────────│
└───────────────────────────────────────────┘
│
relay/CDC polls outbox ──▶ broker ──▶ consumers
The transactional outbox solves it: instead of publishing to the broker directly, the service writes the event into an outbox table in the same database transaction as the business data. A separate relay process — polling the table, or tailing the database’s change log via change data capture — reads committed outbox rows and publishes them to the broker. Now the write and the “intent to publish” are atomic, and the relay guarantees the event eventually reaches the broker (at least once). This pattern is the single most important thing separating a toy event-driven system from one that does not lose data, and it is covered end-to-end in the outbox pattern post.
Choreography vs orchestration
When a business process spans services — place order, charge card, reserve stock, ship — there are two ways to coordinate it. In choreography, each service reacts to events and emits its own, with no central brain: the payment service hears OrderPlaced and emits PaymentCaptured, the inventory service hears that and emits InventoryReserved, and so on. In orchestration, a coordinator (often a durable workflow engine) explicitly drives each step and handles failures centrally.
CHOREOGRAPHY (events chain, no coordinator):
OrderPlaced ─▶ PaymentCaptured ─▶ InventoryReserved ─▶ OrderConfirmed
ORCHESTRATION (a saga coordinator calls each step):
┌──────────────┐
│ Orchestrator│──▶ capture payment
│ (saga) │──▶ reserve inventory
│ │──▶ on failure: compensate
└──────────────┘
Choreography is maximally decoupled and wonderful until you need to answer “why did order 123 get stuck,” at which point the lack of a central view becomes painful — the logic is smeared across six services and exists nowhere as a whole. Orchestration reintroduces a coordinator (a single place to see and change the flow) at the cost of some coupling. The failure-handling discipline for either — compensating actions, because you cannot roll back a distributed transaction — is the saga pattern, which the microservices patterns post develops. The pragmatic guidance: choreography for short, stable, two-or-three-step reactions; orchestration once a workflow has real branching, timeouts, and compensation, because debuggability matters more than purity.
Schema evolution: the contract you forgot you signed
The day you publish an event consumed by another team, its shape becomes a public contract — and events live a long time, especially in a replayable stream where a consumer might read an event written two years ago. So you must evolve schemas without breaking old consumers. The rules mirror good API versioning discipline: only make additive changes to an existing event type (add optional fields, never remove or rename or retype), and when you truly need a breaking change, publish a new event version (OrderPlaced.v2) alongside the old one for a deprecation window. A schema registry (Confluent Schema Registry for Avro/Protobuf, or JSON Schema validation in your pipeline) enforces compatibility at publish time so a producer cannot ship an event that breaks the contract. Treat consumers as obligated to ignore fields they do not understand rather than choking on them, and additive evolution can run for years before you ever need a v2.
Error handling that does not lose data
In a synchronous call, a failure returns an error to the caller. In an event-driven flow there is no caller waiting, so you need explicit machinery:
- Retry with backoff for transient failures (a downstream is briefly down). Cap the retries and grow the delay (1s, 2s, 4s) so you do not hammer a struggling service.
- Dead-letter queue (DLQ) for messages that fail repeatedly. After N attempts, move the message to a separate queue for human inspection instead of blocking the pipeline or dropping the data. A DLQ that nobody monitors is just a slower way to lose data, so alert on its depth.
- Poison-message handling: one malformed event must not wedge the whole partition. The combination of bounded retries plus a DLQ is what prevents a single bad message from halting an at-least-once consumer forever.
|
|
The subtle rule embedded there: commit the offset only after successful processing (or after dead-lettering), never on receipt, or a crash mid-processing silently skips the message.
Observability: debugging a flow with no stack trace
The hardest operational reality of event-driven systems is that a single business transaction has no stack trace — it is a chain of asynchronous hops across services, and “why is this order stuck” is genuinely hard to answer without the right instrumentation. Three things make it tractable. Propagate a correlation id through every event in a causally-related flow (set it at the edge, copy it into every downstream event) so you can grep one id and see the whole story. Use distributed tracing so each hop is a span and the flow is a connected trace — the tracing post covers wiring this through a broker. And monitor the broker itself: consumer lag (how far behind real-time each consumer group is) is the single most important health metric in any streaming system — rising lag means a consumer is falling behind and the user-visible effect (late emails, stale inventory) is coming. Correlation ids in events, structured logs keyed by them, and lag dashboards turn an opaque mesh into something you can actually operate.
When event-driven architecture is the wrong choice
The decoupling is not free, and for many systems the cost is not worth it.
| Good fit | Poor fit |
|---|---|
| Many services reacting to the same facts | A single app doing simple CRUD |
| Asynchronous workflows tolerant of eventual consistency | Flows that need an immediate synchronous answer |
| High-throughput pipelines, real-time data | Low volume where a function call is simpler |
| Audit trails / replayable history | A team new to async with no operational appetite |
If the answer to a request must be computed now and returned in the same call, an event is the wrong tool — you would be bolting a synchronous request/response on top of an asynchronous substrate and fighting it. If your system is one service and a database, events add network hops, eventual consistency, and a broker to operate in exchange for decoupling you do not need yet. Eventual consistency in particular is a feature you are imposing on your users — “your order is placed but inventory will reserve in a moment” is fine for an e-commerce checkout and unacceptable for, say, a check that an account has sufficient balance before withdrawal. The CAP trade-offs behind that are worked through in consistency, CAP, and replication. The mature move is to adopt event-driven patterns where the coupling and scale problems are real, and to keep the boring synchronous path everywhere else — often in the same system.
Verdict
Event-driven architecture is a genuine superpower for the problems it fits: decoupling producers from an open-ended set of consumers, absorbing load spikes, building replayable history, and letting teams evolve independently. But every one of those benefits is purchased with distributed-systems complexity that you must design for explicitly, not discover in production. Treat events as immutable past-tense facts and keep them distinct from commands; assume at-least-once delivery and make every consumer idempotent on a unique event id; choose partition keys deliberately because they are the dial between ordering and parallelism; solve the dual-write problem with a transactional outbox before it silently corrupts your data; pick choreography for short reactions and orchestration once a workflow needs real failure handling; evolve schemas additively and version when you cannot; and instrument with correlation ids, tracing, and consumer-lag alerts because there is no stack trace to fall back on. Reach for none of this on a simple CRUD app — the simplest system that meets the requirement wins. But when you have many services that genuinely need to react to the same facts at scale, event-driven architecture, built with these disciplines, is how loosely coupled systems stay both loose and correct.
Comments