Architecture · Event-Driven · Field Guide

Event-Driven Architecture Pro Playbook

The messaging half of distributed systems — queues, streams, Kafka, delivery semantics, event sourcing, CQRS, sagas, and the outbox. Explained for students and pros.

Companion to the Distributed Systems playbook — that half is the machinery; this half is how services talk
What it is Why it matters How it works In plain words ASCII diagram
Part I · Messaging Foundations
01Synchronous vs asynchronous communication★ start here

Scenario: checkout calls payment, which calls fraud, which calls email — all synchronously. One slow service and the whole chain hangs; one dead service and checkout fails entirely. Going async breaks that fragile chain.

What

Sync: caller waits for the callee's reply (request/response, RPC). Async: caller emits a message/event and moves on; work happens later, decoupled.

Why

Sync couples availability and latency across services (one slow link stalls all). Async decouples them — services fail and recover independently.

How

Replace a blocking call with an event on a broker (queue/stream). Consumers process at their own pace; the producer doesn't wait.

Blocking chain vs decoupled events
  SYNC (coupled):  checkout→payment→fraud→email
     any hop slow/down → checkout blocks or fails ❌

  ASYNC (decoupled):
     checkout ──event: OrderPlaced──► [ broker ]
                                        │  ├─► payment (own pace)
                                        │  ├─► fraud
                                        │  └─► email
     checkout returns instantly; consumers catch up if they lag/restart
🧠
Analogy 1 — phone call vs voicemail: sync is a phone call — both must be free at once, and if they don't pick up, you're stuck. Async is voicemail — you leave the message and hang up; they listen when ready. The post office (broker) holds it meanwhile.
🍽️
Analogy 2 — a restaurant order ticket: the waiter doesn't stand at the pass waiting for each dish (sync); they clip the ticket to the rail (async event) and serve other tables. The kitchen works the tickets at its own pace. The rail decouples front-of-house from the kitchen.
From blocking calls to an event
# SYNC: checkout is only as available as its slowest dependency
def checkout(order):
    payment.charge(order)      # blocks
    fraud.check(order)         # blocks
    email.send(order)          # blocks → 3 ways to fail the sale

# ASYNC: emit once, return; consumers react independently
def checkout(order):
    db.save(order, status="placed")
    broker.publish("OrderPlaced", order)   # fire and continue
    return "ok"                # payment/fraud/email run downstream
🧒
In plain wordsSynchronous is like a phone call — you both have to be available right now, and if they don't answer, you're stuck waiting. Asynchronous is like texting or leaving a voicemail — you send it and get on with your day; they handle it when they can. For services that don't need an instant answer, texting (events) is far sturdier than everyone phoning everyone.

✅ Do

  • Use async events for work that can happen slightly later (email, analytics, fulfillment)
  • Keep sync calls for reads you need right now to answer the user
  • Return fast to the user; let downstream consumers do the heavy work

❌ Don't

  • Chain many synchronous calls — you multiply latency and failure (see the Distributed Systems playbook)
  • Make everything async — debugging and user-facing reads get harder
Gotcha: async doesn't remove complexity, it relocates it — you trade "the call might fail" for "the event might arrive twice, out of order, or much later." You now need idempotent consumers (topic 6), ordering awareness (topic 7), and a way to trace a request across hops (see the Distributed Systems playbook). Teams that adopt events for decoupling but skip those inherit a new class of heisenbugs.
02Message queues vs event streams★ core idea

Scenario: you reach for "a queue" but the real choice is two different tools. A task queue (RabbitMQ/SQS) hands each job to one worker and deletes it. An event stream (Kafka) keeps an append-only log many consumers replay. Picking wrong causes pain.

What

Queue: messages are consumed and removed; each goes to one worker (work distribution). Stream: an ordered, retained log many independent consumers read at their own offset.

Why

Queues excel at "do this job once." Streams excel at "many services react to the same facts, and can replay history." Different jobs.

How

Queue: push task → worker acks → gone. Stream: append event → each consumer group tracks its own position → data stays for a retention window.

Consume-and-delete vs append-and-replay
  QUEUE (RabbitMQ/SQS): one job → one worker, then gone
     [j1 j2 j3] ──► worker A takes j1 (removed)
                 ──► worker B takes j2 (removed)   work is SPLIT

  STREAM (Kafka): append-only log, each consumer has its own offset
     log: e1 e2 e3 e4 e5 ...
          ▲billing@3   ▲search@5   ▲audit@1   (all read the SAME events,
     replayable: reset audit to 0 to reprocess history)
🧠
Analogy 1 — a to-do inbox vs a newspaper archive: a queue is a shared to-do inbox — you grab a task, do it, it's gone, and no one else does it. A stream is a newspaper archive — every subscriber reads the same editions at their own pace, and you can always re-read last month.
🎟️
Analogy 2 — deli ticket line vs a group chat history: the queue is the deli — take a number, get served, ticket's used up. The stream is a group chat — everyone sees every message, new members can scroll up, and the same message informs many people.
Two tools, two shapes
# QUEUE — distribute work, each task done once
queue.send("resize-image", {"id": 42})     # exactly one worker resizes it
job = queue.receive(); process(job); job.ack()   # ack → removed

# STREAM — publish a fact many services consume & can replay
stream.append("orders", {"type":"OrderPlaced","id":42})
# billing, search, analytics each read from their OWN offset:
for event in stream.consume("orders", group="billing", from_offset="last"):
    handle(event)         # audit group can start from 0 to rebuild state
🧒
In plain wordsA queue is a stack of chores: a worker grabs one, does it, and it's gone — great for "this job must happen once." A stream is more like a diary that keeps every entry: many readers can each read the whole thing at their own speed, and you can re-read old entries. If several parts of your system need to react to the same news (and maybe re-read it later), you want a stream.

✅ Do

  • Use a queue for one-time task distribution (resize this, send that email)
  • Use a stream when many consumers need the same events, or you need replay (topic 15)
  • Match retention to your replay needs — streams keep data, queues don't

❌ Don't

  • Use a task queue when you'll later want to add a new consumer of past events
  • Treat Kafka like a work queue without understanding partitions (topics 4, 7)
Gotcha: the classic mistake is choosing a delete-on-consume queue, then months later needing a new service to process events that already happened — but they're gone forever. Streams keep history so you can add consumers and replay (topic 15); queues don't. Choosing "queue" for a domain of durable business facts is a decision you'll regret when requirements grow (topics 4, 9).
03Pub/sub & fan-out

