🍔 Food Delivery Platform — High-Level Design¶
Focus: Order lifecycle as a saga across payment, order and dispatch; real-time partner location ingestion; geo-sharded dispatch; peak-hour surge
1. Scope and numbers¶
Functional: browse restaurants/menus, cart, checkout, pay, restaurant accept/prepare, partner assignment and tracking, notifications, cancellations/refunds.
Back-of-envelope (large Indian metro-scale platform):
| Quantity | Estimate | Derivation |
|---|---|---|
| Orders/day | 3 M | |
| Peak hour share | ~15% of the day | dinner peak 8–9 pm |
| Peak order rate | ~125/s average in the peak hour, ~400/s bursts | 450 k / 3600 s; ×3 for minute-level spikes |
| Online partners at peak | 300 k | |
| Location pings | 75 k writes/s | 300 k partners / 4 s interval |
| Ping payload | ~100 B → ~7.5 MB/s ingress | |
| Order status events | ~3 M × 8 transitions ≈ 24 M/day, ~1.2 k/s at peak | |
| Menu reads | 100:1 read/write; served from cache/CDN |
The interesting load is location ingestion and dispatch, not order writes.
2. Architecture¶
flowchart LR
subgraph Clients
CA[Customer app]
RA[Restaurant app]
PA[Partner app]
end
GW[API Gateway<br/>auth, rate limit]
CA & RA --> GW
PA -- pings via WebSocket/MQTT --> LI[Location Ingestion]
GW --> CAT[Catalogue / Menu svc]
GW --> CART[Cart + Pricing svc]
GW --> ORD[Order svc<br/>state machine, saga orchestrator]
ORD --> PAY[Payment svc]
PAY --> PSP[(PSP)]
ORD -- outbox --> K[(Kafka)]
K --> DIS[Dispatch svc<br/>geo-sharded]
LI --> K
LI --> GEO[(Redis geo index<br/>per cell)]
DIS --> GEO
DIS -- assignment events --> K
K --> ORD
K --> NOTIF[Notification svc]
K --> TRACK[Tracking svc<br/>live ETA to customer]
K --> SURGE[Surge svc<br/>supply/demand per cell]
SURGE --> CART
ORD --> ODB[(Orders DB<br/>Postgres, sharded by order_id)]
CAT --> CDB[(Catalogue DB + cache)]
Order service vs dispatch service¶
| Order service | Dispatch service | |
|---|---|---|
| Owns | Order state machine, line snapshot, price, payment refs | Partner state (available / assigned / batch), offers, assignment |
| Consistency | Strong per order (row lock or version CAS) | Strong per partner (CAS on partner record) |
| Partitioning | By order_id (or customer) |
By geo cell (H3 res ~7–8 / city zone); one leader per cell |
| Latency budget | p99 < 300 ms for placement | Assignment within ~10–30 s of accept; each attempt < 100 ms |
| Scales with | Order rate | Partner density × order rate per cell |
They are split because they change at different rates, scale on different keys, and dispatch needs an in-memory view of partners that would be wrong to couple to the order DB. They talk through events plus a small number of commands (AssignRequested, PartnerAssigned, AssignmentFailed, UnassignRequested).
3. Location ingestion¶
- Partner app sends a ping every 3–5 s while online (faster when on an active trip), over a long-lived connection to a stateless ingestion fleet.
- Ingestion validates (speed sanity check, drop pings older than the last accepted timestamp), then:
- Hot path: updates the partner's position in the geo index for its cell (Redis
GEOADDor an in-memory H3 bucket map in the dispatch shard), withlast_seen. - Stream: publishes to Kafka
partner-locationskeyed bypartner_id(per-partner ordering) for tracking, ETA models and history (cold store: S3/Parquet). - Liveness: a partner is "online" only if
now - last_seen < 30 s; dispatch filters stale entries, and a sweeper marks them offline. - Cell handover: when a partner crosses a cell boundary, ingestion writes to the new cell and deletes from the old; dispatch queries the target cell plus k-ring neighbours (H3
gridDisk) so boundary partners aren't missed. - 75 k writes/s is comfortable for a sharded Redis (a single node handles ~100 k simple ops/s); shard by cell so a city's hot cells spread across nodes.
4. The placement saga (payment + order + dispatch)¶
No distributed transaction: each step is a local transaction plus an event, with compensations. The order service orchestrates (orchestration beats choreography here because the business wants one place that knows an order's state).
sequenceDiagram
participant C as Customer app
participant O as Order svc
participant P as Payment svc
participant R as Restaurant app
participant D as Dispatch svc
C->>O: POST /orders (Idempotency-Key)
O->>O: insert order PENDING_PAYMENT (unique customer_id+key)
O->>P: authorize(amount, key)
P-->>O: authorized(payment_id)
O->>O: PENDING_PAYMENT → PLACED + outbox(OrderPlaced)
O-->>C: 201 order
O-->>R: OrderPlaced (via Kafka → push)
R->>O: accept
O->>O: PLACED → ACCEPTED + outbox(AssignRequested)
O-->>D: AssignRequested
D->>D: rank + CAS claim partner
D-->>O: PartnerAssigned
Note over O,D: ... PREPARING, READY, PICKED_UP ...
O->>P: capture on DELIVERED
| Step fails | Compensation |
|---|---|
| Authorization declined | Order → PAYMENT_FAILED (terminal); nothing else ran |
| Restaurant rejects / times out | REJECTED; void the authorization (no money moved) |
| Customer cancels before prep | CANCELLED; void or refund per policy; UnassignRequested to dispatch |
| No partner within N minutes | Keep retrying with widening radius and incentives; after SLA, ops decides: cancel + refund, or restaurant self-delivery |
| Partner drops before pickup | Unassign + re-dispatch excluding them |
| Crash between steps | The outbox and idempotent consumers make every step safely re-drivable; a reconciler scans orders stuck in a state longer than its SLA |
Authorize then capture (rather than charging up front, as the LLD does for simplicity) makes the commonest compensation, rejection, a void instead of a refund: faster for the customer, no fees. UPI-style instant payments don't support auth/capture well, so for those the compensation is a refund.
Idempotency everywhere: the client key dedups placement; the order id dedups payment calls; consumers dedup on (order_id, seq). The transactional outbox guarantees "state changed ⇔ event published" without 2PC.
5. Dispatch at scale¶
- Single writer per cell. Each dispatch shard owns a set of cells and holds partner state in memory, so the assignment CAS is a local operation; durability via a log (Kafka compacted topic of partner state) to rebuild on failover. Cross-cell candidates (k-ring) require a claim RPC to the neighbouring shard, which uses the same CAS.
- Alternative: stateless dispatchers + Redis; the claim is a Lua script
if HGET p state == AVAILABLE then HSET ... end. Simpler ops, one more network hop per attempt, and hot cells contend on Redis. - Greedy vs batched matching. Greedy per order (the LLD) is simple and low-latency. At peak, collecting orders for a few seconds per cell and solving an assignment problem (Hungarian / min-cost flow over ETA) gives materially better global ETAs and enables batching. Most large platforms use the windowed approach.
- Offer, not assign. A partner gets an offer with a ~15–30 s timeout; decline/timeout releases the claim and moves to the next candidate. Track acceptance rate as a ranking feature.
- Assign timing. Dispatch so that partner arrival ≈ food-ready time (predicted prep time). Assigning too early wastes partner time at the restaurant; too late lets food go cold.
6. Peak-hour surge¶
- The surge service consumes order and location streams and computes, per cell per minute, demand (open orders awaiting assignment, checkout sessions) vs supply (idle partners, predicted to free up soon).
- Output is a delivery-fee multiplier with a cap and hysteresis (don't flap every minute), published to the pricing service's cache. The multiplier used is stamped on the quote, and the quote has an id + expiry so the customer is charged what they saw.
- Other levers, usually cheaper than raising fees: partner incentives in hot cells, widening search radius, temporarily hiding far-away restaurants or increasing their displayed ETA, throttling new orders from a restaurant whose kitchen queue is long.
- Platform protection at peak: rate-limit at the gateway, shed non-critical work (recommendations) first, pre-scale on predictable peaks (Friday dinner, cricket finals).
7. Data and consistency choices¶
| Data | Store | Consistency |
|---|---|---|
| Orders, order events | Postgres sharded by order_id (see DB_SCHEMA.md) |
Strong per order; status CAS via version column |
| Idempotency keys | Same DB as orders, unique (customer_id, key), 24 h TTL |
Strong |
| Partner live state | Dispatch shard memory / Redis | Strong per partner (CAS); rebuilt from log |
| Partner location history | Kafka → object storage | Eventual, analytics only |
| Menus | Postgres + Redis/CDN cache | Eventual (seconds); price re-validated at placement |
| Customer order history views | Read model built from events | Eventual |
8. Failure modes¶
| Failure | Effect | Mitigation |
|---|---|---|
| Dispatch shard dies | Cell can't assign | Standby takes over from the compacted log within seconds; orders retry AssignRequested |
| Kafka lag | Notifications/assignments delayed | Alert on consumer lag; assignment path can be a direct RPC with the event as the audit trail |
| PSP timeout | Unknown payment outcome | Retry with the same key; reconcile with PSP webhooks; never create a second auth |
| Duplicate events | Double notifications, double refunds | Consumers dedup on (order_id, seq); refunds keyed on payment_id |
| GPS spoofing / stale pings | Bad assignments | Speed checks, liveness TTL, server-side ETA validation |
| Restaurant tablet offline | Orders never accepted | Auto-reject timer + IVR call fallback; mark restaurant temporarily closed after N misses |
| Thundering herd at peak | Latency spikes | Gateway rate limits, queue placement behind a token bucket per city, autoscale |
9. What I'd monitor¶
Placement success rate and p99; time-to-accept; time-to-assign (p50/p95 per cell); assignment retries per order; partner utilisation; orders stuck per state beyond SLA; surge multiplier per cell; refund rate by reason; outbox lag.