Engineering Notes
Home/Distributed Systems/Message Brokers/Event-Driven Architecture
Distributed Systems · Message Brokers

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.

🕸️ Distributed Systems● Advanced⏱ 8 min read▶ 3 interactive🛠️ 3 exercises🎤 5 interview Q&As

🎯 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
Part 1Events as the Integration Contract

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

text
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-drivenevent-driven
couplingcaller knows callees, their APIs and availabilitypublisher knows only the event schema
adding a consumerchange the callersubscribe; no change to the publisher
failure of a downstreamcaller fails or must handle itevent waits in the broker; consumer catches up
latency to the end usersum of the chainpublisher returns immediately
consistencycan be immediateeventual
understanding the flowread the callertrace events across services (harder)

2Three Kinds of Events

KindCarriesExampleTrade-off
Event notification"something happened" + IDsOrderPlaced { orderId }tiny, but consumers call back for details (coupling + load)
Event-carried state transferthe data consumers needOrderPlaced { 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 truthItemAddedToCart { 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 — not CreateOrder (that's a command, 05-queues_and_topics/ §2) and not OrderTableRowUpdated (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 emits PaymentSucceeded → inventory emits StockReserved → 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)

  1. Commands disguised as events (SendEmailRequested published to "whoever") — hidden coupling.
  2. Leaking internal schemas (orders_table_updated) — every DB refactor breaks consumers.
  3. Notification events that trigger N callbacks to the publisher — a self-inflicted DDoS on every event.
  4. Breaking schema changes without versioning.
  5. Publishing outside the DB transaction — lost events (use the outbox, Part 2).

📋 Quick Reference (Part 1)

text
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 compensation
Part 2Outbox, Event Sourcing and CQRS

Part 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:

ts
await db.orders.insert(order);                 // ✅ committed
await broker.publish("OrderPlaced", order);    // 💥 crash here → event lost forever

Outbox 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.

sql
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.

text
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:

text
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:

sql
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 retry

The 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).

A command updates the write model and inserts an outbox row in one transaction; a relay publishes the event to the broker, and idempotent projectors update several read models used by queries
A command updates the write model and inserts an outbox row in one transaction; a relay publishes the event to the broker, and idempotent projectors update several read models used by queries

⚠️ Common Pitfalls (Part 2)

  1. Dual writes instead of an outbox — lost or phantom events.
  2. Event sourcing everything — CRUD screens with no history needs don't benefit.
  3. Mutating stored events — breaks replay; version and upcast instead.
  4. Non-idempotent projections — rebuilds and redeliveries corrupt read models.
  5. 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)

PatternSolvesKey mechanism
outboxDB write + publish atomicallysame-transaction insert + relay (poll/CDC)
inboxconsumer dedupprocessed event ids in the same transaction
event sourcinghistory as source of truthappend-only streams, expected-version append, snapshots
CQRSdifferent shapes for reads and writesprojections 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

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

1. A service commits an order then crashes before publishing. The fix?
The event becomes part of the local transaction.
2. Which event type lets consumers work even when the publisher is down?
Consumers keep the data they need.
3. In CQRS, read models are…
Projections feed them.
4. Why append with an expected version in an event store?
A concurrent write fails and is retried.