Problem RestatementProblem
Design a delivery marketplace like Uber Eats, DoorDash, or a 10-minute grocery app like Flipkart Minutes. Customers browse nearby restaurants or a nearby dark store (a small warehouse only for delivery), add items to a cart, and pay. Merchants accept and prepare the order. Couriers are assigned to pick it up and deliver it. Everyone tracks the order live on a map.
This has three sides that must stay in sync, plus real-time location, so it combines search, orders, payments and dispatch.
RequirementsRequirements
1.1 Functional
- Browse and search nearby merchants and menus (or dark-store inventory).
- Cart, checkout and payment.
- The merchant accepts or rejects and marks the order ready.
- Assign a courier, then track pickup and delivery live.
- ETAs and notifications at every step.
1.2 Non-Functional
- Order correctness: no lost or double orders, and a correct state everywhere.
- Low latency for browsing and for location updates (a few seconds).
- Peak handling: dinner time is 3–5x the average.
- Inventory accuracy for quick commerce (don't sell items that are out of stock).
1.3 Scale Estimates
- 10M orders/day, peaking at 500 orders/sec at dinner.
- 500K active couriers sending GPS every 4 seconds → 125K location updates/sec.
- Browsing: 100M menu or listing views/day.
1.4 API Design
/v1/merchants?lat=&lng=&q=and GET /v1/merchants/{id}/menu/v1/orders(Idempotency-Key) { merchant_id, items, address, payment_method }/v1/orders/{id}/accept(merchant), POST /v1/orders/{id}/ready/v1/couriers/{id}/location{ lat, lng, ts }/v1/orders/{id}/track(WebSocket or SSE for live updates)High-Level ArchitectureArchitecture
2.1 Overview
- Discovery/Search: finds merchants near the address (geo index) and serves menus from a cache.
- Cart & Order Service: builds the order, prices it, reserves inventory (quick commerce) and runs the order state machine.
- Payment Service: authorizes at checkout and captures on delivery.
- Dispatch Service: picks the best courier using live locations and ETAs.
- Location Service: ingests courier GPS into an in-memory geo index and a stream.
- Tracking/Notification Service: pushes status and courier location to the customer.
2.2 Architecture Diagram
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
CU["Customer app"] --> SR["Discovery / Search"]
CU --> OS["Order Service - state machine"]
OS --> PAY["Payment Service"]
OS --> INV[("Inventory - dark stores")]
OS --> DB[("Orders DB")]
OS --> K[("Order events - Kafka")]
K --> DSP["Dispatch Service"]
CO["Courier app"] -->|"GPS every 4s"| LOC["Location Service"]
LOC --> GEO[("In-memory geo index")]
DSP --> GEO
DSP -->|"offer job"| CO
ME["Merchant tablet"] --> OS
K --> TR["Tracking + Notifications"]
TR --> CUData ModelData model
orders: order_id, customer_id, merchant_id, courier_id, items (JSON), total_cents,
status, address, created_at, eta
order_events: order_id, status, ts, actor -- full history
couriers: courier_id, status (offline/available/on_job), vehicle, current_order_id
inventory: store_id, sku, available_qty, reserved_qty -- quick commerceplaced → accepted → preparing → ready_for_pickup → picked_up → delivered, with side paths cancelled and rejected.
Key FlowsFlows
4.1 Placing an order
- Checkout validates the cart (prices, items available, merchant open).
- For quick commerce, reserve stock:
reserved_qty += nonly ifavailable_qty - reserved_qty >= n, in one atomic update. - Authorize payment, create the order (
placed) and publish an event. - The merchant tablet gets the order and accepts it. If it doesn't accept within ~3 minutes, the order is cancelled and refunded automatically.
4.2 Dispatching a courier
- When the order is accepted, dispatch starts so the courier arrives about when the food is ready (prep time estimate − travel time).
- Find available couriers near the merchant from the geo index, score them (ETA to merchant, current load, ratings), and offer the job to the best one with a 30-second timeout. If declined, offer it to the next.
- Assignment uses a conditional update (
set courier_id only if still null) so an order never gets two couriers.
4.3 Tracking
Courier GPS → Location Service → a stream per active order → pushed to the customer's app every few seconds with an updated ETA.
Deep Dive A — Deciding which courier takes which orderDeep dive
Orders become ready continuously and couriers free up continuously. The matching policy is the single biggest lever on delivery time and courier earnings.
Give each order to the nearest free courier immediately
The moment an order is ready, find the closest available courier and assign it.
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
O1["Order A ready - 12:00:01"] --> N1["Nearest courier: Sam, 200 m"]
N1 --> ASSIGN["Sam assigned to A"]
O2["Order B ready - 12:00:04, same block"] --> N2["Sam is gone"]
N2 --> FAR["Nearest now: Rhea, 2.1 km"]
FAR --> LATE["Order B delivered 14 minutes late"]Each decision is locally optimal and the sequence is not. Greedy assignment spends the nearby couriers on whichever order happened to be ready first, and the order three seconds behind it — from the same restaurant — gets someone across the city. Nobody is choosing that; it falls out of deciding one order at a time.
Wait a few seconds and solve a batch
Collect orders and available couriers over a 10–30 second window per zone, then solve a small assignment problem that minimises total lateness across the whole set.
The extra few seconds buy far more than they cost, because the solver can see that Sam should take B and Rhea should take A. This is the core of the answer. What it still does not use is the fact that one courier can carry more than one order.
Batch, then stack, inside a zone
Add stacking to the batch solve: let one courier carry two orders when the pickup points are close and the detour does not push the first order past its promise.
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
W["Every 10-30 s, per zone"] --> POOL["Ready orders + free couriers"]
POOL --> SOLVE["Assignment solve - minimise total lateness"]
SOLVE --> STACK{"Two orders, same or nearby pickup?"}
STACK -->|"detour under the cap"| PAIR["Stack on one courier"]
STACK -->|"over the cap"| SEP["Separate couriers"]
PAIR --> DISP["Dispatch"]
SEP --> DISPStacking is where the economics live — a courier delivering two orders on one trip roughly halves the cost per delivery — and it is also the easiest thing to get wrong. Cap it by added delay to the first order, not by distance: a second pickup 100 metres away at a restaurant with a 12-minute queue is a worse stack than one 800 metres away that is ready now.
Shard dispatch by city or zone. Orders never cross cities, so this partitions the problem for free and keeps each assignment solve small enough to run in well under the batching window.
Deep Dive B — Quick commerce inventory and failuresDeep dive
- Stock is counted per dark store. Reservations expire if payment fails (e.g., after 10 minutes), which releases stock.
- Pickers scan items when packing. If something is missing, the customer gets an instant substitution or partial refund.
- Failures: the merchant rejects → refund. The courier cancels → re-dispatch at higher priority. The app crashes mid-checkout → the idempotency key prevents double orders. Every state change is an event, so any service can rebuild what happened.
Trade-offs & AlternativesTrade-offs
| Decision | Choice | Why | Alternative |
|---|---|---|---|
| Order consistency | Strongly consistent order DB + state machine | Money and fulfillment must be exact | Eventual NoSQL: risk of double assignment |
| Courier locations | In-memory geo index from a stream | Updates every 4s, instant queries | Write every GPS point to a DB: too slow and costly |
| Matching | Batched assignment per zone | Better global efficiency | Greedy nearest: simple, worse at peaks |
| Integration | Events via Kafka | Loose coupling, replayable | Direct calls everywhere: fragile chains |
Common Follow-up QuestionsFollow-ups
- "How do you compute ETAs?" An ML model trained on past trips (distance, traffic, time of day, restaurant prep history), updated live during delivery.
- "Dinner peak?" Autoscale stateless services, pre-scale before known peaks, and use surge incentives to bring more couriers online.
- "How do you cache menus?" Menus change rarely. Cache them per merchant with invalidation on edit, and serve them via CDN.
Wrap-UpWrap-up
Use a strict order state machine in a consistent DB, emit events for every change, and let separate services handle payment, dispatch, tracking and notifications. Keep courier locations in an in-memory geo index fed by a stream, match couriers in small batches per zone with a conditional assignment, and for quick commerce reserve inventory atomically with expiring holds.