Scenario: when an order is placed, five teams want to know — billing, inventory, search, analytics, notifications. You don't want checkout to call all five. Instead it publishes one event and each team subscribes. That's pub/sub.

What

Publish/subscribe: producers emit events to a topic without knowing who listens; any number of consumers subscribe. Fan-out = one event delivered to many.

Why

It decouples producers from consumers — you can add a new listener without touching the producer, the ultimate open/closed for architecture.

How

Publish to a topic/exchange; the broker copies the event to each subscriber (or each consumer group in a stream). New subscribers just join.

One publish, many independent reactions
                      ┌─► billing      (charge)
   checkout           ├─► inventory    (reserve)
   publish ──OrderPlaced──► [ topic ] ──┼─► search       (index)
   (knows nobody)     ├─► analytics    (count)
                      └─► notifications(email)
   add a 6th consumer tomorrow → checkout code UNCHANGED
🧠
Analogy 1 — a radio broadcast: the station transmits once; anyone with a receiver tunes in. It doesn't know or care how many are listening, and a new listener needs no permission. Publishing an event is broadcasting; subscribing is tuning in.
📰
Analogy 2 — a newsletter: the author writes one issue; every subscriber gets a copy. Adding a subscriber doesn't change how the author writes. Cancel and you stop receiving — the author never knows. Producers and consumers stay blissfully independent.
Publisher knows nothing about subscribers
# producer — fire one event to a topic
broker.publish("OrderPlaced", {"order_id": 42, "total": 90})

# each consumer subscribes independently (own group = own copy)
broker.subscribe("OrderPlaced", group="billing",   handler=charge)
broker.subscribe("OrderPlaced", group="inventory", handler=reserve)
broker.subscribe("OrderPlaced", group="search",    handler=index)
# tomorrow: add group="loyalty" → zero changes to checkout
🧒
In plain wordsInstead of the checkout calling every team one by one, it just announces "an order was placed!" to a bulletin board. Every team that cares is watching that board and reacts on its own. Best part: a new team can start watching tomorrow, and checkout never has to know or change. Announce once, many listen.

✅ Do

  • Publish business facts ("OrderPlaced"), not commands ("ChargeCard")
  • Give each consumer its own group so all get their own copy (fan-out)
  • Version your event schema so new subscribers and old ones coexist (topic 14)

❌ Don't

  • Bake consumer knowledge into the producer — that re-couples them
  • Assume delivery is instant or once — plan for delay and duplicates (topic 5)
Gotcha: fan-out makes it dangerously easy for hidden dependencies to accumulate — six services now silently rely on your event's shape, and a "small" field rename breaks consumers you didn't know existed. Because the producer is decoupled, nothing warns you. Treat published events as a public API with versioning and a schema registry (topic 14), not an internal struct you can refactor freely.
04Kafka fundamentals — topics, partitions, offsets★ core idea

Scenario: you put all order events on one Kafka topic and consumers can't keep up — or events for the same customer get processed out of order. Understanding partitions and keys is what makes Kafka scale and stay ordered where it matters (topic 7).

What

A topic is a named log, split into partitions (the unit of parallelism & ordering). Each message has an offset; a key decides its partition. Consumer groups share partitions.

Why

Partitions give you throughput (parallel consumers) and per-key ordering (same key → same partition → in order). It's the core scaling knob.

How

Producers hash the key to a partition. Each partition is consumed by exactly one member of a group. Order is guaranteed within a partition, not across.

Topic → partitions → offsets, keyed for order
  topic "orders"  (key = customer_id → same customer, same partition)
   P0: [o0 o1 o2 o3]      ← consumer A (group G)
   P1: [o0 o1 o2]         ← consumer B (group G)
   P2: [o0 o1 o2 o3 o4]   ← consumer C (group G)
        ▲offsets
  ordering: guaranteed WITHIN a partition, NOT across partitions
  parallelism: max consumers in a group = number of partitions
🧠
Analogy 1 — checkout lanes at a store: a topic is the store; partitions are the lanes. More lanes = more shoppers served at once (throughput). If you always send the same family to the same lane (key), their items stay in order. Across different lanes, order is anyone's guess.
📚
Analogy 2 — a series split across volumes: a topic is the whole series; each partition is a numbered volume you read page by page (offsets). One reader per volume in a book club (consumer group). Pages within a volume are ordered; page 5 of volume 2 vs volume 1 has no defined order.
Key for ordering, scale with partitions
# producer: key by customer so their events stay ordered
producer.send("orders", key=str(customer_id), value=event)
#   same customer_id → same partition → strict order for that customer

# consumer group: N consumers share N+ partitions
consumer.subscribe(["orders"], group_id="billing")
for msg in consumer:
    handle(msg.value)
    consumer.commit()        # advance offset ONLY after success (topic 5)
# add partitions to scale; add consumers up to #partitions
🧒
In plain wordsKafka is a giant notebook (topic) split into several columns (partitions) so many people can write and read at once. Each line has a line-number (offset). If you always put the same customer's events in the same column, they stay in order for that customer. Want to go faster? Add more columns. But things in different columns aren't guaranteed to be in order — so choose your column-key wisely.

✅ Do

  • Pick a partition key that groups things needing order (e.g. per user/account) — topic 7
  • Provision enough partitions up front — they set your max parallelism
  • Commit offsets only after successfully processing (at-least-once, topic 5)

❌ Don't

  • Expect global ordering across partitions — it doesn't exist
  • Use one partition for everything — you've capped throughput at one consumer
Gotcha: partition count is easy to increase and painful to reason about after — adding partitions changes the key→partition mapping, so a key that used to land on P2 may now land on P5, scattering a key's history across partitions and breaking per-key ordering for in-flight data. Plan partitions generously at design time rather than reshuffling a live topic (topics 2, 7).
05Delivery semantics — at-least / at-most / exactly-once★ core idea

Scenario: your consumer processes an event, then crashes before committing its offset. On restart it sees the event again and processes it twice. Whether that's a disaster or a non-event depends entirely on your delivery semantics — and your idempotency (topic 6).

What

Three guarantees: at-most-once (may lose, never duplicate), at-least-once (never lose, may duplicate), exactly-once (neither — hard and narrow).

Why

Most real systems are at-least-once, so duplicates will happen. Whether that's safe depends on whether your consumer is idempotent (topic 6).

How

