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")
Output
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 | 409

Five 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-Key is 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.events

Four 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_id is 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

flowchart TB CUS[Customer app] --> CDN[CDN: images, static] CUS --> GW[API gateway
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 RestaurantCard documents 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

sequenceDiagram participant C as Customer app participant G as Gateway participant O as Order service participant D as Orders DB participant P as Payment participant K as Kafka participant X as Dispatch participant R as Restaurant C->>G: POST /orders (Idempotency-Key, quote_id) G->>O: place(cmd) O->>D: INSERT idempotency_keys(key) — unique insert claims the request O->>O: verify quote signature + expiry O->>D: TX — reserve stock + INSERT order CREATED + INSERT outbox O->>P: authorise(total, idempotency_key) P-->>O: authorised O->>D: UPDATE orders SET state='PAID' WHERE id=? AND state='CREATED' O->>D: store idempotency response (DONE) O-->>C: 201 {order_id, state: PAID} D->>K: outbox publisher -> order.placed K->>R: push to partner tablet R-->>O: accept(prep_minutes=18) O->>D: state PAID -> ACCEPTED (conditional) D->>K: order.accepted K->>X: schedule dispatch at ready_at - travel_time X->>X: rank nearby couriers, offer with 25s lock X-->>O: courier assigned O->>K: order.courier_assigned -> notify customer socket K->>C: WebSocket: state updates every step

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

  1. 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_PROGRESS if the first attempt has not finished.
  2. Verify the signed quote before touching money. Expired or tampered → 409 QUOTE_EXPIRED, and the client re-quotes.
  3. 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.
  4. 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 CREATED with no payment is recoverable by a sweeper; money with no order is a support ticket.
  5. Move to PAID with a conditional update. WHERE state='CREATED' means a duplicate webhook or a retried worker cannot advance it twice.
  6. 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)
Output
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_transition does 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 == 1 is your distributed lock.
  • Idempotency is claimed before, and completed after. The IN_PROGRESS state 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 — tdr1y is roughly a 5 km cell, tdr1yk about 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))
Output
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

DataGuaranteeWhy
Payment and order stateStrong (single-primary, CP)Double charges are unacceptable; read from the primary
Item stockStrong-ish (Redis counter + reconciliation)Overselling one biryani is recoverable; a locked row at 7 p.m. is not
Courier assignmentStrong (atomic claim)Two couriers for one order is a real-world mess
Menu and pricesEventual, seconds (versioned cache)The signed quote protects the customer from a mid-checkout change
Item availabilityEventual, ~5 sFast enough; a stale "available" becomes a restaurant rejection and refund
Feed and search rankingEventual, ~1 minNobody notices; the cache hit rate is worth far more
Courier positionEventual, ~3–5 sThe map is an approximation by nature
Ratings and analyticsEventual, minutesRollups; 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 failsWhat the customer seesMechanism
Payment provider down"Pay on delivery" offered, or a clear retryCircuit breaker on the PSP; a secondary PSP; order held in CREATED and swept
PSP times out (unknown state)Nothing unusualNever retry blind: reconcile by idempotency key, then void or capture
Redis cache cluster downSlower feedFall through to replicas, with a per-key lock so 55k requests do not stampede
Elasticsearch downSearch degrades to name prefix matchingFall back to Postgres ILIKE on a trigram index; keep browse working
Kafka downOrders still place; tracking lagsOutbox rows accumulate durably in Postgres and drain on recovery
Dispatch service down"Finding your courier" for longerOrders queue; on recovery, oldest-first with a backlog drain plan
Restaurant never respondsAuto-cancel and full refund after 10 minA scheduled timeout job per order, not a thread sleeping somewhere
Courier goes offline mid-deliveryReassignment, or support contactHeartbeat timeout → dispatch re-offers from the courier's last position
One availability zone lostNothingReplicas and stateless tiers spread across three AZs
A region lostThat region's cities are downHonest 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 planRecommendations and ratings disappearLoad 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.

Last lessonFinish System DesignMark this lesson complete and pick your next course.