🧠 Food Delivery (Swiggy / Zomato / DoorDash) LLD — Thought Process Guide¶
Goal: Reason your way to a design that survives the three things interviewers push on here: duplicate orders, illegal state changes, and two orders grabbing the same delivery partner.
📊 Class Diagram¶
classDiagram
class OrderService {
+quote(cart, coupon_code) PriceBreakdown
+place_order(cart, idempotency_key, coupon_code) Order
+accept(order_id) DeliveryPartner
+reject(order_id, actor)
+start_preparing(order_id)
+mark_ready(order_id)
+pick_up(order_id, partner_id)
+deliver(order_id, partner_id)
+cancel(order_id, actor)
+dispatch(order_id) DeliveryPartner
+subscribe(observer)
}
class Cart {
+customer: Customer
+add(restaurant, item_id, qty, replace_cart)
+remove(item_id, qty)
}
class Restaurant {
+restaurant_id
+location: Location
+packaging_fee: Decimal
+is_open: bool
+item(item_id) MenuItem
}
class MenuItem {
+item_id
+price: Decimal
+available: bool
}
class Order {
-status: OrderStatus
-partner_id
-seq: int
-lock
+lines: OrderLine[]
+bill: PriceBreakdown
+transition(new, actor, actor_id) OrderEvent
+attach_partner(partner_id) bool
}
class PricingEngine {
+price(ctx) PriceBreakdown
}
class PricingRule {
<<abstract>>
+apply(ctx, bill)
}
class DispatchService {
+register(partner)
+assign(order, restaurant) DeliveryPartner
}
class AssignmentStrategy {
<<abstract>>
+max_orders_per_partner: int
+rank(order, restaurant, snapshots) list
}
class DeliveryPartner {
-active: dict
-picked_up: bool
-lock
+snapshot() PartnerSnapshot
+try_claim(order_id, restaurant_id, max_orders) bool
+mark_picked_up(order_id)
+release(order_id)
}
class PaymentGateway {
<<abstract>>
+charge(customer_id, amount, idempotency_key) str
+refund(payment_id, amount)
}
class OrderObserver {
<<abstract>>
+on_order_event(event)
}
OrderService --> DispatchService
OrderService --> PricingEngine
OrderService --> PaymentGateway
OrderService --> "*" OrderObserver
OrderService --> "*" Order
Cart --> Restaurant
Restaurant --> "*" MenuItem
PricingEngine --> "*" PricingRule
PricingRule <|-- ItemSubtotalRule
PricingRule <|-- PackagingFeeRule
PricingRule <|-- DeliveryFeeRule
PricingRule <|-- SurgeRule
PricingRule <|-- CouponRule
PricingRule <|-- TaxRule
DispatchService --> AssignmentStrategy
DispatchService --> "*" DeliveryPartner
AssignmentStrategy <|-- NearestAvailableStrategy
AssignmentStrategy <|-- BatchingStrategy
OrderObserver <|-- NotificationObserver
PaymentGateway <|-- FakePaymentGateway
⏱️ How to run this in a 45–60 min interview¶
| Minutes | Do | Say out loud |
|---|---|---|
| 0–7 | Clarify (questions below). Write the state machine on the board first. | "The state machine and who may drive each edge is the spine; everything else hangs off it." |
| 7–15 | Entities + interfaces: Restaurant/MenuItem, Cart, Order, DeliveryPartner, and the three seams: AssignmentStrategy, PricingRule, OrderObserver (+ PaymentGateway port). |
"I'll keep pricing, dispatch and notifications behind interfaces because each is a known change axis." |
| 15–35 | Core code: transition table + Order.transition, place_order with idempotency, PricingEngine with 3–4 rules, NearestAvailableStrategy. |
"Money is Decimal, quantised per line. Orders snapshot item prices." |
| 35–45 | Concurrency: per-order lock for transitions; per-partner try_claim compare-and-set; idempotency slot; cancel-vs-dispatch race via attach_partner. |
"Strategy ranks from a snapshot; the claim re-validates. Stale reads cost a retry, never a double-assign." |
| 45–55 | Extension the interviewer picks: batching, surge, auto-reject timeout, reassignment when a partner goes offline. | "Batching is a new strategy plus a capacity argument to the claim; nothing else changes." |
| 55–60 | Tests you'd write, and what changes when this becomes services (see HLD). |
Clarifying questions worth asking¶
- One restaurant per cart? (Yes, nearly universal: one kitchen, one pickup.)
- Prepaid only, or COD too? (Changes whether payment precedes
PLACED.) - Who can cancel, until when, and with what refund? (Drives the actor rules.)
- When is a partner assigned: on placement, on accept, or near "ready"? (Here: on accept, so travel overlaps cooking.)
- Can a partner carry several orders? Same restaurant only? (Batching.)
- Is surge on delivery fee only, or on food too? (Delivery only here; surging food price is a regulatory/PR problem.)
- Scale for the LLD: single process, in-memory, but thread-safe? (Yes.)
Phase 1: Identify the nouns¶
"Customers browse restaurants, fill a cart, place an order; the restaurant accepts and cooks; a delivery partner picks it up and delivers. Customers get notified at each step."
| Noun | Decision | Why |
|---|---|---|
| Restaurant, MenuItem | Classes | Catalogue; MenuItem.available toggles stock-outs |
| Customer, Location | Frozen dataclasses | Value objects |
| Cart | Class | Enforces the single-restaurant invariant |
| OrderLine | Frozen dataclass | Snapshot of name and price at order time |
| Order | Class with a lock | Owns its status, partner and event sequence |
| OrderStatus, Actor | Enums | The vocabulary of the state machine |
| TRANSITIONS | Data, not code | (from, to) -> allowed actors; reviewing policy = reading a table |
| PricingRule / PricingEngine | ABC + pipeline | Fees, surge, coupons and tax change independently |
| DeliveryPartner | Class with a lock | Owns its capacity; the only place a claim happens |
| AssignmentStrategy | ABC | Nearest, batching, ML-scored: swappable |
| PaymentGateway | ABC (port) | Real PSP in production, fake in tests |
| OrderObserver | ABC | Push, SMS, analytics subscribe without touching the order flow |
| OrderService | Facade | Orchestrates; holds no business rules that belong to the entities |
Phase 2: The state machine first¶
stateDiagram-v2
[*] --> PLACED
PLACED --> ACCEPTED: restaurant
PLACED --> REJECTED: restaurant / system timeout
ACCEPTED --> PREPARING: restaurant
PREPARING --> READY: restaurant
READY --> PICKED_UP: assigned partner
PICKED_UP --> DELIVERED: assigned partner
PLACED --> CANCELLED: customer / system
ACCEPTED --> CANCELLED: customer / restaurant / system
PREPARING --> CANCELLED: restaurant / system
READY --> CANCELLED: restaurant / system
PICKED_UP --> CANCELLED: system only
Why these cancel rules: once the kitchen is cooking, a customer cancellation wastes food the platform or restaurant pays for, so the customer loses the button (support can still cancel as SYSTEM, with a partial refund policy). Partners never cancel orders; they unassign, and the order is re-dispatched.
Encoding it as a dict means transition() is three checks under a lock: edge exists, actor allowed, and (for partners) it is the assigned partner.
Phase 3: Money¶
Decimaleverywhere;money()refusesfloatsomoney(0.1)fails loudly.- Quantise each line (half-up) and sum the quantised lines, so the receipt adds up to the total exactly.
- Rule order matters and is explicit: items → packaging → delivery → surge (reads delivery) → coupon (reads items) → tax (on items after discount). A coupon can never exceed the food subtotal; the total is floored at zero.
Phase 4: Idempotent placement¶
The client generates the key when the checkout screen opens and resends it on every retry. The server scopes it per customer and stores a fingerprint of the request:
- same key, same body → return the original order (no second charge);
- same key, different body →
IdempotencyConflictError(client bug); - concurrent duplicates → the first caller owns an in-flight slot, others wait on its
Event; - the first attempt fails (payment declined) → slot removed, so the same key can be retried.
The payment call reuses the key, so even a crash between charge and order insert is safe against a PSP that dedups on it.
Phase 5: Dispatch without double-assignment¶
- Take
snapshot()of every partner (each under that partner's own lock). strategy.rank(...)returns partner ids, best first. Pure function, easy to unit-test.- For each id:
partner.try_claim(order_id, restaurant_id, max_orders), a compare-and-set that re-checks online / capacity / same-restaurant / not-yet-picked-up. order.attach_partner(pid)under the order lock; if the order was cancelled meanwhile, release the claim.
No global lock, and two dispatchers can never hold the same partner beyond capacity. In production step 3 is a conditional update (UPDATE partners SET ... WHERE id=? AND status='AVAILABLE') or a Redis Lua script.
Phase 6: Notifications¶
OrderService._move performs the transition, then (after the order lock is released) releases the partner, refunds if needed, and publishes an OrderEvent to observers. Each event carries a per-order seq, so a consumer receiving events out of order (likely once this is Kafka) can drop stale ones. A failing observer is swallowed so notification problems never fail an order.
Phase 7: Quick checklist¶
✅ State machine as data, with per-actor permissions
✅ Idempotent placement, including concurrent duplicates and retry after failure
✅ Decimal money, explicit rule order, receipt sums exactly
✅ Strategy ranks, partner claims (CAS); cancel-vs-dispatch race closed
✅ Observer decoupled, ordered by seq, failure-isolated
✅ Deterministic demo, unit tests including concurrency