At-least-once = ack after processing. At-most-once = ack before. "Exactly-once" = at-least-once delivery + idempotent processing (effectively-once).

Where you ack decides the guarantee
  AT-MOST-ONCE:  receive → ACK → process
     crash after ack, before process → event LOST (never duplicated)

  AT-LEAST-ONCE: receive → process → ACK
     crash after process, before ack → reprocessed → DUPLICATE

  EXACTLY-ONCE (effective): at-least-once + idempotent consumer (topic 6)
     duplicate arrives → dedupe by key → no visible double effect ✓
🧠
Analogy 1 — signing for a package: sign before opening (at-most-once) and if it's empty you've lost it with no recourse. Sign after confirming contents (at-least-once) and a re-delivery might get signed twice. "Exactly-once" is signing after and a ledger that refuses a second signature for the same tracking number.
💊
Analogy 2 — a medication checklist: cross off the dose before giving it (at-most-once) and a crash means a missed dose. Cross off after (at-least-once) and a mix-up risks a double dose — unless the chart says "already given at 2pm" (idempotency) so the repeat is safely ignored.
At-least-once done right = ack after + idempotent
for event in consumer:
    if seen.contains(event.id):        # idempotent guard (topic 6)
        consumer.commit(); continue    # duplicate → skip, then ack
    process(event)                     # do the work
    seen.add(event.id)
    consumer.commit()                  # ACK only AFTER success
