Design an event-sourced order pipeline
The interviewer asks
“Design an order pipeline where every change to an order is stored as an immutable event (OrderPlaced, PaymentCaptured, OrderShipped), and payment, fraud scoring, notifications and the order-history screen are all built by consumers of those events. A fraud check calls an LLM that can take 30 seconds or fail. No order may ever be charged twice.”
What a strong answer looks like
A strong answer stores events, not rows: an append-only event store in Postgres keyed by (order_id, version), where a unique constraint gives optimistic concurrency and current state is a fold over the events (with snapshots for long histories). Each append commits together with an outbox row, and a relay or Debezium publishes to Kafka topic orders.events keyed by order_id, so all events for one order stay in order. Consumers (payment, AI fraud scorer, notifications, projections for read models) each track offsets, record processed event ids in the same transaction as their side effect, and use retry topics plus a DLQ so one poison event never blocks a partition. The payment consumer passes order_id as an idempotency key to the gateway, so redelivery cannot double-charge. The candidate should be precise that Kafka's exactly-once is internal to Kafka; end to end this is effectively-once via idempotent consumers. Schema versioning, replaying projections from offset 0, and fixing mistakes with compensating events complete the picture.
Concepts to mention
1 Requirements
Functional
- Place, pay, cancel, ship and deliver orders; every state change is an event appended to the order's stream
- Current order state is derived from its events; full history is available for audit and customer support
- Consumers react to events: charge payment, run an AI fraud score, reserve inventory, send notifications
- Read models (projections): order details page, 'my orders' list, ops dashboard, analytics
- Rebuild any projection from history after a bug fix or to create a new view
- Failed processing goes to a dead-letter queue that can be inspected and replayed
Non-functional
- Place-order API p99 under 200 ms; it returns 202 with the order id and does not wait for payment or fraud scoring
- Peak 5k orders/s (festive sale), ~25k events/s across all event types
- Never double-charge, never lose an event once the API has returned
- Projections lag p99 under 2 s in normal operation
- Events retained forever (audit, 7+ years for tax); projections are disposable
- LLM fraud scoring may take up to 30 s and fail 5-20% of the time without slowing the order API
Out of scope (say it out loud)
- Payment gateway internals
- Warehouse and delivery routing
- Search over orders
2 Estimates
3 API
| Endpoint | What it does |
|---|---|
POST/v1/orders | Place order with Idempotency-Key header; appends OrderPlaced; returns 202 {order_id, status: pending} |
POST/v1/orders/{id}/cancel | Command: validated against current state, appends OrderCancelled or returns 409 |
GET/v1/orders/{id} | Read from the order-details projection (may lag ~1 s; includes version) |
GET/v1/orders/{id}/events | Full event history for support and audit |
GET/v1/users/{id}/orders?cursor= | 'My orders' projection, cursor paginated |
POST/admin/dlq/{topic}/replay | Republish DLQ messages after a fix, filtered by error or time |
4 Data model
order_events (event store)Postgresorder_idversionevent_id (UUID)typeschema_versionpayload jsonbmetadata (trace_id, actor, causation_id)created_at; PK (order_id, version)Append-only. The PK is the optimistic concurrency check: two commands both trying to write version 7 → one fails and retries. Partition by month; hash-shard by order_id when one primary is not enough.
outboxPostgresidaggregate_idevent_idtopicpayloadcreated_atWritten in the same transaction as the event; relay or Debezium publishes to Kafka. Can be skipped if CDC reads order_events directly.
snapshotsPostgresorder_idversionstate jsonbEvery ~50 events, so loading an order folds at most 50 events. Most orders have under 15 events and never need one.
orders.eventsKafkakey = order_id; value = event; headers: event_idtypeschema_versiontraceparent48 partitions, replication factor 3, min.insync.replicas 2, long retention; plus orders.retry.1m, orders.retry.10m, orders.dlq.
processed_eventsPostgres (per consumer)consumerevent_id (PK)processed_atInserted in the same transaction as the consumer's side effect; duplicate insert = already done.
order_view / user_ordersPostgres or DynamoDB / Cassandraorder_iduser_idstatusitemsamountslast_versionProjections shaped for each screen. Updates apply only if event version = last_version + 1 (or greater, for idempotent overwrite).
5 Architecture
Hover a box to highlight its connections.
6 Request flows
Place and pay for an order
- 1Client POSTs /orders with Idempotency-Key; command API checks the key table and returns the existing order if seen
- 2Command handler validates (cart, prices, stock hint), then in one transaction inserts OrderPlaced at (order_id, version 1) and an outbox row
- 3API returns 202 with order_id in ~50 ms; the app shows 'Confirming payment'
- 4Relay publishes OrderPlaced to orders.events with key order_id
- 5Payment consumer charges via the gateway using idempotency key = order_id, then sends a RecordPayment command that appends PaymentCaptured (version 2)
- 6Fraud scorer, in parallel, calls the LLM through the AI gateway and appends FraudScored; a high score appends OrderHeld for manual review
- 7Projection builders update order_view and user_orders; the app's next poll or SSE update shows 'Order placed'
Consumer crashes after charging
- 1Payment consumer charges successfully, then the pod is killed before committing its offset
- 2Kafka redelivers OrderPlaced to another instance after the group rebalances
- 3The consumer finds the event_id in processed_events only if its local transaction committed; if not, it calls the gateway again with the same idempotency key
- 4The gateway returns the original charge instead of creating a new one; the RecordPayment command hits version conflict or a no-op because PaymentCaptured already exists
Rebuild a projection after a bug
- 1A bug wrote wrong delivery dates into order_view
- 2Deploy the fixed builder with a new consumer group and a new table order_view_v2
- 3It replays from the earliest retained offset (or from the event store for older history) at full speed
- 4When lag reaches ~0, switch the query API to order_view_v2 and drop the old table
7 Deep dives
The event store: append, concurrency and loading state
Each order is a stream of events with a strictly increasing version. To handle a command (cancel), load the snapshot if any, fold the remaining events into current state, check business rules (cannot cancel after shipping), and append the new event at version n+1. The primary key (order_id, version) makes this optimistic concurrency control: if two commands race, both read version 6, both try to write 7, and the database rejects one, which reloads and re-checks. No locks are held while thinking. Postgres works well as the event store at this scale (an indexed append per event, tens of thousands per second on good hardware, more with hash sharding by order_id); EventStoreDB or DynamoDB with a conditional put on (pk=order_id, sk=version) are alternatives. Events are facts in past tense and are never updated or deleted. If something was wrong, append a compensating event (PaymentRefunded, OrderCorrected). This is what gives audit and support a complete history: 'why was this order cancelled?' is answered by reading the stream, with actor and causation ids in the metadata.
Getting events to Kafka without the dual-write bug
If the command handler writes the event to Postgres and then publishes to Kafka, a crash between the two leaves an event that nobody hears about; publishing first risks announcing an event that was never saved. The fix is the transactional outbox: the event and an outbox row commit in one database transaction, and a separate relay publishes outbox rows to Kafka and marks them sent. With CDC (Debezium reading the Postgres WAL) the event table itself can be the outbox. The relay can publish a row twice if it crashes after sending but before marking, so delivery into Kafka is at-least-once; consumers dedupe on event_id. Key every message by order_id: Kafka only orders messages within a partition, so a random key lets PaymentCaptured overtake OrderPlaced. Producer settings matter: acks=all, enable.idempotence=true, and topics with replication factor 3 and min.insync.replicas=2, so an acknowledged event survives a broker loss. Propagate the W3C traceparent in Kafka headers so one trace spans API, relay and every consumer.
Effectively-once consumers and no double charge
Kafka's exactly-once (idempotent producer plus transactions) only covers read-process-write loops that stay inside Kafka. A consumer that charges a card or writes to Postgres gets at-least-once delivery, so correctness comes from idempotent processing. Pattern: in one database transaction, insert event_id into processed_events and apply the side effect; a duplicate violates the primary key and the whole thing is skipped. Commit the Kafka offset only after that transaction commits. For external side effects that cannot share our transaction, such as the payment gateway, pass a deterministic idempotency key (order_id or event_id) so the provider deduplicates. Ordering within a partition plus version checks in projections (apply only if event.version is newer than last_version) make replays safe. The failure modes to name: committing the offset before the side effect loses events; committing after but without idempotency duplicates them. Use cooperative-sticky partition assignment so scaling consumers does not pause the whole group on every rebalance.
Slow and failing consumers: the LLM fraud scorer
The fraud scorer calls an LLM that may take 30 s or fail 20% of the time. If it retried inline, one stuck order would block every later order in its partition. So failures are moved aside: after an immediate retry, publish the event to orders.retry.1m, then orders.retry.10m, each consumed by a worker that waits until the message is due; after the last attempt, publish to orders.dlq with the error, stack trace and attempt count in headers. The main partition keeps moving. Because the LLM is non-deterministic, store its output (score, reasons, model and prompt version) in the FraudScored event itself. Replays then reuse the recorded decision instead of re-asking the model and getting a different answer, which is a core event-sourcing rule: record the result of non-deterministic calls. Add a business fallback: if no score arrives within 2 minutes, apply a rules-only score so orders are not stuck. Scale the scorer on consumer lag (KEDA), but cap replicas at the partition count and at the LLM rate limit, or you only produce 429s faster.
8 Trade-offs
Event sourcing vs CRUD plus an outbox
Event sourcing gives full history, replayable projections and natural audit, at the cost of more complexity: schema evolution, eventual consistency in reads, and harder ad-hoc queries. Many teams get most of the benefit with a normal orders table plus outbox events. Choose event sourcing when the history itself has business value, as in orders, payments and ledgers.
Eventual consistency in reads
Projections lag ~1 s, so a user may refresh and not see the order yet. Mitigate by returning the new state in the command response, reading your own writes from the event store for the details page right after a command, or waiting on the projection version in the client.
Schema evolution
Events live forever, so old shapes must stay readable. Use a schema registry with backward-compatible changes (add optional fields), a schema_version on every event, and upcasters that convert old versions to the current shape on read. Never rename or reuse fields in place.
Choreography vs orchestration
Consumers reacting to events (choreography) stay decoupled but the end-to-end flow is hard to see. For multi-step flows with compensation (reserve, pay, ship, refund on failure), an orchestrator such as Temporal or a saga service makes the flow explicit. A common split: events for facts and fan-out, orchestration for the checkout saga.
9 Bottlenecks
Hot partition from a giant merchant or bulk orders
Keying by order_id spreads load well; problems come when keying by merchant_id or user_id. Keep order_id as the key and do merchant-level aggregation downstream.
Event store write throughput
At 10× (250k events/s) one Postgres primary is not enough. Hash-shard streams by order_id across several primaries (Citus or app-level), or move to DynamoDB with conditional writes.
Projection rebuild time
Rebuilding from years of history takes too long. Use snapshots, keep compacted Kafka topics of latest state for some views, and parallelise rebuilds across partitions.
10 Failure handling
Kafka unavailable
Commands still succeed because they only need the event store; the outbox grows and the relay catches up when Kafka returns. Projections and downstream actions are delayed, so the UI shows 'processing'.
Poison event
An event the consumer cannot parse would block the partition forever. After retries it goes to the DLQ with error details, the partition moves on, and an alert fires. After a fix, replay from the DLQ.
LLM provider outage
Fraud scoring events pile up in retry topics; the 2-minute fallback applies rules-only scoring so orders keep flowing. When the provider recovers, the scorer drains the backlog at its rate limit.
Bad event written by a bug
Never edit history. Append a correcting event and fix the code; projections that consumed the bad event either handle the correction or are rebuilt.
11 What impresses
- Explaining optimistic concurrency with the (order_id, version) key instead of row locks
- Being precise about where Kafka exactly-once stops
- Storing the LLM's decision in the event so replays are deterministic
- Rebuilding a projection into a new table and switching reads, not mutating in place
- Caveat that event sourcing is not free and naming when CRUD plus outbox is enough
- Capping consumer scaling at partition count and the LLM rate limit
12 Mistakes
- Writing to the DB and Kafka separately (dual write) without an outbox
- Random or missing Kafka keys, so events for one order are reordered
- Committing offsets before the side effect, or retrying a failed message inline forever
- Calling the payment gateway without an idempotency key
- Updating or deleting events to fix mistakes
- Reading current state from projections when validating commands, which can be stale
13 Follow-ups
Lessons this design uses
Practice questions
- Sagas rely on compensations, but some actions cannot be undone and other requests can see intermediate state. How do you design around that?
- After placing an order, the user is taken to 'My Orders', and the new order is missing for a few seconds. The orders list is built from a read model updated via Kafka. How do you handle this?
- Support needs to see every status change of an order with time and reason. Do you just update a status column, or something else?
- Order events (created, paid, shipped) must be processed in order for each order. How do you guarantee this with Kafka, including during retries and failures?
- Your order service saves an order in Postgres and then publishes OrderCreated to Kafka. Sometimes the order exists but the event was never sent. Why, and how do you fix it?
- An architect proposes event sourcing for the whole order platform. When is event sourcing worth it, and what problems will the team face?
- Your payments service publishes 'payment_succeeded' to Kafka, and a cashback service credits ₹50 to the user's wallet on that event. How do you guarantee the user gets cashback exactly once, even with crashes and redeliveries?
- Your order service saves the order in Postgres and then publishes an 'OrderPlaced' event to Kafka. Sometimes the warehouse never hears about an order. What is going wrong, and how do you fix it?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.