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.
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
# 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✅ 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
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.
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)
# 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✅ 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)
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.
┌─► billing (charge)
checkout ├─► inventory (reserve)
publish ──OrderPlaced──► [ topic ] ──┼─► search (index)
(knows nobody) ├─► analytics (count)
└─► notifications(email)
add a 6th consumer tomorrow → checkout code UNCHANGED
# 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✅ 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)
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 "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
# 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✅ 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
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).
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 ✓
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✅ 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)
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".
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
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"✅ 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
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.
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)
# ✓ 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)✅ 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
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.
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
# 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)✅ 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
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.
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
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✅ 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
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).
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)
# 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✅ 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
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: 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)
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✅ 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
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.
┌── 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)
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✅ 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
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.
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
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✅ 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
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.
✓ 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
# ✓ 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)✅ 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
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).
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)
# 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.✅ 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)
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.
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)
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)✅ 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
Answer 5 per round. Your progress and score are saved in this browser — come back anytime and continue where you left off.