Design problems
Platform & infrastructureHardSDE-3 35 min

Design an event-sourced order pipeline

Asked at
AmazonFlipkartMeeshoSwiggyZeptoZomato

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

Append-only event store with (aggregate_id, version)Optimistic concurrency via unique versionSnapshots for long streamsTransactional outbox or CDC to KafkaPartition key = order_id for per-order orderingCQRS projections as read models, rebuildable from historyIdempotent consumers with processed_events in the same transactionKafka exactly-once is internal; end to end is effectively-onceRetry topics and DLQ so poison messages do not block partitionsSchema versioning and upcastingCompensating events instead of editsRecord results of non-deterministic (LLM) steps in events

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

Order rate~120/s average, 5k/s peak10M orders/day ÷ 86,400 ≈ 115/s; sale-start minutes reach ~40× average
Event rate~600/s average, ~25k/s peak~5 events per order lifecycle (placed, paid, scored, shipped, delivered)
Event store growth~50M events/day, ~25 GB/day50M × ~500 bytes; ~9 TB/year, partitioned by month, cold months moved to cheaper storage
Kafka partitions48 for orders.events25k/s peak ÷ ~1k/s per consumer instance for the slowest normal consumer ≈ 25, doubled for headroom; also caps useful consumers per group at 48
Fraud scorer concurrency~1,500 in-flight LLM calls at peakLittle's Law: 5k orders/s × 30% scored by LLM × ~1 s typical latency; slow tails need more, so the LLM step is behind its own queue
Projection rebuild~3 hours for 1 year~18B events/year is too much; rebuild from snapshots + last 90 days (~4.5B events) at ~400k events/s across 48 partitions ≈ 3 h

3 API

EndpointWhat it does
POST/v1/ordersPlace order with Idempotency-Key header; appends OrderPlaced; returns 202 {order_id, status: pending}
POST/v1/orders/{id}/cancelCommand: 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}/eventsFull event history for support and audit
GET/v1/users/{id}/orders?cursor='My orders' projection, cursor paginated
POST/admin/dlq/{topic}/replayRepublish DLQ messages after a fix, filtered by error or time

4 Data model

order_events (event store)Postgres
order_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.

outboxPostgres
idaggregate_idevent_idtopicpayloadcreated_at

Written in the same transaction as the event; relay or Debezium publishes to Kafka. Can be skipped if CDC reads order_events directly.

snapshotsPostgres
order_idversionstate jsonb

Every ~50 events, so loading an order folds at most 50 events. Most orders have under 15 events and never need one.

orders.eventsKafka
key = order_id; value = event; headers: event_idtypeschema_versiontraceparent

48 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_at

Inserted in the same transaction as the consumer's side effect; duplicate insert = already done.

order_view / user_ordersPostgres or DynamoDB / Cassandra
order_iduser_idstatusitemsamountslast_version

Projections shaped for each screen. Updates apply only if event version = last_version + 1 (or greater, for idempotent overwrite).

5 Architecture

CLIENTSEDGESERVICESASYNCDATAEXTERNALcommandsreadsappend + outbox (1 tx)CDC / relaycharge, idem keyscore, 30 s timeoutPaymentCaptured cmdFraudScored cmdafter retriesWeb / appAPI gatewayOrder command APIOrder query APIKafka: orders.eventsPayment consumerProjection buildersAI fraud scorerEvent store + outboxRead modelsRetry topics + DLQPayment gatewayLLM via AI gateway
ClientEdge / gatewayService / workerQueue / streamCacheDatabase / storageAI / modelThird partyrequestasync (event / queue)

Hover a box to highlight its connections.

6 Request flows

Place and pay for an order

  1. 1Client POSTs /orders with Idempotency-Key; command API checks the key table and returns the existing order if seen
  2. 2Command handler validates (cart, prices, stock hint), then in one transaction inserts OrderPlaced at (order_id, version 1) and an outbox row
  3. 3API returns 202 with order_id in ~50 ms; the app shows 'Confirming payment'
  4. 4Relay publishes OrderPlaced to orders.events with key order_id
  5. 5Payment consumer charges via the gateway using idempotency key = order_id, then sends a RecordPayment command that appends PaymentCaptured (version 2)
  6. 6Fraud scorer, in parallel, calls the LLM through the AI gateway and appends FraudScored; a high score appends OrderHeld for manual review
  7. 7Projection builders update order_view and user_orders; the app's next poll or SSE update shows 'Order placed'

Consumer crashes after charging

  1. 1Payment consumer charges successfully, then the pod is killed before committing its offset
  2. 2Kafka redelivers OrderPlaced to another instance after the group rebalances
  3. 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
  4. 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

  1. 1A bug wrote wrong delivery dates into order_view
  2. 2Deploy the fixed builder with a new consumer group and a new table order_view_v2
  3. 3It replays from the earliest retained offset (or from the event store for older history) at full speed
  4. 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

Q1How would you change an event's schema that 50 consumers depend on?
Q2A user asks for their data to be deleted under the DPDP Act. How do you do that in an immutable log?
Q3How do you show the user their order immediately after placing it if projections lag?
Q4How would you add a new 'seller analytics' view over two years of history?
Q5How would you run the checkout saga across inventory, payment and shipping?
Q6How do you test that consumers are really idempotent?

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.

Start free