Event-Driven Architecture
Events become the integration contract: services announce facts and others react. Design good events, choose choreography or orchestration, then go deeper with the transactional outbox, event sourcing and CQRS.
🎯 What you'll learn
- Request-driven vs event-driven integration
- Event notifications, event-carried state transfer and domain events
- Designing events: naming, envelopes, keys, versioning
- Outbox, event sourcing, CQRS and idempotent projections
📚 Before you start
The previous topics covered the plumbing — brokers, queues, topics, delivery guarantees. Event-driven architecture (EDA) is the design style built on top: services communicate primarily by publishing facts about what happened, and other services react to those facts, instead of calling each other directly.
1Request-Driven vs Event-Driven
request-driven (orchestrated by the caller):
orders ──HTTP──► payments
──HTTP──► inventory
──HTTP──► email
──HTTP──► loyalty orders must know (and wait for) every downstream service
event-driven (choreographed by reactions):
orders ──publish OrderPlaced──► broker ──► payments (charge)
──► inventory (reserve)
──► email (confirm)
──► loyalty (add points)
orders knows nothing about who listens| request-driven | event-driven | |
|---|---|---|
| coupling | caller knows callees, their APIs and availability | publisher knows only the event schema |
| adding a consumer | change the caller | subscribe; no change to the publisher |
| failure of a downstream | caller fails or must handle it | event waits in the broker; consumer catches up |
| latency to the end user | sum of the chain | publisher returns immediately |
| consistency | can be immediate | eventual |
| understanding the flow | read the caller | trace events across services (harder) |
2Three Kinds of Events
| Kind | Carries | Example | Trade-off |
|---|---|---|---|
| Event notification | "something happened" + IDs | OrderPlaced { orderId } | tiny, but consumers call back for details (coupling + load) |
| Event-carried state transfer | the data consumers need | OrderPlaced { orderId, customerId, items, total } | consumers keep a local copy, no callbacks; larger events, data duplication |
| Domain event (for event sourcing) | the state change itself, as the source of truth | ItemAddedToCart { cartId, sku, qty } | full history and replay; see Part 2 |
Most systems use event-carried state transfer for integration events: consumers stay autonomous even when the publisher is down.
3Designing Good Events
- Past tense, business language:
OrderPlaced,PaymentFailed— notCreateOrder(that's a command,05-queues_and_topics/§2) and notOrderTableRowUpdated(that leaks the schema). - Envelope + payload:json
{ "eventId": "01HZX6K8…", // unique, for dedup (09-delivery_guarantees/) "eventType": "OrderPlaced", "schemaVersion": 2, "occurredAt": "2025-06-12T10:15:03Z", "source": "orders-service", "correlationId": "req-81f…", // ties to the original request / trace "data": { "orderId": "o-17", "customerId": "c-4", "total": 4599, "currency": "EUR" } }(CloudEvents is a standard for this envelope.)
- Key by the aggregate id (
orderId) so all events of one order stay ordered (08-…/). - Schema evolution: only make backward-compatible changes (add optional fields; never rename/remove/change types) within a version; use a schema registry (Avro/Protobuf/JSON Schema) to enforce it; publish a new event type or version for breaking changes.
4Choreography vs Orchestration
Multi-step business processes (place order → pay → reserve → ship) can be coordinated two ways:
- Choreography: each service reacts to events and emits its own (
OrderPlaced→ payments emitsPaymentSucceeded→ inventory emitsStockReserved→ shipping…). No central brain. Great for simple flows; for long flows the process is implicit and hard to see or change. - Orchestration: a process manager / saga orchestrator sends commands and listens for replies (
distributed-systems/fundamentals/08-distributed_transactions). The flow is explicit in one place; that service is a coupling point.
Rule of thumb: choreograph notifications and loosely related reactions; orchestrate a business transaction with compensations.
5Eventual Consistency, Made Visible
After OrderPlaced, the loyalty service's points balance is updated "soon" — not in the same transaction. Design for it:
- UIs show pending states ("Points will appear shortly").
- Read-your-writes where it matters: the service that accepted the write serves the immediate follow-up read.
- Consumers must be idempotent and tolerant of reordering (
08-…/,09-…/).
⚠️ Common Pitfalls (Part 1)
- Commands disguised as events (
SendEmailRequestedpublished to "whoever") — hidden coupling. - Leaking internal schemas (
orders_table_updated) — every DB refactor breaks consumers. - Notification events that trigger N callbacks to the publisher — a self-inflicted DDoS on every event.
- Breaking schema changes without versioning.
- Publishing outside the DB transaction — lost events (use the outbox, Part 2).
📋 Quick Reference (Part 1)
event = past-tense fact, envelope {eventId, type, version, occurredAt, correlationId} + data
key by aggregate id • event-carried state transfer for integration • backward-compatible evolution
choreography for reactions • orchestration for multi-step transactions with compensationPart 1 defined events as the integration contract. Part 2 covers the three patterns that make event-driven systems reliable and that use events as more than notifications.
1The Transactional Outbox
A service must update its database and publish an event. Doing both naively has the dual-write problem:
await db.orders.insert(order); // ✅ committed
await broker.publish("OrderPlaced", order); // 💥 crash here → event lost foreverOutbox pattern: write the event to an outbox table in the same transaction as the business change; a separate relay publishes outbox rows to the broker and marks them sent.
BEGIN;
INSERT INTO orders (id, customer_id, total) VALUES ('o-17', 'c-4', 4599);
INSERT INTO outbox (event_id, aggregate_id, event_type, payload)
VALUES ('01HZX…', 'o-17', 'OrderPlaced', '{"orderId":"o-17", ...}');
COMMIT;Relay options: polling (SELECT … FROM outbox WHERE sent_at IS NULL ORDER BY id LIMIT 100 FOR UPDATE SKIP LOCKED) or CDC (Debezium tails the WAL and publishes outbox inserts). Either way the guarantee is at-least-once publishing — consumers dedup by eventId.
The mirror image on the consumer side is the inbox: record processed eventIds in the consumer's DB in the same transaction as the side effect.
2Event Sourcing
Instead of storing current state and also emitting events, store only the events; current state is derived by replaying them.
stream "account-42":
1 AccountOpened { owner: "ana" }
2 MoneyDeposited { amount: 100 }
3 MoneyWithdrawn { amount: 30 }
4 MoneyDeposited { amount: 50 }
──────────────────────────────
fold(events) → balance = 120- Commands are validated against the current state (rebuilt from the stream), then produce new events appended to the stream.
- Optimistic concurrency: append with "expected version = 4"; if someone else appended first, the append fails and the command retries.
- Snapshots every N events avoid replaying long streams.
Gains: complete audit history; temporal queries ("balance on March 1st"); rebuild or add new read models by replaying; debugging by replay. Costs: events are forever — schema evolution needs upcasters; GDPR erasure needs crypto-shredding or careful design; querying current state needs projections (CQRS); a steeper learning curve.
Use it where history is the domain (ledgers, carts, workflows, compliance), not everywhere.
3CQRS — Command Query Responsibility Segregation
Separate the write model (validates commands, enforces invariants) from one or more read models (shaped for specific queries), kept in sync by events:
command ──► write model ──events──► projector ──► read model A (order list per customer, PostgreSQL)
projector ──► read model B (search, Elasticsearch)
projector ──► read model C (dashboard counters, Redis)
query ◄───────────────────────────────────────── read models- Read models are eventually consistent with the write side (lag = projector delay).
- A read model can be dropped and rebuilt by replaying events from the beginning (Kafka retention/compaction or the event store).
- CQRS doesn't require event sourcing (you can project from outbox events), and event sourcing almost always needs CQRS.
4Projections Must Be Idempotent and Ordered per Aggregate
A projector processes events at-least-once, so: track the last applied position/version per aggregate, skip anything older or equal, and make each update idempotent. That's what makes replay and redelivery safe.
Under the HoodAppending to an Event Store Safely▾
An event store needs exactly two guarantees: append-only streams and atomic conditional append. In PostgreSQL:
CREATE TABLE events (
stream_id TEXT NOT NULL,
version INTEGER NOT NULL, -- 1, 2, 3 … per stream
event_type TEXT NOT NULL,
data JSONB NOT NULL,
global_pos BIGSERIAL, -- total order for projectors
PRIMARY KEY (stream_id, version) -- ← the optimistic concurrency check
);
-- command handler loaded the stream at version 4, appends version 5:
INSERT INTO events (stream_id, version, event_type, data) VALUES ('account-42', 5, 'MoneyWithdrawn', '{"amount":80}');
-- a concurrent writer that also read version 4 tries version 5 → unique violation → reload and retryThe primary key turns "expected version" into a database-enforced check, so two concurrent commands can never both act on the same stale state (e.g. both withdraw the last €80). Projectors read by global_pos; note that with concurrent transactions a BIGSERIAL can commit out of order, so robust projectors either read only up to a safe position (no in-flight lower positions) or rely on a store that assigns positions at commit (EventStoreDB, Kafka offsets).
⚠️ Common Pitfalls (Part 2)
- Dual writes instead of an outbox — lost or phantom events.
- Event sourcing everything — CRUD screens with no history needs don't benefit.
- Mutating stored events — breaks replay; version and upcast instead.
- Non-idempotent projections — rebuilds and redeliveries corrupt read models.
- Reading your own write from a lagging read model — route that read to the write side or wait for the projection position.
📋 Quick Reference (Part 2)
| Pattern | Solves | Key mechanism |
|---|---|---|
| outbox | DB write + publish atomically | same-transaction insert + relay (poll/CDC) |
| inbox | consumer dedup | processed event ids in the same transaction |
| event sourcing | history as source of truth | append-only streams, expected-version append, snapshots |
| CQRS | different shapes for reads and writes | projections from events, eventually consistent |
🧠 Key takeaways
- Event-driven integration: publishers know only the event schema; adding consumers needs no publisher change; consistency is eventual.
- Prefer event-carried state transfer for integration; name events in the past tense; use an envelope with an ID, type, version and correlation ID.
- Never dual-write: use a transactional outbox (+ relay/CDC) and idempotent consumers.
- Event sourcing stores events as the truth; CQRS splits writes from query-optimized read models fed by projections.
🎤 Interview Q&A
Q1What are the benefits of event-driven architecture?core▾
Loose coupling (producers don't know consumers), independent scaling and deployment, resilience (the broker buffers during consumer outages), easy extension (new consumers subscribe without changing producers), and a natural audit trail of what happened.
- And the main challenge? Understanding and debugging flows that span many asynchronous steps.
Q2What makes a good event?core▾
A past-tense fact named in domain language (PaymentCaptured), immutable, with an ID, timestamp, version and the data consumers need (or a reference), owned by one producer, and backward-compatible over time.
- Should events contain the full entity? Enough for consumers to act without calling back; avoid leaking internal fields.
Q3What is event sourcing?senior▾
Storing state as an append-only sequence of events instead of current values; current state is rebuilt by replaying events (with snapshots for speed). It gives a full history and enables new projections, at the cost of complexity, schema evolution of old events, and eventual consistency for read models.
- Is EDA the same as event sourcing? No; you can be event-driven without storing state as events.
Q4How do you debug an event-driven system?core▾
Propagate correlation/trace IDs in event headers, use distributed tracing across producers and consumers, log event IDs, keep an event catalog documenting producers/consumers and schemas, and monitor lag and DLQs.
- What's an event catalog? Documentation (e.g. AsyncAPI) listing events, schemas, owners and subscribers.
Q5How do you ensure consistency across services in EDA?core▾
Accept eventual consistency, use the outbox pattern for reliable publishing, idempotent consumers, sagas with compensation for multi-step workflows, and versioned events to handle ordering.
- How do you show users pending state? Explicit states like 'payment processing' updated when the event arrives.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.