# crash before commit → event redelivered → guard makes it a no-op
# this is "effectively-once" — the practical form of exactly-once
🧒
In plain wordsWhen a computer passes a message to another, three things can happen: it might get lost, it might arrive twice, or (ideally) exactly once. In real life you usually pick "never lose it, but it might arrive twice," and then you make your code shrug at repeats (remember what you've already done). True "exactly once" is very hard, so smart teams fake it: allow duplicates, but ignore them.

✅ Do

  • Assume at-least-once and make consumers idempotent (topic 6)
  • Commit/ack the offset only after the work is safely done
  • Treat "exactly-once" as "at-least-once + dedupe," not magic

❌ Don't

  • Ack before processing unless losing messages is genuinely acceptable
  • Trust vendor "exactly-once" to cover side effects outside the broker (emails, charges)
Gotcha: "exactly-once" is a marketing-friendly phrase that's only truly achievable within a closed system (broker→broker with transactions). The moment your consumer touches the outside world — sends an email, charges a card, calls an API — you're back to at-least-once for that side effect, and only idempotency saves you (topic 6). Never let "the broker guarantees exactly-once" lull you into non-idempotent side effects.
06Idempotent consumers — surviving duplicates★ core idea

Scenario: your system is at-least-once (topic 5), so the same "OrderPaid" event will arrive twice someday — a redelivery, a rebalance, a replay. If your consumer isn't idempotent, that means a double shipment or a doubled loyalty-points grant. Idempotent consumers are how you survive the duplicates you can't avoid.

What

A consumer designed so that processing the same event twice has the same effect as once — via a dedupe store (record processed event ids) or naturally-idempotent operations.

Why

At-least-once delivery guarantees duplicates. Idempotency is the only thing that turns "processed twice" from a data-corruption bug into a harmless no-op.

How

Give each event a stable id; before applying, check-and-record it atomically (a unique constraint or transactional dedupe table). Prefer "set to X" over "add X".

Dedupe by event id, atomically
  event(id=E1) ─►┌ consumer ┐
                 │ INSERT id=E1 into processed (unique) ── ok → apply ✓
                 └───────────┘   store id + result in ONE transaction
  duplicate event(id=E1) ─►┌ consumer ┐
                 │ INSERT id=E1 → UNIQUE VIOLATION → skip (no-op) ✓
                 └───────────┘   same effect as processing once
  ⚠ check-then-write in TWO steps → two duplicates race through → use a
    unique constraint or an atomic upsert to close the gap
🧠
Analogy 1 — a bouncer's guest list with a highlighter: each guest's name is crossed off as they enter. If the same name shows up again, the bouncer sees it's already crossed off and doesn't admit a second copy. The list + highlighter is your dedupe store; the crossing-off must be one atomic motion, or two identical guests slip in at once.
🧾
Analogy 2 — stamping "PAID" on an invoice: once an invoice is stamped PAID, a second payment attempt sees the stamp and stops. The stamp is the record that this exact thing already happened. Without it, an accountant processing the same invoice twice pays the vendor twice.
Atomic dedupe on the event id
def handle(event):
    with db.transaction():                       # ONE atomic unit
        try:
            db.insert("processed_events", id=event.id)   # UNIQUE column
        except UniqueViolation:
            return                               # duplicate → no-op ✓
        apply_effect(event)                      # do the work in the SAME tx
# the unique constraint + single transaction close the race two
# concurrent duplicates would otherwise slip through (topic 5)
# even better: naturally idempotent ops — "set status=shipped" not "ship again"
🧒
In plain wordsBecause messages sometimes arrive twice, your code has to be okay with seeing the same event again. The trick: keep a list of events you've already handled (by their unique id). Before doing anything, check the list — if it's already there, just skip it. The important detail: checking and recording must happen together in one step, or two copies arriving at the same instant could both sneak past. Do this and duplicates become totally harmless.

✅ Do

  • Give every event a stable id and dedupe on it in a single atomic step
  • Store the dedupe record and the effect in the same transaction
  • Prefer naturally-idempotent effects ("set balance", "mark shipped")

❌ Don't

  • Check "have I seen this?" and write in two separate steps — duplicates race through
  • Assume duplicates are rare enough to ignore — at-least-once guarantees them
Gotcha: the subtle failure is a non-atomic dedupe — "check if seen, then process, then mark seen" as three separate steps. Two copies of the same event arriving concurrently both pass the check before either marks it, and both process. The fix is a database unique constraint (or an atomic upsert) so the second insert fails hard, done in the same transaction as the effect. Idempotency you didn't make atomic is idempotency you don't actually have (topics 5, 12).
07Ordering & partitioning — when order matters

Scenario: "AccountOpened" and "AccountClosed" for the same account get processed out of order, and now you're trying to close an account that doesn't exist yet. Ordering in event systems is only guaranteed within a partition — so you must partition by the thing that needs order.

What

Event streams guarantee order within a partition, not across the whole topic. The partition key decides which events are ordered together.

Why

Many domains need per-entity order (a bank account, a user's actions) but not global order. Partitioning by that entity gives you the order you need and parallelism.

How

Key by the entity whose events must stay ordered (account_id). All its events land on one partition, processed in order; different entities parallelize across partitions.

Order per key, parallel across keys
  key = account_id  → same account, same partition, IN ORDER
    P0: [acct-7: Opened, Deposit, Closed]   ✓ correct order for acct-7
    P1: [acct-9: Opened, Withdraw]          ✓ parallel, independent

  ✗ wrong key (e.g. random): acct-7 events scatter across partitions →
     Closed may be processed before Opened → corruption
  global ordering across ALL partitions does NOT exist (topic 4)
🧠
Analogy 1 — sorting mail by household: a letter carrier keeps each household's mail in delivery order, but doesn't care that house #7's mail is ordered relative to house #9's. Partitioning by household (key) gives each home correctly-ordered mail while many carriers work different streets in parallel.
🎳
Analogy 2 — bowling lanes: pins in one lane fall in a sequence that makes sense for that game; comparing the timing to the next lane is meaningless. Each account is a lane — its events are ordered; across lanes, order isn't defined and doesn't need to be.
Key by what must stay ordered
# ✓ per-account ordering: key by account_id
producer.send("account-events", key=str(account_id), value=event)
#   Opened → Deposit → Closed for acct-7 ALL land on one partition, in order

# ✗ keying by something else (or none) scatters an account's events →
#   Closed can be handled before Opened → invalid state
# rule: partition key = the entity whose event order you must preserve
# need strict GLOBAL order? one partition — but that caps throughput (topic 4)
🧒
In plain wordsEvents for the same thing sometimes need to happen in the right order — you can't "close" an account before it's "opened." But event systems only keep order within one lane (partition), not across all lanes. So the trick is: put everything for the same account into the same lane by using the account's id as the sorting key. Then that account's events stay in order, while different accounts run in parallel lanes for speed.

✅ Do

  • Key by the entity whose events must stay ordered (account, user, order)
  • Accept that only per-key order is guaranteed — design around it
  • Handle out-of-order gracefully where you can (e.g. tolerate late events)

❌ Don't

  • Assume global ordering across a topic — it doesn't exist (topic 4)
  • Force strict global order with a single partition unless throughput allows it
Gotcha: the trap is needing order but keying wrong (or not at all), which scatters an entity's events across partitions where they're processed concurrently and out of sequence — producing impossible states like a "Closed" before its "Opened." And strict global ordering has a hidden cost: it requires a single partition, which caps your throughput at one consumer. Most systems need per-entity order, not global — choose the partition key to match exactly what must stay in sequence (topics 4, 6).
08Choreography vs orchestration

Scenario: an order needs payment, inventory, shipping, and email — in a sensible order, with rollback on failure. Do the services react to each other's events (choreography) or does one coordinator drive them (orchestration)? Both are valid; the choice shapes everything.

What

Choreography: services react to events with no central brain — emergent flow. Orchestration: a coordinator explicitly calls each step and tracks the workflow.

Why

Choreography = loose coupling, easy to extend, but flow is implicit and hard to see. Orchestration = clear, debuggable flow, but the orchestrator is a coupling point.

How

Choreography: each service subscribes to events and emits new ones. Orchestration: a workflow engine (or saga orchestrator, topic 11) issues commands step by step.

Emergent flow vs a conductor
  CHOREOGRAPHY (no brain, services react):
   OrderPlaced→[payment]→Paid→[inventory]→Reserved→[shipping]→Shipped
   flow lives in the wiring; no one place shows the whole story

  ORCHESTRATION (a conductor):
        ┌────── ORCHESTRATOR ──────┐
        ▼         ▼        ▼        ▼
     payment  inventory shipping  email
   the orchestrator KNOWS the whole flow; easy to see & change order
🧠
Analogy 1 — a dance troupe vs an orchestra: choreography is dancers who each know their cue and respond to the dancer before them — beautiful and flexible, but no one "runs" it. Orchestration is an orchestra with a conductor who cues every section — the flow is explicit and easy to redirect, but everything depends on the conductor.
🍽️
Analogy 2 — a buffet vs a set menu with a maître d': choreography is a buffet — each station just does its thing as guests flow past. Orchestration is a maître d' walking you course by course. The buffet scales and adds stations easily; the maître d' gives you a clear, controllable experience.
Two styles, same order flow
# CHOREOGRAPHY — each service reacts & emits the next event
on("OrderPlaced",  lambda e: (charge(e), emit("Paid", e)))
on("Paid",         lambda e: (reserve(e), emit("Reserved", e)))
on("Reserved",     lambda e: (ship(e), emit("Shipped", e)))
# no central view; flow = the sum of these subscriptions

# ORCHESTRATION — a coordinator drives & sees the whole flow
def fulfill(order):
    charge(order); reserve(order); ship(order); notify(order)
    # one place to read, reorder, add compensation (topic 11)
🧒
In plain wordsTwo ways to run a multi-step job across services. Choreography: no boss — each service watches for events and does its part, like dancers taking cues from each other. Orchestration: one boss (a coordinator) tells each service when to go, like a conductor. Dancers are flexible but the routine is hard to follow; the conductor makes the plan obvious but everything leans on them.

✅ Do

  • Prefer choreography for loose, evolving flows with few steps
  • Prefer orchestration for complex flows needing visibility & compensation (topic 11)
  • Document the event flow — choreography's biggest weakness is discoverability

❌ Don't

  • Build a sprawling choreography no one can trace end to end
  • Let the orchestrator become a god-service that everything depends on
Gotcha: pure choreography scales the architecture but not your understanding — with a dozen services reacting to each other's events, no single place describes what a business flow actually does, and diagnosing "why didn't the order ship?" means archaeology across logs and traces. Most mature teams end up orchestrating the critical, compensating flows (sagas, topic 11) and choreographing the peripheral reactions.
Part II · Event-Driven Patterns
09Event sourcing — store the changes, not the state★ core idea

Scenario: a customer disputes their balance and asks "how did it get to $40?" Your database only stores the current $40 — the history is gone. With event sourcing, you store every change as an immutable event, so the state is always reconstructable and auditable.

What

Persist state as an append-only log of events (facts that happened). Current state is derived by replaying events, not stored directly.

Why

You get a perfect audit log, time-travel/debugging, the ability to build new read models from history (topic 10), and natural fit with event-driven systems.

How

Append events (Deposited, Withdrew). To get state, fold the events. Snapshot periodically so you don't replay from the beginning of time.

State = a fold over the event log
  traditional: row  balance = 40   (history lost on each UPDATE)

  event-sourced log (immutable, append-only):
    Deposited 100 → Withdrew 30 → Withdrew 30 → balance = 40
    replay ────────────────────────────────►  derive any time
  benefits: audit trail, "what was the balance on Tuesday?",
            rebuild new views from the SAME history (topic 10)
  snapshots: cache balance@event#1000 so replay isn't from zero
🧠
Analogy 1 — a bank statement vs a sticky note: a sticky note shows only today's balance (traditional). A bank statement lists every deposit and withdrawal (event log) — you can always re-add them to get the balance and answer "what happened on the 3rd?" Events are the statement; state is just the running total.
♟️
Analogy 2 — chess moves vs a board photo: a photo shows the current board (state). The move list (1. e4 e5 2. Nf3…) is event sourcing — replay the moves to reach any position, review how you got there, or branch a new analysis. The moves are the truth; the board is derived.
Append events, fold to state
events = [("Deposited", 100), ("Withdrew", 30), ("Withdrew", 30)]

def apply(state, event):
    kind, amt = event
    return state + amt if kind == "Deposited" else state - amt

balance = 0
for e in events:                # fold the log → current state
    balance = apply(balance, e)
print(balance)                  # 40 — and the FULL history is preserved
# snapshot every N events so you fold from the snapshot, not from 0
🧒
In plain wordsMost apps just save "the balance is $40" and forget how it got there. Event sourcing instead writes down every change — "+$100, −$30, −$30" — like a diary that's never erased. To know the balance, you add it all up. Now you can always answer "how did we get here?", replay the past, and even build brand-new reports from the old history. The diary is the source of truth.

✅ Do

  • Use it where audit, history, and "why" genuinely matter (finance, ledgers)
  • Make events immutable, past-tense facts; snapshot to bound replay cost
  • Version event schemas — you'll be replaying old events for years (topic 14)

❌ Don't

  • Event-source everything — it's heavy machinery for simple CRUD
  • Ever mutate or delete a stored event — that destroys the guarantee
Gotcha: event sourcing makes schema evolution permanent homework — because you replay events written years ago, you can never just "change the shape," you must handle every historical version forever (upcasting, topic 14). And a bug that wrote bad events can't be fixed with an UPDATE; you correct it with compensating events. The audit-log superpower and this rigidity are the same coin (topic 10).
10CQRS — separate the write and read models

Scenario: your write path needs strict validation and normalized tables, but your dashboard needs fast, denormalized reads across those same tables — and the two fight. CQRS splits them: one model for commands (writes), a separate model for queries (reads).

What

Command Query Responsibility Segregation: use different models (often different stores) for writing vs reading, kept in sync via events.

Why

Reads and writes have opposite needs (consistency/normalization vs speed/denormalization). Splitting lets each scale and optimize independently.

How

Commands update the write model and emit events; projections consume events to build read-optimized views (search index, cache, denormalized tables).

Write side and read side, joined by events
   COMMANDS ─► [ write model ] ──events──► [ projections ] ◄─ QUERIES
   (validate,   normalized,                denormalized read views:
    one truth)  consistent                 • search index
                                            • dashboard table
                                            • cache
   write side optimized for correctness; read side for speed
   (reads are eventually consistent with writes)
🧠
Analogy 1 — a kitchen vs a menu: the kitchen (write model) is organized for cooking correctly — raw ingredients, strict recipes. The menu (read model) is organized for choosing fast — pretty descriptions, prices, categories. You don't make diners read recipe cards; you don't cook from the menu. Same food, two representations.
🏭
Analogy 2 — a factory vs a showroom: the factory (writes) is built for assembly and quality control; the showroom (reads) is built for browsing and quick answers. Products flow from factory to showroom (events → projections). Optimizing one for the other's job would ruin both.
Commands write; projections build read views
# WRITE side: validate + persist + emit
def place_order(cmd):
    validate(cmd)
    write_db.insert(cmd)                 # normalized source of truth
    emit("OrderPlaced", cmd)

# READ side: a projection builds a fast, denormalized view
on("OrderPlaced", lambda e:
    read_db.upsert("orders_dashboard",   # pre-joined, indexed for queries
        {"id": e.id, "customer": e.name, "total": e.total, "day": e.day}))

dashboard = read_db.query("orders_dashboard where day = today")  # fast
🧒
In plain wordsSaving data and showing data want opposite things: saving wants to be careful and tidy, showing wants to be fast and pre-arranged. CQRS says: use one setup for writing and a different, read-friendly setup for showing, and copy changes from one to the other. Like a kitchen (organized to cook) and a menu (organized to pick) — same food, two layouts, each great at its job.

✅ Do

  • Reach for CQRS when read and write workloads truly diverge or need separate scaling
  • Build read models as disposable projections you can rebuild from events (topic 9)
  • Accept and communicate that read views are eventually consistent

❌ Don't

  • Apply CQRS to simple CRUD — it's overhead you don't need
  • Expect a read view to be instantly consistent with the write — it lags
Gotcha: CQRS bakes eventual consistency into your UX — the user submits a command, the write succeeds, but the read model hasn't caught up, so their own change "isn't there" on refresh. This confuses users and support alike. You either add read-your-writes handling (see the Distributed Systems playbook) or set expectations ("processing…"). CQRS without a plan for that lag generates a steady stream of "it didn't save!" tickets.
11The Saga pattern — distributed transactions★ core idea

Scenario: placing an order must charge payment, reserve inventory, and book shipping — across three services with three databases. There's no distributed COMMIT. If shipping fails after payment succeeded, you must undo the charge. That's a saga.

What

A saga models a multi-service transaction as a sequence of local transactions, each with a compensating action that semantically undoes it if a later step fails.

Why

Two-phase commit across services is slow, brittle, and blocks. Sagas give you eventual atomicity without a global lock — the pragmatic way to span services.

How

Run steps forward; on failure, run compensations backward (refund, release, cancel). Orchestrated (a coordinator) or choreographed (events) — topic 8.

Forward steps, compensate on failure
  forward:  charge ──► reserve ──► ship
              ✓          ✓          ✗ FAILS
  compensate (run backward):
            refund ◄── release ◄──┘
  each step: a LOCAL transaction + a matching UNDO
  net effect: eventual atomicity, no global lock (no 2PC)
🧠
Analogy 1 — booking a trip piece by piece: you book a flight, then a hotel, then a car. If the car falls through, you don't have a magic "cancel everything" button — you individually cancel the hotel and flight (compensations). A saga is planning each booking with its cancellation in hand.
↩️
Analogy 2 — a shopping return policy: a store can't "un-sell" atomically across departments, so each department has a returns process. If your combo deal falls apart, each item is returned through its own counter. Compensations are those per-department returns — not a rollback, but a deliberate undo.
Orchestrated saga with compensations
def place_order(order):
    done = []
    try:
        charge(order);   done.append(refund)      # step + its undo
        reserve(order);  done.append(release)
        ship(order);     done.append(cancel_ship)
    except Exception:
        for undo in reversed(done):    # compensate in reverse order
            undo(order)                # refund, release — semantic undo
        raise
# compensations must be idempotent (topic 6) — they may be retried
🧒
In plain wordsWhen one action spans several services, there's no single "undo everything" button like a database has. So a saga does the steps one by one, and for each step it keeps a matching "how to undo this". If a later step fails, it walks backward undoing what it already did — refund the payment, release the stock. It's not a true rollback; it's a planned series of undos. And each undo must be safe to retry.

✅ Do

  • Define a compensating action for every step that has side effects
  • Make both steps and compensations idempotent (they get retried, topic 6)
  • Orchestrate complex sagas for visibility; persist saga state to resume after crashes

❌ Don't

  • Reach for two-phase commit across microservices — it blocks and doesn't scale (see the Distributed Systems playbook)
  • Assume compensation fully "undoes" — a sent email or shipped box can't be unsent
Gotcha: sagas give you eventual atomicity but not isolation — midway through, the world sees a half-done state (money charged, order not yet placed), so another transaction can act on that intermediate reality. And some steps can't truly be compensated (you can't un-send an email or un-ship a package). You design around this with semantic locks, "pending" states, and compensations that mitigate rather than reverse (topics 8, 10).
12The outbox pattern — atomic DB + event★ core idea

Scenario: you save an order to the DB, then publish "OrderPlaced" to Kafka. The DB commit succeeds — then the broker publish fails. Now your data and your events disagree forever. The outbox pattern makes "save + publish" atomic.

What

Write the event into an outbox table in the same DB transaction as your business data. A separate relay reads the outbox and publishes to the broker.

Why

It solves the dual-write problem: you can't atomically write to a DB and a broker, so instead you make one atomic DB write and publish from that.

How

One transaction inserts the order and an outbox row. A relay (poller or CDC like Debezium) reads unpublished rows, sends them, marks them sent.

One atomic commit, then relay publishes
  ┌── single DB transaction ──┐
  │ INSERT order              │  ← business data + event together:
  │ INSERT outbox(OrderPlaced)│    both commit or neither
  └───────────────────────────┘
        │ committed
        ▼
   relay/CDC polls outbox ──► broker (Kafka)  ──► consumers
        marks row "published" (at-least-once → consumers dedupe, topic 6)
🧠
Analogy 1 — writing a letter and a copy in your own ledger: instead of trusting you'll remember to mail it, you record "letter to mail" in your ledger as you write the letter (one act). Later a helper checks the ledger and mails anything outstanding. Even if you're interrupted, the ledger guarantees it gets sent.
📮
Analogy 2 — an outbox tray on your desk: you finish a document and drop it in the outbox tray in the same motion. The mail carrier empties the tray later. You never have to "remember to send" — the tray is the record, and it was filled atomically with finishing the work.
Save data and event in one transaction
with db.transaction():                 # atomic: both rows or neither
    db.insert("orders", order)
    db.insert("outbox", {"type": "OrderPlaced", "payload": order,
                          "published": False})
# ...separately, a relay drains the outbox reliably:
def relay():
    for row in db.select("outbox where not published order by id"):
        broker.publish(row.type, row.payload)   # at-least-once
        db.update("outbox", row.id, published=True)
# DB and events can never disagree — the event is part of the commit
🧒
In plain wordsSaving your data and announcing "it happened!" are two separate steps, and if the second fails, your data and your announcements disagree forever. The fix: when you save the data, also write the announcement into the same database, in the same save — so they succeed together. Then a little helper reads those pending announcements and sends them out. Now the data and the news can never contradict each other.

✅ Do

  • Write the event to an outbox in the same transaction as the data
  • Use CDC (Debezium) or a poller to relay — and make consumers idempotent (topic 6)
  • Include an event id so downstream dedupe works

❌ Don't

  • Publish to the broker and write the DB as two independent steps (dual-write bug)
  • Delete outbox rows before confirming publication — mark then clean up later
Gotcha: the outbox guarantees the event is published at least once, not exactly once — the relay can crash after publishing but before marking the row sent, so it re-publishes on restart. This is fine and expected: it just means every consumer must be idempotent (topic 6). Teams who adopt the outbox for reliability but forget consumer-side dedupe simply move their duplicate bug one hop downstream (topics 5, 6).
13Dead letter queues & poison messages

Scenario: one malformed message can't be processed. Your consumer retries it, fails, retries, fails — forever — blocking every message behind it. A dead letter queue sidelines the poison message so the rest of the stream keeps flowing.

What

A poison message is one that always fails processing. A dead letter queue (DLQ) is where you route messages after N failed attempts, instead of retrying forever.

Why

Without a DLQ, one bad message blocks the queue (head-of-line blocking) or spins infinite retries, burning resources and stalling everything behind it.

How

Track attempts; after a max, move the message to the DLQ with its error and metadata. Alert on DLQ depth; inspect, fix, and replay when resolved.

Sideline the poison, keep the line moving
  main queue: [ good  POISON  good  good ]
                       │ fails ×3 (max retries)
                       ▼
                   ┌───────┐   alert! inspect the error,
                   │  DLQ  │   fix code/data, then replay
                   └───────┘
  without DLQ: POISON retries forever → blocks the 2 good msgs behind it
🧠
Analogy 1 — a jammed letter in a sorting machine: you don't stop the whole mail sort for one mangled envelope — you pull it into a "problem" bin for a human to handle, and the line keeps running. The DLQ is that problem bin; the poison message is the mangled envelope.
🛒
Analogy 2 — a checkout item that won't scan: a good cashier sets the un-scannable item aside and keeps ringing up the rest, then deals with it after — rather than holding the whole line hostage. The DLQ is "set aside for later"; the queue keeps moving.
Bounded retries, then dead-letter
MAX = 5
for msg in consumer:
    try:
        process(msg)
        consumer.commit()
    except Exception as e:
        if msg.attempts >= MAX:
            dlq.send(msg, error=str(e), attempts=msg.attempts)  # sideline
            consumer.commit()          # unblock the queue
        else:
            requeue(msg, delay=backoff(msg.attempts))   # try later
# alert when dlq depth > 0; provide a tool to inspect & replay after a fix
🧒
In plain wordsSometimes one message is broken and will never succeed. If you keep retrying it, it clogs the pipe and nothing behind it moves. So after a few tries, you toss it into a special "problem" bin (the dead letter queue) and move on. Later, a human looks in the bin, figures out what's wrong, fixes it, and re-sends. The key: never let one bad message freeze the whole line.

✅ Do

  • Cap retries and route failures to a DLQ with the error + context attached
  • Alert on DLQ depth — a filling DLQ is a real incident signal
  • Build a way to inspect and replay DLQ messages after a fix (topic 15)

❌ Don't

  • Retry a failing message infinitely — that's a self-inflicted outage
  • Silently drop poison messages — you'll lose data and never know why
Gotcha: a DLQ is a symptom detector, not a fix — an unmonitored DLQ silently swallows business events (lost orders, dropped payments) while everything looks healthy, and you discover it weeks later via an angry customer. The DLQ only helps if its depth is alerted on and someone owns draining it. Distinguish transient failures (retry) from poison (dead-letter) too, or you'll DLQ things that just needed one more try (topics 5, 15).
Part III · Production Streaming
14Schema evolution & the registry★ core idea

Scenario: you rename a field in your "OrderPlaced" event, deploy, and three downstream consumers you forgot about crash — plus every replayed historical event is now the "wrong" shape. Events are a contract that outlives any single deploy, so schemas must evolve compatibly.

What

Managing how event schemas change over time so old and new producers/consumers coexist. A schema registry (Avro/Protobuf/JSON Schema) stores schemas and enforces compatibility rules on every change.

Why

Producers and consumers deploy independently and events are replayed for years (topic 9). A breaking schema change silently shatters consumers you can't see (topic 3).

How

Only make backward/forward-compatible changes: add optional fields with defaults, never rename or remove required ones. The registry rejects incompatible changes at publish time.

Compatible changes only — the registry enforces it
  ✓ SAFE (backward compatible):
     add an OPTIONAL field with a default → old consumers ignore it
  ✗ BREAKING:
     rename/remove a field · change a type · make optional → required
     → old consumers crash; replayed old events don't fit the new shape

  SCHEMA REGISTRY: every publish validated against compat rules
     producer → [ registry: is this change compatible? ] → allow / REJECT
🧠
Analogy 1 — updating a paper form everyone uses: if you add an optional box, people who ignore it are fine (backward compatible). If you rename "Surname" to "Family name" or delete a required box, every office still using the old form breaks. The registry is the clerk who refuses to print a new form that would break existing offices.
🔌
Analogy 2 — a power socket standard: you can add a new optional pin that old plugs ignore, but you can't move the live pin without frying every existing device. Events are sockets that many devices (consumers) depend on; the registry is the safety standard that blocks a dangerous redesign.
Add optional, never break
# ✓ backward-compatible evolution:
#   v1: {order_id, total}
#   v2: {order_id, total, coupon_code = null}   # NEW optional + default
#   old consumers read v2 fine (ignore coupon_code); new read v1 (default)

# ✗ breaking (registry should REJECT):
#   rename total → amount · remove order_id · total: number → string

# a schema registry validates every produced schema against a
# compatibility mode (BACKWARD/FORWARD/FULL) and rejects unsafe changes.
# for event sourcing, keep UPCASTERS: old_event → new_event shape (topic 9)
🧒
In plain wordsThe events your services send are a promise about their shape, and lots of other services depend on that shape — including old events you might replay years later. So you can't just rename or delete fields; that breaks everyone still expecting the old shape. The safe move is to only add new optional fields. A "schema registry" is a gatekeeper that checks every change and blocks the ones that would break existing readers.

✅ Do

  • Add optional fields with defaults; keep changes backward/forward compatible
  • Use a schema registry to enforce compatibility on every publish
  • Version events and keep upcasters for replayed history (topic 9)

❌ Don't

  • Rename or remove fields, or change types — you break unseen consumers (topic 3)
  • Treat an event's shape like an internal struct you can refactor freely
Gotcha: the danger of events is that the coupling is invisible — unlike an API call, nothing in your code references the six consumers that depend on your event's shape, so a "harmless" rename passes review and detonates in production across services you forgot existed. Worse, event sourcing (topic 9) means you'll replay events written under old schemas forever, so every historical version must remain readable. A schema registry with a strict compatibility mode is the guardrail that makes independent deploys safe (topics 3, 9).
15Backpressure, consumer lag & replay

Scenario: a spike floods your topic and consumers fall behind — "consumer lag" climbs into the millions, and events are processed minutes late. Separately, you deploy a bug that mis-processed a day of events. Both problems are handled by two superpowers of streams: flow control and replay.

What

Consumer lag = how far behind a consumer is (unread offsets). Backpressure = controlling flow so consumers aren't overwhelmed. Replay = resetting a consumer's offset to reprocess past events.

Why

Lag is the key health metric of a streaming system. And replay — only possible because streams retain history (topic 2) — lets you rebuild state or fix bugs by reprocessing, a thing queues can't do.

How

Monitor and alert on lag; scale consumers up to the partition count; buffer with bounded memory. To replay, reset the consumer group's offset to an earlier point and let it reprocess (idempotently, topic 6).

Lag to watch; replay to fix
  log: e0 e1 e2 e3 e4 e5 e6 e7 e8 e9  (latest = e9)
                     ▲ consumer at e4  → LAG = 5 (falling behind!)
   fix: add consumers (up to #partitions) · bounded buffers (backpressure)

  REPLAY (streams only — queues can't):
     reset group offset e4 → e0  ⇒  reprocess e0..e9 from scratch
     rebuild a read model, or reprocess after fixing a bug
     (consumers MUST be idempotent so replay doesn't double-apply — topic 6)
🧠
Analogy 1 — a DVR of a live channel: consumer lag is how far behind "live" you are while watching a recording. If you fall too far behind, you speed up or get help (more consumers). And because it's recorded, you can rewind and re-watch (replay) — something you can't do with a one-time phone call (a queue).
🎞️
Analogy 2 — re-running the tape after fixing the projector: if the projector mangled last night's film, you don't lose the movie — you rewind the reel and play it again correctly. Stream replay is rewinding the reel: fix the consumer, reset to yesterday, reprocess. The reel (retained log) is what makes it possible.
Watch lag; replay to reprocess
# MONITOR lag = latest_offset - committed_offset per partition
#   alert when lag grows; scale consumers up to the partition count (topic 4)

# REPLAY — reset the consumer group to an earlier offset & reprocess:
$ kafka-consumer-groups --group billing --reset-offsets \
    --to-datetime 2026-07-01T00:00:00 --topic orders --execute
# now billing reprocesses everything since July 1 — to fix a bug or
# rebuild a projection (topic 10). Consumers MUST be idempotent (topic 6)
# so replaying doesn't double-charge or double-count.
🧒
In plain wordsTwo streaming superpowers. First, lag: if events pour in faster than a service reads them, it falls behind — you watch that gap and add more workers or slow the inflow (backpressure) so nothing gets buried. Second, replay: because a stream keeps all its events (unlike a queue that throws them away), you can "rewind" and reprocess the past — perfect for fixing a bug or rebuilding a report. Just make sure reprocessing is safe to repeat, or you'll double things up.

✅ Do

  • Monitor and alert on consumer lag — it's the key streaming health metric
  • Scale consumers up to the partition count; use bounded buffers for backpressure
  • Use replay to rebuild read models and reprocess after bug fixes — idempotently (topic 6)

❌ Don't

  • Ignore rising lag — it means events are processed late, silently
  • Replay into non-idempotent consumers — you'll double every side effect (topic 6)
Gotcha: replay is a genuine superpower and a loaded gun — resetting an offset re-emits every past event through your consumer, so if that consumer isn't idempotent (topic 6) you re-charge cards, re-send emails, and double every count. Teams reach for replay to "fix" data and cause a second, bigger incident. Replay is only safe when consumers dedupe and side effects are idempotent — which is exactly why idempotency (topic 6) is the foundation everything else in this playbook rests on (topics 2, 6).
16Capstone — an event-driven order system★ capstone

Scenario: assemble everything into one real system — an order platform where checkout emits one event and billing, inventory, shipping, and analytics all react, correctly, even under duplicates and failure. This is the whole playbook working together.

What

A production event-driven flow wiring together the outbox, pub/sub fan-out, idempotent consumers, a saga, CQRS read models, DLQs, and schema-safe events — the concepts from all 15 topics.

Why

Any one pattern is easy alone. Production is making them coexist: decoupled, correct under duplicates and failure, observable, and evolvable.

How

Commit order + outbox atomically → relay to a keyed stream → many consumers fan out, each idempotent → a saga coordinates fulfillment with compensations → projections build read views → poison goes to a DLQ.

The whole system, assembled
  POST /order ─► [ order-svc ]
     └ tx: INSERT order + INSERT outbox(OrderPlaced)   (atomic, topic 12)
             │ relay/CDC
             ▼
        [ stream: orders ]  (keyed by customer, topics 4, 7)
          ├─► SAGA: charge→reserve→ship, compensate on fail (topic 11)
          │      every consumer idempotent (topic 6)
          ├─► projection → orders_dashboard (CQRS read model, topic 10)
          └─► analytics / search (fan-out, topic 3)
  poison → DLQ (topic 13) · events schema-checked (topic 14) · replayable (15)
🧠
Analogy 1 — a well-run newsroom: one reporter files a story (OrderPlaced) to the wire (stream); every desk — sports, finance, web — picks up the same wire and runs its own version (fan-out + projections). A mangled story goes to the editor's problem tray (DLQ), the style guide keeps formats compatible (schema registry), and the archive lets you re-run yesterday's edition (replay). One filing, many correct reactions.
🏗️
Analogy 2 — a finished building vs a pile of materials: you learned bricks (idempotency), wiring (events), and plumbing (queues/streams) separately. The capstone is the standing, inspected, occupied building — every system connected and up to code. That integration is the engineering.
Order flow, everything wired together
def place_order(cmd):
    with db.transaction():                          # atomic data + event (12)
        order = db.insert("orders", cmd, status="placed")
        db.insert("outbox", event("OrderPlaced", order, id=uuid()))
    return order
# relay drains outbox → stream(keyed by customer, 4/7) → consumers:
on("OrderPlaced", saga_fulfill)      # charge→reserve→ship, compensate (11)
on("OrderPlaced", project_dashboard) # CQRS read model (10)
on("OrderPlaced", index_search)      # fan-out (3)
# every consumer: idempotent by event id (6); poison → DLQ (13)
# events schema-checked before publish (14); stream replayable to rebuild (15)
🧒
In plain wordsThis is everything put together: an order comes in and is saved with its announcement in one safe step, the announcement fans out to the teams that charge, reserve, and ship it — each able to ignore duplicates and undo its part if a later step fails. Reads come from fast pre-built views, broken messages go to a "problem" bin instead of jamming the line, the event shapes are kept compatible, and you can re-run the past to fix mistakes. Each part was simple alone — the real skill is making them all work together, correctly, even when messages arrive twice or a step fails.

✅ Do

  • Start simple; add the outbox, idempotency, and DLQs before you add more consumers
  • Make the happy path correct and the duplicate/failure paths tested
  • Treat events as a versioned contract and keep them replayable (topics 14, 15)

❌ Don't

  • Adopt every pattern here for a small system — most apps need very few of them
  • Bolt on idempotency and schema safety "later" — later is after the duplicate-charge incident
Gotcha: the hardest part of an event-driven system isn't any single pattern — it's that they interact. The outbox creates duplicates that only idempotent consumers survive; CQRS creates read-model lag that confuses users mid-saga; replay re-fires every side effect through consumers that had better be idempotent. Idempotency (topic 6) is the keystone the whole arch depends on. Ship the smallest system that solves the real problem, then add each pattern deliberately — because every one you add is complexity you'll debug at 3am. And remember: for the machinery underneath (consistency, replication, resilience), reach for the companion Distributed Systems playbook. 🚀
Test yourself · Round-based quiz

Answer 5 per round. Your progress and score are saved in this browser — come back anytime and continue where you left off.

Score: 0 / 0 answered · 0 total