System Design · Lesson 18 of 18
Capstone: Food Delivery (Swiggy / DoorDash)
One real-world ask, every layer: requirements, HLD, LLD, API, schema, sharding, caching and failure.
- Advanced
- 50 min read
- 3 objectives
Before this lessonLesson 17: Notification System
What you will learn
- Run a full interview end to end
- Connect HLD choices to LLD code
- Shard, cache and degrade deliberately
Your Progress
0 of 18 lessons 0%
- Lessons0 / 18
- Completed0
- Est. time left~ 16 hours
Create a free account to keep your progress on every device.
Tip: pressing Next marks this lesson complete automatically.
This is the capstone. One realistic prompt — "design a food delivery app like Swiggy, Zomato, DoorDash or Uber Eats" — carried all the way through every layer: requirements, capacity math, API, database schema, high-level architecture, low-level code, geo-indexing, sharding, caching, consistency decisions, failure modes and rollout. It is the closest thing here to a full 45-minute interview transcript.
Food delivery is a favourite prompt because it is genuinely three systems welded together, each with a different character:
- A read-heavy catalogue — browsing restaurants and menus. Massive reads, rare writes, stale-tolerant. Cache and CDN territory.
- A transactional order system — payment, inventory, a strict lifecycle. Low volume, zero tolerance for double charges. ACID and idempotency territory.
- A realtime geospatial matching system — courier locations and dispatch. High write volume, soft consistency, latency-sensitive. Queue, geo-index and in-memory territory.
The single most valuable thing you can say in this interview is that these three have different requirements and therefore get different storage, different consistency guarantees and different scaling strategies. Candidates who pick one database for all three lose the plot.
Step 1 — Clarify the requirements
Ask, do not assume. Here is a realistic set of answers to design against.
Functional, in scope for v1
- Customer: search and browse nearby restaurants, view a menu, add to cart, place an order, pay, track the order live, rate it.
- Restaurant: receive orders, accept or reject, mark prepared, toggle items out of stock, set open hours.
- Courier: go online, receive an offer, accept, navigate, mark picked up and delivered.
- Platform: assign a courier to each order, compute ETA and price, notify all three parties on every state change.
Explicitly out of scope (say this out loud)
Grocery and multi-store carts, scheduled orders, subscriptions, loyalty and coupons engine, courier payouts and tax, the fraud platform, the support tooling, dine-in. Each is a system of its own; naming them shows you know the real product is bigger than the interview.
Non-functional
- Scale: 20M daily active users, 5M orders/day, 200k restaurants, 500k couriers with 100k online at peak.
- Latency: home feed p99 < 300 ms; search p99 < 500 ms; order placement p99 < 2 s including the payment call; courier location ingest p99 < 100 ms; live tracking updates every 3–5 s.
- Availability: 99.99% for browse and order; degraded-but-alive is better than down. Payment correctness beats availability — we would rather reject an order than charge twice.
- Consistency: strong for money, order state and inventory; eventual for ratings, menu edits, search ranking and courier positions.
- Geography: multi-region, but every order is inherently local — a customer, restaurant and courier are within a few kilometres. This is the key structural insight: the workload shards naturally by city.
Step 2 — Back-of-the-envelope estimation
DAU = 20_000_000
orders_per_day = 5_000_000
restaurants = 200_000
couriers_online = 100_000 # at peak
SECONDS_PER_DAY = 86_400
# --- Orders: surprisingly small ---
orders_avg = orders_per_day / SECONDS_PER_DAY # ~58/s
orders_peak = orders_avg * 10 # lunch + dinner spikes are brutal, not 3x
print(f"orders: {orders_avg:.0f}/s avg, {orders_peak:.0f}/s peak")
# --- Browse: 20-40x the order volume (people look, then leave) ---
sessions_per_day = DAU * 1.5
views_per_session = 20
browse_avg = sessions_per_day * views_per_session / SECONDS_PER_DAY
print(f"browse: {browse_avg:,.0f}/s avg, {browse_avg * 8:,.0f}/s peak")
# --- Courier pings: the real firehose ---
ping_every_seconds = 4
pings = couriers_online / ping_every_seconds
print(f"courier pings: {pings:,.0f}/s")
# --- Storage per year ---
order_row_bytes = 1_500 # order + items + denormalised snapshot
orders_gb_year = orders_per_day * 365 * order_row_bytes / 1e9
ping_bytes = 40 # courier_id, lat, lng, ts, accuracy
pings_tb_year = pings * SECONDS_PER_DAY * 365 * ping_bytes / 1e12
print(f"orders: {orders_gb_year:,.0f} GB/year raw pings: {pings_tb_year:,.1f} TB/year")
# --- Live tracking fan-out (WebSocket connections held) ---
in_flight = orders_per_day / SECONDS_PER_DAY * 30 * 60 # ~30 min per order
print(f"orders in flight at once: {in_flight:,.0f} -> ~{in_flight * 2 / 1000:,.0f}k sockets")orders: 58/s avg, 579/s peak browse: 6,944/s avg, 55,556/s peak courier pings: 25,000/s orders: 2,738 GB/year raw pings: 31.5 TB/year orders in flight at once: 104,167 -> ~208k sockets
What the numbers actually decide
- 580 orders/s peak is small. A well-indexed Postgres cluster handles that. So the order database is not sharded in v1 — it is a primary with replicas, sharded by city later if needed. Saying "I will use Cassandra for orders" here is over-engineering, and you lose the transactions you actually need.
- 55k browse requests/s is large and 99% of it is the same few thousand restaurant and menu documents per city. That is a cache and CDN problem, not a database problem.
- 25k location writes/s and 31 TB/year of raw pings is the one genuinely heavy write path. It does not belong in the order database. Current position goes in Redis (overwritten, not appended); the history stream goes to Kafka and then to cheap columnar storage with a short retention, downsampled.
- 208k concurrent WebSockets means a dedicated connection tier — roughly 50k–100k sockets per node, so a handful of nodes plus headroom, separate from the stateless API fleet so deploys do not drop everyone's tracking.
- Peak is 10x, not 3x. Lunch and dinner are the whole business. Autoscaling that reacts in five minutes is too slow for a 30-minute dinner rush — pre-scale on a schedule, and keep warm capacity.
Step 3 — API design
Resource-oriented, versioned, idempotent where it matters. Auth is a bearer token; the gateway resolves it to a user id — the client never sends one.
## Discovery (read-heavy, cacheable, eventually consistent)
GET /api/v1/feed?lat=12.97&lng=77.59&cursor=<opaque>
-> 200 { sections: [{ title, restaurants: [RestaurantCard] }], next_cursor }
Cache-Control: private, max-age=60
# RestaurantCard is a denormalised read model: name, image, rating,
# eta_minutes, price_band, is_open, promos. One document, zero joins.
GET /api/v1/search?q=biryani&lat=&lng=&filters=veg,rating_4plus&cursor=
-> 200 { results: [RestaurantCard], facets: {...}, next_cursor }
GET /api/v1/restaurants/{id}/menu
-> 200 { version: 412, categories: [{ name, items: [{ id, name, price_minor,
currency, is_available, customisations: [...] }] }] }
ETag: "menu-412" # client revalidates cheaply with If-None-Match
## Cart and pricing (server authoritative — never trust a client price)
POST /api/v1/carts/{cart_id}/items { item_id, qty, customisations[] }
-> 200 Cart | 409 ITEM_UNAVAILABLE | 409 RESTAURANT_CLOSED
POST /api/v1/carts/{cart_id}/quote { address_id, tip_minor, promo_code? }
-> 200 { subtotal_minor, delivery_fee_minor, surge_fee_minor, taxes_minor,
discount_minor, total_minor, eta_minutes, quote_id, expires_at }
# quote_id is signed and short-lived (2 min). Order placement replays it,
# so price cannot change between "Pay" and the charge.
## Order placement — the one endpoint that must be exactly-once
POST /api/v1/orders
Headers: Idempotency-Key: 7f1c... (client-generated UUID, required)
{ cart_id, quote_id, address_id, payment_method_id, instructions? }
-> 201 { order_id, state: "CREATED", eta_minutes, total_minor,
payment: { status: "REQUIRES_ACTION", client_secret } }
409 QUOTE_EXPIRED | 409 ITEM_UNAVAILABLE | 402 PAYMENT_DECLINED
429 + Retry-After | 503 during load shedding
# A retry with the same Idempotency-Key returns the SAME 201 body.
# It never creates a second order and never charges twice.
GET /api/v1/orders/{id} -> 200 Order (full state + courier summary)
GET /api/v1/orders/{id}/events -> 200 { events: [...] } # audit timeline
POST /api/v1/orders/{id}/cancel { reason } -> 200 | 409 TOO_LATE_TO_CANCEL
## Live tracking — push, never poll at 200k clients
WS /api/v1/orders/{id}/track # subscribe; server pushes:
{ type: "state", state: "PICKED_UP", at: "..." }
{ type: "courier_position", lat, lng, bearing, eta_minutes }
# Fallback for restricted networks: SSE, then long-poll every 5s.
## Restaurant-facing
WS /api/v1/partner/orders/stream # new orders pushed to the tablet
POST /api/v1/partner/orders/{id}/accept { prep_minutes } -> 200 | 409
POST /api/v1/partner/orders/{id}/reject { reason } -> 200 | 409
POST /api/v1/partner/orders/{id}/ready -> 200 | 409
PATCH /api/v1/partner/menu/items/{id} { is_available } -> 200
## Courier-facing
POST /api/v1/courier/location { lat, lng, bearing, accuracy_m, ts }
-> 204 # batched: the app sends up to 5 samples per call, fire-and-forget
POST /api/v1/courier/offers/{offer_id}/accept -> 200 { order } | 409 TAKEN
POST /api/v1/courier/orders/{id}/picked_up -> 200 | 409
POST /api/v1/courier/orders/{id}/delivered { proof? } -> 200 | 409Five API decisions worth defending
- Money in minor units as integers (
total_minor: 48950). Floats and money never mix. - The server quotes the price, signs it, and replays it. Otherwise a modified client sets its own delivery fee.
Idempotency-Keyis required, not optional, on order creation. Mobile networks retry; you must not.- 409 with a machine-readable code for every lifecycle conflict, so the app can show the right message instead of "something went wrong".
- Cursor pagination everywhere. The feed changes constantly; offsets would duplicate and skip rows.
Step 4 — Data model
Different stores for the three workloads. Each service owns its tables; nobody reaches into another service's database.
-- =========================================================
-- CATALOGUE (Postgres, source of truth; read path is cached)
-- =========================================================
restaurants (
id BIGINT PRIMARY KEY,
name TEXT NOT NULL,
city_id INT NOT NULL, -- the natural shard / partition key
lat DOUBLE PRECISION NOT NULL,
lng DOUBLE PRECISION NOT NULL,
geohash6 CHAR(6) NOT NULL, -- ~1.2 km cell, for coarse proximity
status SMALLINT NOT NULL, -- 0 offline, 1 live, 2 paused
prep_minutes SMALLINT NOT NULL,
rating_avg NUMERIC(2,1), -- denormalised rollup, eventually consistent
rating_count INT,
menu_version INT NOT NULL -- bump on any menu change -> cache key
);
CREATE INDEX ON restaurants (city_id, status);
CREATE INDEX ON restaurants (geohash6) WHERE status = 1;
menu_items (
id BIGINT PRIMARY KEY,
restaurant_id BIGINT NOT NULL REFERENCES restaurants(id),
category TEXT NOT NULL,
name TEXT NOT NULL,
price_minor INT NOT NULL,
is_available BOOLEAN NOT NULL DEFAULT TRUE, -- toggled hundreds of times/day
tags TEXT[] -- veg, spicy, bestseller
);
CREATE INDEX ON menu_items (restaurant_id) INCLUDE (name, price_minor);
-- =========================================================
-- ORDERS (Postgres, strongly consistent, the money path)
-- =========================================================
orders (
id BIGINT PRIMARY KEY, -- Snowflake: time-sortable (lesson 7)
customer_id BIGINT NOT NULL,
restaurant_id BIGINT NOT NULL,
city_id INT NOT NULL, -- shard key when we shard
courier_id BIGINT NULL,
state TEXT NOT NULL, -- see the state machine below
version INT NOT NULL DEFAULT 0, -- optimistic concurrency control
-- money, captured at order time and never recomputed:
subtotal_minor INT NOT NULL,
delivery_fee_minor INT NOT NULL,
surge_fee_minor INT NOT NULL DEFAULT 0,
taxes_minor INT NOT NULL,
discount_minor INT NOT NULL DEFAULT 0,
total_minor INT NOT NULL,
payment_id TEXT NULL,
delivery_address JSONB NOT NULL, -- SNAPSHOT, not a foreign key:
placed_at TIMESTAMPTZ NOT NULL, -- editing your address later must
promised_at TIMESTAMPTZ NOT NULL, -- not rewrite last month's order
delivered_at TIMESTAMPTZ NULL
);
CREATE INDEX ON orders (customer_id, placed_at DESC); -- "my orders"
CREATE INDEX ON orders (restaurant_id, state); -- partner dashboard
CREATE INDEX ON orders (state) WHERE state IN ('PAID','ACCEPTED','PREPARING','READY');
order_items ( -- price snapshot per line
order_id BIGINT NOT NULL REFERENCES orders(id),
menu_item_id BIGINT NOT NULL,
name_snapshot TEXT NOT NULL,
qty SMALLINT NOT NULL,
unit_price_minor INT NOT NULL,
customisations JSONB,
PRIMARY KEY (order_id, menu_item_id, customisations)
);
order_events ( -- append-only audit log
order_id BIGINT NOT NULL,
seq INT NOT NULL,
from_state TEXT, to_state TEXT NOT NULL,
actor TEXT NOT NULL, -- customer | restaurant | courier | system
payload JSONB, at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (order_id, seq)
);
idempotency_keys ( -- the anti-double-charge table
key TEXT PRIMARY KEY,
user_id BIGINT NOT NULL,
request_hash TEXT NOT NULL, -- reject key reuse with a new body
state TEXT NOT NULL, -- IN_PROGRESS | DONE
response JSONB NULL,
created_at TIMESTAMPTZ NOT NULL -- TTL sweep after 24-48h
);
outbox ( -- transactional outbox pattern
id BIGSERIAL PRIMARY KEY, topic TEXT NOT NULL,
key TEXT NOT NULL, payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL, published_at TIMESTAMPTZ NULL
);
CREATE INDEX ON outbox (id) WHERE published_at IS NULL;
-- =========================================================
-- LIVE STATE (Redis — ephemeral, high churn, never the source of truth)
-- =========================================================
GEOADD couriers:city:42 <lng> <lat> courier:9001 -- sorted-set geo index
HSET courier:9001 state ONLINE order_id 0 battery 61 ts 1737
SETEX track:order:88231 30 '{"lat":..,"lng":..,"eta":7}'
SETEX menu:rest:1234:v412 600 '<serialised menu json>'
SETEX feed:geo:tdr1y:v3 60 '<serialised feed page>'
INCR inv:rest:1234:item:77 -- stock counters
SET offer:lock:88231 courier:9001 NX EX 25 -- dispatch lock
-- =========================================================
-- SEARCH (Elasticsearch, fed by CDC — eventually consistent, ~1s behind)
-- =========================================================
doc: { restaurant_id, name, city_id, geo_point, cuisines[], dish_names[],
rating_avg, is_open, price_band, popularity_score }
-- =========================================================
-- ANALYTICS (Kafka -> columnar warehouse; downsampled location history)
-- =========================================================
topics: order.events, courier.pings, search.queries, payment.eventsFour modelling decisions to explain
- The address and item prices are snapshots on the order, not foreign keys. An order is a historical financial record. If a customer edits their address, last month's receipt must not change. This is the single most common schema mistake in this interview.
city_idis on every row even though it is derivable. It is the future shard key, and putting it in from day one is what makes sharding a migration rather than a rewrite.- Current courier position lives in Redis, history in Kafka. 25k writes/s of "where is courier 9001" is an overwrite of one key, not 31 TB/year of rows in your OLTP database.
- Search is a separate store fed by CDC. Elasticsearch gives you typo tolerance, dish-level matching and ranking; Postgres is the source of truth. They are allowed to be a second out of step.
Step 5 — High-level design
auth, rate limit, routing] RES[Restaurant tablet] --> GW COU[Courier app] --> GW GW --> DISC[Discovery service] GW --> ORD[Order service] GW --> CART[Cart / pricing service] GW --> LOC[Location ingest service] GW --> WS[Realtime gateway
WebSocket / SSE] DISC --> RC[(Redis: feed + menu cache)] DISC --> ES[(Elasticsearch)] DISC --> CAT[(Postgres: catalogue)] CART --> PRICE[Pricing + surge engine] ORD --> ODB[(Postgres: orders
primary + replicas)] ORD --> PAY[Payment service] --> PSP[External PSP] ORD --> OUT[[Outbox publisher]] OUT --> K[(Kafka)] LOC --> GEO[(Redis GEO: live positions)] LOC --> K K --> DISP[Dispatch service] K --> NOTIF[Notification service] K --> ETA[ETA / ML service] K --> WH[(Warehouse / analytics)] K --> WS DISP --> GEO DISP --> ODB CAT -->|CDC| ES
Three read/write characters, three storage choices. Kafka is the spine: the order service writes one outbox row, and dispatch, notifications, ETA, analytics and the realtime gateway all react independently.
What each service owns
- API gateway — TLS, auth, per-user and per-IP rate limits, request validation, routing, load shedding.
- Discovery — the feed and search. Never writes. Serves precomputed
RestaurantCarddocuments out of Redis; falls back to Elasticsearch, then Postgres. - Cart & pricing — validates availability, computes the signed quote (subtotal, distance fee, surge, taxes, promos).
- Order service — the only writer of
orders. Owns the state machine, idempotency and the outbox. Strongly consistent, reads from the primary. - Payment service — wraps the external PSP, owns retries and refunds, and is itself idempotent per order.
- Location ingest — absorbs 25k pings/s, writes Redis GEO and produces to Kafka. Deliberately dumb and horizontally scalable.
- Dispatch — consumes
order.ready_for_dispatch, finds candidate couriers, makes offers, handles acceptance races. - Realtime gateway — holds the 208k customer sockets and the partner tablet streams; stateful, sticky, deployed separately.
- Notification, ETA, analytics — pure Kafka consumers. If they are down, ordering still works.
Step 6 — Walk one order end to end
The synchronous path is short: claim the key, verify the quote, write the order, authorise the card, return. Everything else — tablets, dispatch, notifications, ETA, analytics — hangs off Kafka.
The order of operations, and why
- Claim the idempotency key first. A unique insert is the cheapest possible mutual exclusion. If it conflicts, this is a retry: return the stored response, or
409 IN_PROGRESSif the first attempt has not finished. - Verify the signed quote before touching money. Expired or tampered →
409 QUOTE_EXPIRED, and the client re-quotes. - Reserve stock and insert the order in one transaction, with the outbox row. Same transaction means the event cannot be lost and cannot be published for an order that rolled back.
- Authorise the payment after the order row exists. If you charge first and then fail to write, you have taken money with no record — the worst outcome in the system. An order in
CREATEDwith no payment is recoverable by a sweeper; money with no order is a support ticket. - Move to
PAIDwith a conditional update.WHERE state='CREATED'means a duplicate webhook or a retried worker cannot advance it twice. - Return, then let the log do the rest. The customer does not wait for a courier to be found, and dispatch is deliberately delayed until near
ready_at— assigning at order time wastes courier minutes standing in a kitchen.
Step 7 — Low-level design: the order service
This is where the HLD becomes code. The state machine and the idempotent write are the two things worth writing out in full.
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
class State(str, Enum):
CREATED = "CREATED"; PAID = "PAID"; ACCEPTED = "ACCEPTED"
PREPARING = "PREPARING"; READY = "READY"; PICKED_UP = "PICKED_UP"
DELIVERED = "DELIVERED"; CANCELLED = "CANCELLED"; REFUNDED = "REFUNDED"
# One table is the whole lifecycle rule set. A new rule edits this, not ten methods.
ALLOWED: dict[State, set[State]] = {
State.CREATED: {State.PAID, State.CANCELLED},
State.PAID: {State.ACCEPTED, State.REFUNDED, State.CANCELLED},
State.ACCEPTED: {State.PREPARING, State.REFUNDED},
State.PREPARING: {State.READY, State.REFUNDED},
State.READY: {State.PICKED_UP},
State.PICKED_UP: {State.DELIVERED},
State.DELIVERED: set(), State.CANCELLED: set(), State.REFUNDED: set(),
}
# Who may cause which transition — authorisation is data, not scattered checks.
ACTOR_RIGHTS = {
"customer": {State.CANCELLED},
"restaurant": {State.ACCEPTED, State.PREPARING, State.READY, State.REFUNDED},
"courier": {State.PICKED_UP, State.DELIVERED},
"system": set(State),
}
class IllegalTransition(Exception): pass
class Unauthorised(Exception): pass
class ConcurrentUpdate(Exception): pass
class DuplicateRequest(Exception):
def __init__(self, stored_response): self.stored_response = stored_response
@dataclass
class Order:
id: int
customer_id: int
restaurant_id: int
city_id: int
total_minor: int
state: State = State.CREATED
version: int = 0
courier_id: int | None = None
events: list[tuple] = field(default_factory=list)
def can_transition(self, target: State, actor: str) -> None:
if target not in ACTOR_RIGHTS.get(actor, set()):
raise Unauthorised(f"{actor} may not set {target}")
if target not in ALLOWED[self.state]:
raise IllegalTransition(f"order {self.id}: {self.state} -> {target}")
# ---- ports (interfaces). The domain never mentions SQL, Redis or Stripe. ----
class OrderRepository(ABC):
@abstractmethod
def claim_idempotency_key(self, key: str, request_hash: str) -> dict | None:
"""Returns None if claimed by us; the stored response if it is a retry."""
@abstractmethod
def save_new(self, order: Order, outbox_event: dict) -> None:
"""ONE transaction: order + items + stock reservation + outbox row."""
@abstractmethod
def compare_and_set_state(self, order_id: int, expect: State,
target: State, expect_version: int) -> bool:
"""UPDATE ... WHERE id=? AND state=? AND version=? -> rowcount == 1"""
@abstractmethod
def finish_idempotency(self, key: str, response: dict) -> None: ...
class PaymentGateway(ABC):
@abstractmethod
def authorise(self, order_id: int, amount_minor: int, method_id: str,
idempotency_key: str) -> str: ...
@abstractmethod
def void(self, payment_id: str) -> None: ...
class QuoteVerifier(ABC):
@abstractmethod
def verify(self, quote_id: str, cart_id: str) -> dict: ...
# ---- the application service ----
class OrderService:
def __init__(self, repo: OrderRepository, payments: PaymentGateway,
quotes: QuoteVerifier, ids):
self._repo, self._payments, self._quotes, self._ids = repo, payments, quotes, ids
def place(self, cmd: dict, idempotency_key: str) -> dict:
# 1. Exactly-once, before anything with a side effect.
stored = self._repo.claim_idempotency_key(idempotency_key, hash_of(cmd))
if stored is not None:
raise DuplicateRequest(stored) # handler returns 201 + this body
# 2. Price is the server's, replayed from a signed quote.
quote = self._quotes.verify(cmd["quote_id"], cmd["cart_id"])
order = Order(id=self._ids.next(), # Snowflake: sortable, shard-friendly
customer_id=cmd["customer_id"],
restaurant_id=quote["restaurant_id"],
city_id=quote["city_id"],
total_minor=quote["total_minor"])
# 3. Order row, stock reservation and outbox event commit together or not at all.
self._repo.save_new(order, outbox_event={
"topic": "order.created", "key": str(order.id),
"payload": {"order_id": order.id, "restaurant_id": order.restaurant_id},
})
# 4. Money last, and idempotent at the PSP too.
try:
payment_id = self._payments.authorise(
order.id, order.total_minor, cmd["payment_method_id"], idempotency_key)
except Exception:
self.transition(order, State.CANCELLED, actor="system",
reason="payment_failed") # releases the stock reservation
raise
# 5. Conditional write: a retried worker cannot pay twice or skip a state.
if not self._repo.compare_and_set_state(order.id, State.CREATED,
State.PAID, order.version):
self._payments.void(payment_id) # someone else moved it; undo
raise ConcurrentUpdate(order.id)
order.state, order.version = State.PAID, order.version + 1
response = {"order_id": order.id, "state": order.state,
"total_minor": order.total_minor}
self._repo.finish_idempotency(idempotency_key, response)
return response
def transition(self, order: Order, target: State, actor: str, **meta) -> Order:
order.can_transition(target, actor) # pure, unit-testable, no I/O
if not self._repo.compare_and_set_state(order.id, order.state,
target, order.version):
raise ConcurrentUpdate(order.id) # lost the race: re-read and retry
order.events.append((order.state, target, actor, meta))
order.state, order.version = target, order.version + 1
return order
def hash_of(cmd: dict) -> str:
import hashlib, json
return hashlib.sha256(json.dumps(cmd, sort_keys=True).encode()).hexdigest()
# Demo with the pure domain object only — no database needed, which is the point.
o = Order(id=88231, customer_id=7, restaurant_id=1234, city_id=42, total_minor=48950)
print(o.state)
for actor, target in (("system", State.PAID), ("restaurant", State.ACCEPTED),
("restaurant", State.PREPARING), ("restaurant", State.READY)):
o.can_transition(target, actor); o.state = target
print(o.state)
for actor, target in (("customer", State.CANCELLED), ("courier", State.DELIVERED)):
try:
o.can_transition(target, actor)
except (IllegalTransition, Unauthorised) as e:
print(type(e).__name__, "-", e)State.CREATED State.READY IllegalTransition - order 88231: State.READY -> State.CANCELLED IllegalTransition - order 88231: State.READY -> State.DELIVERED
The LLD points an interviewer is listening for
- The domain is pure.
Order.can_transitiondoes no I/O, so every lifecycle rule is unit-tested in microseconds. All infrastructure sits behind three interfaces (repository, payment gateway, quote verifier). - Authorisation is a table, not scattered
ifs. "Couriers may not cancel" is one line of data. - Every state change is a compare-and-set. Two servers processing the same webhook is not a rare edge case — it is Tuesday.
rowcount == 1is your distributed lock. - Idempotency is claimed before, and completed after. The
IN_PROGRESSstate is what stops a fast double-tap from running the body twice. - The compensating action is explicit. Payment succeeded but the state write lost the race →
void(). That is a saga, written out.
Step 8 — Dispatch and geospatial matching
The hardest subsystem, and the usual deep-dive request.
Indexing 100k moving couriers
You need "couriers within 3 km of this restaurant, online, unassigned", recomputed constantly. A WHERE clause over lat/lng in Postgres cannot do this at 25k writes/s.
- Geohash interleaves latitude and longitude bits into one string, so nearby points share a prefix —
tdr1yis roughly a 5 km cell,tdr1ykabout 1 km. Proximity becomes a prefix scan on a normal index. The catch: two points either side of a cell boundary can be metres apart with different prefixes, so always query the cell and its eight neighbours. - Redis GEO (
GEOADD/GEOSEARCH) is geohash-backed sorted sets and is the pragmatic answer here: one key per city, sub-millisecond radius queries, and a position update is a single O(log N) write. - Quadtree subdivides dense areas more finely — better for wildly uneven density, more complex, and it must be rebuilt or rebalanced.
- S2 cells (Google) project onto a Hilbert curve, giving better locality than geohash and clean multi-resolution covering. Used by Uber-scale systems.
Choose Redis GEO keyed per city, and say why: cities are independent, each key stays small, a city outage is contained, and it scales by adding cities rather than resharding.
The assignment problem
Dispatch is not "nearest courier wins". It is a ranking with a lock, and a global optimisation you deliberately approximate.
from dataclasses import dataclass
@dataclass
class Courier:
id: int; distance_km: float; rating: float
batch_compatible: bool # already heading to the same area
idle_minutes: float # fairness: nudge work to whoever has waited longest
accept_rate: float # likelihood they take the offer
def score(c: Courier, weights: dict) -> float:
"""Higher is better. Every term is a product decision, so keep them named
and weighted rather than buried in one expression."""
return (
weights["proximity"] * (1.0 / (1.0 + c.distance_km))
+ weights["quality"] * (c.rating / 5.0)
+ weights["batching"] * (1.0 if c.batch_compatible else 0.0)
+ weights["fairness"] * min(c.idle_minutes / 30.0, 1.0)
+ weights["accept"] * c.accept_rate
)
WEIGHTS = {"proximity": 0.45, "quality": 0.10, "batching": 0.20,
"fairness": 0.15, "accept": 0.10}
candidates = [
Courier(901, 0.4, 4.9, False, 2, 0.95),
Courier(902, 1.1, 4.6, True, 12, 0.80),
Courier(903, 2.6, 4.8, False, 28, 0.60),
]
for c in sorted(candidates, key=lambda c: -score(c, WEIGHTS)):
print(c.id, round(score(c, WEIGHTS), 3))902 0.646 901 0.524 903 0.421
Note that the nearest courier (901, 400 m away) loses to 902, who is 1.1 km away but already delivering in that direction. That is the batching win, and it is the kind of trade-off worth narrating.
The acceptance race
Offer one order to several couriers and two may accept. Resolve it with an atomic claim: SET offer:lock:<order_id> courier:<id> NX EX 25 in Redis, then the conditional UPDATE orders SET courier_id=? WHERE id=? AND courier_id IS NULL as the durable arbiter. The loser gets 409 TAKEN immediately and goes back in the pool. If nobody accepts before the lock expires, widen the radius, raise the incentive, and retry — with a hard cap before escalating to a human queue.
Dispatch timing
Assign at ready_at − travel_time_to_restaurant, not at order time. Too early wastes courier minutes idling in a kitchen; too late means cold food and a broken promise. This is an ETA-prediction problem (a model over historical prep times, traffic, weather and current queue depth) wrapped in a scheduler — which is exactly why ETA is its own service on the diagram.
Step 9 — Scaling each layer
Browse and search (55k QPS peak)
Three caching tiers, and the read model is precomputed:
- CDN for images and menu photos — the overwhelming majority of bytes, none of them dynamic.
- Redis feed cache keyed by
geo:<geohash6>:<filters>:<version>with a 30–60 s TTL plus jitter. Everyone within a ~1 km cell shares one cached page, which is what collapses 55k requests into a few thousand distinct keys per city. - Menu cache keyed by
menu:rest:{id}:v{menu_version}. Because the version is in the key, an edit never needs an invalidation — the new key is simply a miss, and the old one ages out. This is the trick worth stating explicitly; explicit cache invalidation across regions is where staleness bugs live. - Item availability is the exception: toggling a dish out of stock must be visible in seconds, so it is a small separate key with a 5 s TTL (or pushed over the socket), rather than busting the whole menu document.
- Postgres read replicas for catalogue reads; the primary only takes partner edits.
Orders (580/s peak)
One primary with replicas is enough — say so, and say what would change your mind. Escalation path, in order: composite indexes → move "my orders" history reads to replicas (a few hundred ms of lag is invisible) → partition orders by month so the hot partition stays small and archival is a detach → only then shard by city_id. City sharding works because an order never spans cities, so there are no cross-shard transactions; route with a directory service so a huge city can get its own shard. Cap it with an archival tier: orders older than 90 days move to cold storage, and the OLTP database stays small enough to fit in memory.
Courier locations (25k writes/s)
Batch 5 samples per HTTP call to cut request overhead 5x. Write only to Redis GEO plus Kafka — never the OLTP store. Shard Redis by city. Drop samples with poor GPS accuracy at the edge, and downsample history to one point every 30 s before it reaches the warehouse, which turns 31 TB/year into something like 4 TB. Compress the stream, and set a short retention on raw pings.
Live tracking (208k sockets)
A dedicated stateful connection tier, separate from the API fleet, with sticky routing. Each node registers which order ids it holds in Redis so the Kafka consumer knows where to push. Coalesce updates: send the courier position at most every 3 s even if pings arrive every second — the map cannot render faster and the customer cannot tell. Fall back to SSE and then 5-second polling on restricted networks. Shed tracking before you shed ordering.
Hot spots to call out
- A viral restaurant. One key gets thousands of reads per second. Replicate that key across cache nodes and add a 2-second in-process cache in every app server.
- A dense downtown cell. One geohash cell holds 5,000 couriers. Use a finer resolution in dense cells, and cap candidate scans (take the nearest 50, not all of them).
- A promo at 7 p.m. A coordinated spike on one restaurant's inventory row. Serialise decrements through Redis counters with a periodic reconciliation, rather than contending on one Postgres row.
- New Year's Eve. 10x the normal peak. Pre-scale on schedule, queue non-critical work, and be ready to disable recommendations, batching optimisation and ratings entirely.
Step 10 — Consistency, decided per feature
| Data | Guarantee | Why |
|---|---|---|
| Payment and order state | Strong (single-primary, CP) | Double charges are unacceptable; read from the primary |
| Item stock | Strong-ish (Redis counter + reconciliation) | Overselling one biryani is recoverable; a locked row at 7 p.m. is not |
| Courier assignment | Strong (atomic claim) | Two couriers for one order is a real-world mess |
| Menu and prices | Eventual, seconds (versioned cache) | The signed quote protects the customer from a mid-checkout change |
| Item availability | Eventual, ~5 s | Fast enough; a stale "available" becomes a restaurant rejection and refund |
| Feed and search ranking | Eventual, ~1 min | Nobody notices; the cache hit rate is worth far more |
| Courier position | Eventual, ~3–5 s | The map is an approximation by nature |
| Ratings and analytics | Eventual, minutes | Rollups; never on the request path |
Being able to produce this table is the difference between "it depends" and a designed system. Notice it is not one answer — it is eight.
Step 11 — Failure modes and graceful degradation
| What fails | What the customer sees | Mechanism |
|---|---|---|
| Payment provider down | "Pay on delivery" offered, or a clear retry | Circuit breaker on the PSP; a secondary PSP; order held in CREATED and swept |
| PSP times out (unknown state) | Nothing unusual | Never retry blind: reconcile by idempotency key, then void or capture |
| Redis cache cluster down | Slower feed | Fall through to replicas, with a per-key lock so 55k requests do not stampede |
| Elasticsearch down | Search degrades to name prefix matching | Fall back to Postgres ILIKE on a trigram index; keep browse working |
| Kafka down | Orders still place; tracking lags | Outbox rows accumulate durably in Postgres and drain on recovery |
| Dispatch service down | "Finding your courier" for longer | Orders queue; on recovery, oldest-first with a backlog drain plan |
| Restaurant never responds | Auto-cancel and full refund after 10 min | A scheduled timeout job per order, not a thread sleeping somewhere |
| Courier goes offline mid-delivery | Reassignment, or support contact | Heartbeat timeout → dispatch re-offers from the courier's last position |
| One availability zone lost | Nothing | Replicas and stateless tiers spread across three AZs |
| A region lost | That region's cities are down | Honest answer: orders are local, so cross-region failover means promoting a warm standby with an RPO of seconds. Do not claim active-active for payments. |
| Traffic 10x above plan | Recommendations and ratings disappear | Load shedding by priority: place-order > track > browse > recommend > analytics |
Two things to add unprompted: a reconciliation job that compares PSP charges against orders every few minutes and flags mismatches (the safety net under every payment design), and timeouts as scheduled jobs rather than in-process timers, so a deploy does not lose them.
Step 12 — Observability and rollout
- Business SLIs, not just technical ones: order success rate, time-to-courier-assignment, promise-kept rate (delivered before
promised_at), restaurant rejection rate, refund rate. These catch failures that a 200-response-rate dashboard shows as perfectly healthy. - Technical SLIs: p50/p95/p99 per endpoint, cache hit rate per key family, replication lag, Kafka consumer lag, outbox backlog age, WebSocket churn.
- Distributed tracing with the trace id propagated from the mobile app through gateway, order, payment and Kafka — the only practical way to debug "why did this one order take 90 seconds".
- Alert on symptoms, not causes: page on "order success rate below 99.5% for 5 minutes", not on "CPU above 80%".
- Rollout: feature flags per city, canary on one small city before the metro, and the ability to turn off batching, surge or recommendations independently. City-level scoping is the natural blast radius for a system that is geographically partitioned anyway.
The trade-off summary — how to close
Spend the last two minutes here. This is the part interviewers grade hardest.
- Postgres for orders, not Cassandra. 580 writes/s does not need a distributed store, and we do need transactions across order, items, stock and outbox. Revisit at roughly 10k writes/s or when one city dominates the box.
- Redis for live positions, not the OLTP database. Trading durability for throughput on data that is worthless in 10 seconds is the correct trade.
- Precomputed feed documents, not joins at read time. We pay with staleness (up to a minute) and a denormalisation pipeline to keep in step. Fine for browse; deliberately not applied to price or stock.
- Kafka as the spine, not synchronous service calls. Ordering keeps working when notifications, ETA, dispatch or analytics fail. We pay with eventual consistency and duplicate handling everywhere downstream — which is why every consumer is idempotent.
- Sharding by city, deferred. The key is on every row from day one; the shard is added when a number demands it.
- CP for money, AP for everything else. Stated per feature, in the table above.
- What I would build first: one service, Postgres, Redis, one city. The architecture above is what it grows into, not where it starts.
// Write your solution here
Finished reading? Mark this lesson complete to track your progress.
