Design a food delivery system (Swiggy, Zomato, DoorDash)¶
TL;DR¶
- You build the three-sided flow: a customer orders, a restaurant accepts and cooks, a courier is offered the job and delivers it.
- Three decisions carry the interview: an order transition table as the single gate on state, a courier offer that is a time-boxed lease (so nobody is double-assigned and a silent courier does not stall the order), and claim-then-act-then-revert across two services so the cancel-versus-accept race has exactly one safe outcome.
- Patterns that earn their place: State (the table), Strategy (courier ranking, coupons), Event Bus plus Observer (notifications), Facade (
FoodDeliveryService), Factory (payment gateways), Null Object (no coupon).
Problem statement¶
"Design a food delivery platform. Customers browse nearby restaurants, build a cart and place an order. The restaurant accepts or rejects it and cooks. A delivery partner is offered the job, accepts, collects the food and delivers it. You take payment, apply coupons, notify all three parties and collect ratings. Focus on the order lifecycle, how a partner gets assigned, and what happens when two things happen at once — two dispatchers picking the same courier, or a customer cancelling while a courier is accepting."
Requirements¶
Functional
- Browse open restaurants near a location, with menus and per-item availability.
- A cart holds items from one restaurant; checkout snapshots prices into the order.
- Coupons: flat off above a minimum spend, capped percentage off, free delivery.
- Payment is authorized at checkout and captured on delivery; a rejection or cancellation voids the authorization.
- The restaurant accepts or rejects, prepares, and marks the food ready.
- A delivery partner is offered the order by a pluggable strategy, and may accept, decline or time out.
- The order walks a state machine from placed to delivered, with cancellation and rejection as terminal branches.
- Notifications to the customer, the restaurant and the partner at every step; ratings after delivery.
Non-functional and constraints
- A courier is never assigned two orders at once, no matter how many dispatchers run.
- An order never sits waiting on a courier who closed the app: every offer expires.
- A cancelled order never leaves a courier believing they are still delivering it.
- In-memory and single-process; the clock and ID generators are injected so every test is deterministic.
Out of scope: real-time GPS tracking on a map, courier batching, surge pricing, restaurant onboarding, and the search index behind "browse".
Clarifying questions and assumptions¶
| Question to ask | Assumption taken here |
|---|---|
| When is the courier assigned — at order time or when the food is ready? | While it cooks. Dispatch starts the moment the restaurant accepts, so the courier arrives as the food does. That is the DoorDash answer and it changes the state machine. |
| Can the customer cancel after a courier accepts? | Yes, until the food is picked up. That is what makes the cancel-versus-accept race real rather than theoretical. |
| Do we charge at checkout or at delivery? | Authorize at checkout, capture on delivery. Two calls, because food can fail to arrive. |
| What happens if a courier ignores the offer? | It expires after 30 seconds and cascades to the next candidate, who is never the one who just passed. |
| Can a cart mix restaurants? | No. One cart, one kitchen — the validation lives in Cart.add. |
| Are menu prices allowed to move between browsing and checkout? | Yes, so the order stores an OrderItem price snapshot. The customer pays what they saw. |
| How is "nearby" computed? | Straight-line distance inside one city. Swapping in a geospatial index changes one strategy class, nothing else. |
Core entities and relationships¶
- Restaurant owns one Menu of MenuItems, each with a price and an availability flag. One restaurant, many menu items.
- Cart holds item ids and quantities for exactly one restaurant. It stores no prices — that is the point.
- Order owns an immutable tuple of OrderItem price snapshots, a delivery
Location, a fee, a discount and anOrderStatus. One order, one restaurant, at most one courier. - DeliveryPartner — id, live location, rating,
PartnerStatus, and the order they currently hold. - DeliveryOffer — the lease: order, partner,
created_at,expires_at,OfferStatus. At most one live offer names a given courier. - OrderService owns the order registry and every transition; DeliveryService owns courier state and offers; PaymentService owns authorizations.
- FoodDeliveryService is the facade that sequences those three and publishes to the EventBus, which NotificationService observes.
- AssignmentStrategy (
NearestPartner,BestRatedNearby,FairRotation) and DiscountStrategy (FlatOff,PercentOff,FreeDelivery,NoDiscount) are the two policies you will be asked to change.
Multiplicities: restaurant 1 → * menu items, order 1 → * order items, order 1 → * offers over its life but 0..1 live at a time, courier 1 → 0..1 live offer, order 0..1 → 1 courier.
Class diagram¶
Domain: what an order is made of, and who can carry it.
classDiagram
class Location {
+lat: float
+lon: float
+distance_km(other) float
}
class MenuItem {
+id: str
+price: Money
+available: bool
}
class Menu {
+add(item) MenuItem
+item(item_id) MenuItem
}
class Restaurant {
+id: str
+is_open: bool
+prep_minutes: int
}
class Cart {
+customer_id: str
+restaurant_id: str
+add(restaurant_id, item_id, quantity)
+remove(item_id)
}
class OrderItem {
+item_id: str
+unit_price: Money
+quantity: int
+line_total Money
}
class Order {
+id: str
+status: OrderStatus
+partner_id: str
+subtotal Money
+total Money
+can_move_to(target) bool
+is_assignable() bool
}
class DeliveryPartner {
+id: str
+rating: float
+status: PartnerStatus
+current_order_id: str
+is_free() bool
}
class DeliveryOffer {
+id: str
+expires_at: float
+status: OfferStatus
+is_live(now) bool
}
class Payment {
+amount: Money
+status: PaymentStatus
}
class Rating {
+stars: int
+comment: str
}
Restaurant "1" *-- "1" Menu
Menu "1" *-- "many" MenuItem
Restaurant --> Location
Cart ..> Restaurant : one kitchen only
Order "1" *-- "many" OrderItem
Order --> Location : deliver_to
Order --> "0..1" DeliveryPartner
DeliveryOffer --> Order
DeliveryOffer --> DeliveryPartner : leases
Order "1" o-- "1" Payment
Order ..> Rating
DeliveryPartner --> Location
Services and policies: three owners of state, one facade, two pluggable rules.
classDiagram
class FoodDeliveryService {
+place_order(cart, deliver_to, method, coupon_code) Order
+restaurant_accepts(order_id) DeliveryOffer
+dispatch(order_id) DeliveryOffer
+partner_accepts(offer_id, partner_id) Order
+sweep_offers() List~DeliveryOffer~
+cancel_order(order_id) Order
+deliver(order_id) Order
}
class OrderService {
+place(cart, deliver_to, coupon_code) Order
+transition(order_id, target) Order
+attach_partner(order_id, partner_id) Order
}
class DeliveryService {
+offer(order_id, origin) DeliveryOffer
+accept(offer_id, partner_id) DeliveryOffer
+decline(offer_id, partner_id) DeliveryOffer
+sweep() List~DeliveryOffer~
+release(offer)
+void_order(order_id)
}
class PaymentService {
+authorize(order, method) Payment
+capture(order_id) Payment
+void(order_id) Payment
}
class EventBus {
+subscribe(topic, handler)
+publish(event) int
+failures() List~str~
}
class NotificationService {
+inbox(recipient) List~str~
}
class AssignmentStrategy {
<<interface>>
+rank(origin, candidates) List~DeliveryPartner~
}
class NearestPartner
class BestRatedNearby
class FairRotation
class DiscountStrategy {
<<interface>>
+discount(subtotal, delivery_fee) Money
}
class NoDiscount
class FlatOff
class PercentOff
class FreeDelivery
class CouponBook {
+lookup(code) DiscountStrategy
}
class PaymentGateway {
<<interface>>
+authorize(amount) bool
+capture(amount) bool
}
class GatewayFactory {
+for_method(method) PaymentGateway
}
FoodDeliveryService --> OrderService
FoodDeliveryService --> DeliveryService
FoodDeliveryService --> PaymentService
FoodDeliveryService --> EventBus
EventBus --> NotificationService : notifies
DeliveryService --> AssignmentStrategy
AssignmentStrategy <|.. NearestPartner
AssignmentStrategy <|.. BestRatedNearby
AssignmentStrategy <|.. FairRotation
OrderService --> CouponBook
CouponBook --> DiscountStrategy
DiscountStrategy <|.. NoDiscount
DiscountStrategy <|.. FlatOff
DiscountStrategy <|.. PercentOff
DiscountStrategy <|.. FreeDelivery
PaymentService --> GatewayFactory
GatewayFactory ..> PaymentGateway : creates
Design patterns applied¶
| Pattern | Where | Why it earns its place |
|---|---|---|
| State (as a table) | ORDER_TRANSITIONS plus OrderService.transition |
Eight statuses and four actors. One dict is the whole machine, every service asks it, and the state diagram below is literally a drawing of that dict. Full State classes would be eight files with no behaviour in them. |
| Strategy | AssignmentStrategy, DiscountStrategy |
The two rules the interviewer will change: "now rank by rating", "now add free delivery over 500". A strategy only ranks or computes — never mutates, never locks — so swapping one cannot introduce a race. |
| Event Bus + Observer | EventBus, NotificationService |
OrderService must not import a push-notification client. It publishes order.assigned; whoever cares subscribes. Handlers run outside the bus lock and a failing handler is logged, not propagated: an order does not roll back because a push timed out. |
| Facade | FoodDeliveryService |
The API layer gets eight verbs and never touches a lock. It is also the only place that knows the order of the three services. |
| Factory Method | GatewayFactory.for_method |
Checkout carries the string "wallet". A registry maps it to a gateway; cash-on-delivery is a class, not an if. |
| Null Object | NoDiscount |
CouponBook.lookup(None) returns an object, so pricing has no if coupon is None branch. An unknown code still raises — absent and invalid are different things. |
| Dependency Injection | Clock, IdGenerator, strategies, gateways |
Tests freeze the clock at 2026-02-25, advance it 31 seconds and watch an offer expire, with no sleeps anywhere. |
What was deliberately not used: Command for the dispatch cascade, and a Repository per entity. Command would let you queue and retry offers, and you should mention it as the natural next step when dispatch moves onto a worker pool — but here the cascade is three lines inside sweep_offers, and wrapping it in objects buys nothing today. Repositories are the persistence seam; say "each service holds a dict and would hold a repository protocol instead", then move on.
Key flows¶
The happy path: three actors, three services, one facade sequencing them.
sequenceDiagram
autonumber
actor U as Customer
participant F as FoodDeliveryService
participant O as OrderService
participant P as PaymentService
participant D as DeliveryService
actor R as Restaurant
actor C as Courier
U->>F: place_order(cart, address, CARD, coupon)
F->>O: place(cart) snapshots prices and discount
O-->>F: Order(PLACED)
F->>P: authorize(total)
P-->>F: Payment(AUTHORIZED)
F-)U: order.placed
R->>F: restaurant_accepts(order_id)
F->>O: PLACED to ACCEPTED to PREPARING
F->>D: offer(order_id, kitchen_location)
D->>D: rank idle couriers, lease the best
D-->>C: DeliveryOffer valid for 30 s
C->>F: partner_accepts(offer_id)
F->>D: accept claims the lease
F->>O: attach_partner
R->>F: mark_ready
C->>F: pick_up then deliver
F->>P: capture(total)
F->>D: complete(partner_id)
F-)U: order.delivered
The two races. Left: the lease times out and cascades. Right: a cancel lands while a courier accepts.
sequenceDiagram
autonumber
participant F as FoodDeliveryService
participant D as DeliveryService
participant O as OrderService
actor C1 as Courier one
actor C2 as Courier two
actor U as Customer
F->>D: offer(order, kitchen)
D-->>C1: offer expires at t plus 30 s
Note over C1: silence
F->>D: sweep() at t plus 31 s
D->>D: offer EXPIRED, courier one back to IDLE and remembered as passed
F->>D: offer(order, kitchen) again
D-->>C2: new offer, never to courier one
par courier two accepts
C2->>F: partner_accepts(offer_id)
F->>D: accept claims the lease
and customer cancels
U->>F: cancel_order(order_id)
F->>O: transition to CANCELLED
end
F->>O: attach_partner
alt order still assignable
O-->>F: Order with courier two attached
else already CANCELLED
O-->>F: OrderStateError
F->>D: release(offer) so courier two goes back to IDLE
end
Order lifecycle. This diagram is ORDER_TRANSITIONS drawn out; the table in models.py is the only thing that enforces it.
stateDiagram-v2
[*] --> PLACED : payment authorized
PLACED --> ACCEPTED : restaurant accepts
PLACED --> REJECTED : restaurant refuses, authorization voided
PLACED --> CANCELLED : customer pulls out
ACCEPTED --> PREPARING : cooking starts, dispatch begins
ACCEPTED --> CANCELLED : customer pulls out
PREPARING --> READY : food on the counter
PREPARING --> CANCELLED : customer pulls out
READY --> PICKED_UP : courier collects
PICKED_UP --> DELIVERED : payment captured
DELIVERED --> [*]
CANCELLED --> [*]
REJECTED --> [*]
Offer lifecycle. Only PENDING is live, and only one caller can move an offer out of it — that single transition is what makes double assignment impossible.
stateDiagram-v2
[*] --> PENDING : courier leased, timer starts
PENDING --> ACCEPTED : courier taps accept first
PENDING --> DECLINED : courier passes, cascade to the next
PENDING --> EXPIRED : 30 s elapse, sweep releases the courier
PENDING --> VOIDED : order cancelled underneath
ACCEPTED --> VOIDED : order cancelled after the claim
ACCEPTED --> [*]
DECLINED --> [*]
EXPIRED --> [*]
VOIDED --> [*]
Implementation¶
The order in the room: the vocabulary and the transition table first, because everything else asks it questions; then the catalog and the order; then the two policies; then the three services and the facade.
The enums plus two frozen sets are the state machine. Putting ORDER_TRANSITIONS in models.py rather than inside a service is deliberate — it is a business rule, and three different callers need the same answer:
class OrderStatus(StrEnum):
PLACED = "placed" # paid for (authorized), waiting for the restaurant
ACCEPTED = "accepted"
PREPARING = "preparing"
READY = "ready" # on the counter, waiting for a courier
PICKED_UP = "picked_up"
DELIVERED = "delivered"
CANCELLED = "cancelled" # customer pulled out
REJECTED = "rejected" # restaurant refused
class PartnerStatus(StrEnum):
OFFLINE = "offline"
IDLE = "idle" # available for an offer
OFFERED = "offered" # holds one live lease and cannot be offered again
DELIVERING = "delivering"
class OfferStatus(StrEnum):
PENDING = "pending"
ACCEPTED = "accepted"
DECLINED = "declined"
EXPIRED = "expired" # the lease timed out
VOIDED = "voided" # the order disappeared under the courier
class PaymentStatus(StrEnum):
AUTHORIZED = "authorized"
CAPTURED = "captured"
VOIDED = "voided"
REFUNDED = "refunded"
class PaymentMethod(StrEnum):
CARD = "card"
WALLET = "wallet"
CASH = "cash"
#: The single source of truth for the order lifecycle. Every service asks this
#: table rather than writing its own if/elif ladder, and the state diagram on the
#: page is a drawing of exactly this dict.
ORDER_TRANSITIONS: dict[OrderStatus, frozenset[OrderStatus]] = {
OrderStatus.PLACED: frozenset({OrderStatus.ACCEPTED, OrderStatus.REJECTED, OrderStatus.CANCELLED}),
OrderStatus.ACCEPTED: frozenset({OrderStatus.PREPARING, OrderStatus.CANCELLED}),
OrderStatus.PREPARING: frozenset({OrderStatus.READY, OrderStatus.CANCELLED}),
OrderStatus.READY: frozenset({OrderStatus.PICKED_UP}),
OrderStatus.PICKED_UP: frozenset({OrderStatus.DELIVERED}),
OrderStatus.DELIVERED: frozenset(),
OrderStatus.CANCELLED: frozenset(),
OrderStatus.REJECTED: frozenset(),
}
#: A courier may be attached only while the food still needs moving. Dispatch
#: starts at ACCEPTED on purpose: the courier should arrive as the food does.
ASSIGNABLE_STATUSES = frozenset({OrderStatus.ACCEPTED, OrderStatus.PREPARING, OrderStatus.READY})
class RestaurantClosedError(ConflictError):
"""The restaurant is not taking orders right now."""
class ItemUnavailableError(ConflictError):
"""An item in the cart went off the menu between browsing and checkout."""
class OrderStateError(InvalidStateError):
"""The order is not in a state that allows this transition."""
class OfferStateError(InvalidStateError):
"""The offer is expired, already answered, or belongs to a different courier."""
class NoPartnerAvailableError(ConflictError):
"""Nobody is idle within range who has not already passed on this order."""
class PaymentDeclinedError(ConflictError):
"""The gateway refused the authorization."""
class UnknownOrderError(NotFoundError):
"""No order with that id."""
class UnknownPartnerError(NotFoundError):
"""No delivery partner with that id."""
The catalog is unremarkable except for one line: Cart stores item ids and quantities and no prices at all. Prices are resolved once, at checkout, into immutable OrderItems:
@dataclass(slots=True)
class MenuItem:
id: str
name: str
price: Money
available: bool = True
@dataclass(slots=True)
class Menu:
items: dict[str, MenuItem] = field(default_factory=dict)
def add(self, item: MenuItem) -> MenuItem:
self.items[item.id] = item
return item
def item(self, item_id: str) -> MenuItem:
try:
return self.items[item_id]
except KeyError:
raise NotFoundError(f"no menu item {item_id}") from None
@dataclass(slots=True)
class Restaurant:
id: str
name: str
location: Location
prep_minutes: int = 20
is_open: bool = True
menu: Menu = field(default_factory=Menu)
@dataclass(slots=True)
class Cart:
"""Item ids and quantities only. Prices are resolved at checkout, never here."""
customer_id: str
restaurant_id: str | None = None
lines: dict[str, int] = field(default_factory=dict)
def add(self, restaurant_id: str, item_id: str, quantity: int = 1) -> None:
if quantity <= 0:
raise ValidationError("quantity must be positive")
if self.restaurant_id is not None and self.restaurant_id != restaurant_id:
raise ValidationError("a cart holds items from one restaurant only")
self.restaurant_id = restaurant_id
self.lines[item_id] = self.lines.get(item_id, 0) + quantity
def remove(self, item_id: str) -> None:
self.lines.pop(item_id, None)
if not self.lines:
self.restaurant_id = None
def is_empty(self) -> bool:
return not self.lines
DeliveryOffer.is_live is the smallest method on the page and the most important one: an offer is usable only while it is PENDING and inside its window.
@dataclass(frozen=True, slots=True)
class OrderItem:
"""A price snapshot. The menu may change tomorrow; this line never does."""
item_id: str
name: str
unit_price: Money
quantity: int
@property
def line_total(self) -> Money:
return self.unit_price * self.quantity
@dataclass(slots=True)
class Order:
id: str
customer_id: str
restaurant_id: str
items: tuple[OrderItem, ...]
deliver_to: Location
delivery_fee: Money
discount: Money
placed_at: float
status: OrderStatus = OrderStatus.PLACED
partner_id: str | None = None
coupon_code: str | None = None
@property
def subtotal(self) -> Money:
total = Money(0)
for item in self.items:
total = total + item.line_total
return total
@property
def total(self) -> Money:
return self.subtotal + self.delivery_fee - self.discount
def can_move_to(self, target: OrderStatus) -> bool:
return target in ORDER_TRANSITIONS[self.status]
def is_assignable(self) -> bool:
return self.status in ASSIGNABLE_STATUSES
@dataclass(slots=True)
class DeliveryPartner:
id: str
name: str
location: Location
rating: float = 5.0
ratings_count: int = 0
status: PartnerStatus = PartnerStatus.OFFLINE
deliveries_today: int = 0
current_order_id: str | None = None
def is_free(self) -> bool:
return self.status is PartnerStatus.IDLE
@dataclass(slots=True)
class DeliveryOffer:
"""A time-boxed lease on one courier. Exactly one can be live per courier."""
id: str
order_id: str
partner_id: str
created_at: float
expires_at: float
status: OfferStatus = OfferStatus.PENDING
def is_live(self, now: float) -> bool:
return self.status is OfferStatus.PENDING and now < self.expires_at
Assignment strategies rank and nothing else. Notice that none of them touches a lock or mutates a courier — that is enforced by giving them a read-only Sequence:
class AssignmentStrategy(Protocol):
"""Ranks candidate couriers for one order, best first.
A strategy only *ranks*. It never mutates a courier and never takes a lock --
``DeliveryService`` does both, which is why swapping the ranking rule cannot
introduce a race.
"""
def rank(self, origin: Location, candidates: Sequence[DeliveryPartner]) -> list[DeliveryPartner]: ...
class NearestPartner:
"""Straight-line distance from the restaurant. The obvious first answer."""
def rank(self, origin: Location, candidates: Sequence[DeliveryPartner]) -> list[DeliveryPartner]:
return sorted(candidates, key=lambda p: (origin.distance_km(p.location), p.id))
class BestRatedNearby:
"""Everyone inside the radius, best rated first. Distance only breaks ties."""
def __init__(self, radius_km: float = 3.0) -> None:
self._radius = radius_km
def rank(self, origin: Location, candidates: Sequence[DeliveryPartner]) -> list[DeliveryPartner]:
nearby = [p for p in candidates if origin.distance_km(p.location) <= self._radius]
return sorted(nearby, key=lambda p: (-p.rating, origin.distance_km(p.location), p.id))
class FairRotation:
"""Fewest deliveries today first, then distance. Keeps earnings even."""
def __init__(self, radius_km: float = 5.0) -> None:
self._radius = radius_km
def rank(self, origin: Location, candidates: Sequence[DeliveryPartner]) -> list[DeliveryPartner]:
nearby = [p for p in candidates if origin.distance_km(p.location) <= self._radius]
return sorted(nearby, key=lambda p: (p.deliveries_today, origin.distance_km(p.location), p.id))
class DiscountStrategy(Protocol):
"""How much comes off this order. Implementations are stateless."""
def discount(self, subtotal: Money, delivery_fee: Money) -> Money: ...
class NoDiscount:
"""Null Object: the default coupon. Callers never branch on ``coupon is None``."""
def discount(self, subtotal: Money, delivery_fee: Money) -> Money:
return Money(0)
class FlatOff:
"""A fixed amount off, above a minimum spend."""
def __init__(self, amount: Money, min_subtotal: Money) -> None:
self._amount = amount
self._min = min_subtotal
def discount(self, subtotal: Money, delivery_fee: Money) -> Money:
if subtotal < self._min:
return Money(0)
return min(self._amount, subtotal)
class PercentOff:
"""A percentage off the food, capped so a large order cannot drain the budget."""
def __init__(self, percent: Decimal, cap: Money) -> None:
if not 0 < percent < 1:
raise ValidationError("percent must be a fraction between 0 and 1")
self._percent = percent
self._cap = cap
def discount(self, subtotal: Money, delivery_fee: Money) -> Money:
return min(subtotal * self._percent, self._cap)
class FreeDelivery:
"""Takes the delivery fee off instead of the food."""
def discount(self, subtotal: Money, delivery_fee: Money) -> Money:
return delivery_fee
class CouponBook:
"""Code to strategy. An unknown code is an error; *no* code is a Null Object."""
def __init__(self, coupons: dict[str, DiscountStrategy] | None = None) -> None:
self._coupons: dict[str, DiscountStrategy] = dict(coupons or {})
def register(self, code: str, strategy: DiscountStrategy) -> None:
self._coupons[code.upper()] = strategy
def lookup(self, code: str | None) -> DiscountStrategy:
if code is None:
return NoDiscount()
try:
return self._coupons[code.upper()]
except KeyError:
raise ValidationError(f"unknown coupon {code!r}") from None
OrderService owns one lock and every transition. transition raises rather than returning False: a caller that tried an illegal move has a bug, and silence would hide it.
class OrderService:
"""Owns the order registry and *every* status change.
One lock guards both. Each transition is a check-and-flip against
``ORDER_TRANSITIONS``: a caller can never observe an order half-way between
two states, and two callers can never both win the same transition.
"""
def __init__(
self,
restaurants: Iterable[Restaurant],
clock: Clock | None = None,
ids: IdGenerator | None = None,
coupons: CouponBook | None = None,
delivery_fee: Money = DEFAULT_DELIVERY_FEE,
) -> None:
self._restaurants = {r.id: r for r in restaurants}
self._clock = clock or SystemClock()
self._ids = ids or SequentialIdGenerator("O")
self._coupons = coupons or CouponBook()
self._fee = delivery_fee
self._orders: dict[str, Order] = {}
self._lock = threading.Lock()
def restaurant(self, restaurant_id: str) -> Restaurant:
try:
return self._restaurants[restaurant_id]
except KeyError:
raise UnknownOrderError(f"no restaurant {restaurant_id}") from None
def nearby(self, location: Location, radius_km: float = 5.0) -> list[Restaurant]:
open_ones = [r for r in self._restaurants.values() if r.is_open]
within = [r for r in open_ones if location.distance_km(r.location) <= radius_km]
return sorted(within, key=lambda r: (location.distance_km(r.location), r.id))
def order(self, order_id: str) -> Order:
with self._lock:
try:
return self._orders[order_id]
except KeyError:
raise UnknownOrderError(f"unknown order {order_id}") from None
def place(self, cart: Cart, deliver_to: Location, coupon_code: str | None = None) -> Order:
"""Snapshot prices *now*: the menu may change while the food is cooking."""
if cart.is_empty() or cart.restaurant_id is None:
raise ValidationError("cannot place an empty cart")
restaurant = self.restaurant(cart.restaurant_id)
if not restaurant.is_open:
raise RestaurantClosedError(f"{restaurant.name} is closed")
items = []
for item_id, quantity in cart.lines.items():
menu_item = restaurant.menu.item(item_id)
if not menu_item.available:
raise ItemUnavailableError(f"{menu_item.name} is sold out")
items.append(OrderItem(item_id, menu_item.name, menu_item.price, quantity))
order = Order(
id=self._ids.next_id(),
customer_id=cart.customer_id,
restaurant_id=restaurant.id,
items=tuple(items),
deliver_to=deliver_to,
delivery_fee=self._fee,
discount=Money(0),
placed_at=self._clock.now(),
coupon_code=coupon_code,
)
order.discount = self._coupons.lookup(coupon_code).discount(order.subtotal, self._fee)
with self._lock:
self._orders[order.id] = order
return order
def transition(self, order_id: str, target: OrderStatus) -> Order:
"""The only way an order changes state. Rejected moves raise, never no-op."""
with self._lock:
order = self._orders.get(order_id)
if order is None:
raise UnknownOrderError(f"unknown order {order_id}")
if not order.can_move_to(target):
raise OrderStateError(f"order {order_id} cannot move {order.status} to {target}")
order.status = target
return order
def attach_partner(self, order_id: str, partner_id: str) -> Order:
"""Pin a courier. Fails if the order was cancelled or already has one."""
with self._lock:
order = self._orders.get(order_id)
if order is None:
raise UnknownOrderError(f"unknown order {order_id}")
if not order.is_assignable():
raise OrderStateError(f"order {order_id} is {order.status}, not assignable")
if order.partner_id is not None:
raise OrderStateError(f"order {order_id} already has courier {order.partner_id}")
order.partner_id = partner_id
return order
DeliveryService is where the lease lives. offer flips a courier from IDLE to OFFERED inside the lock, which is the entire double-assignment defence; _retire is the one place a courier is freed, so the three ways an offer can die share their cleanup:
class DeliveryService:
"""Courier state, live offers and the lease. One lock over all three.
The invariant: a courier is in exactly one of IDLE, OFFERED and DELIVERING,
and at most one PENDING offer names them. ``offer`` flips IDLE to OFFERED
inside the lock, so a courier that two dispatchers pick simultaneously is
still only offered one order -- the second dispatcher finds nobody idle and
moves down its ranking.
"""
OFFER_TIMEOUT_SECONDS = 30.0
def __init__(
self,
partners: Iterable[DeliveryPartner],
strategy: AssignmentStrategy | None = None,
clock: Clock | None = None,
ids: IdGenerator | None = None,
offer_timeout: float = OFFER_TIMEOUT_SECONDS,
) -> None:
self._partners = {p.id: p for p in partners}
self._strategy = strategy or NearestPartner()
self._clock = clock or SystemClock()
self._ids = ids or SequentialIdGenerator("OF")
self._timeout = offer_timeout
self._offers: dict[str, DeliveryOffer] = {}
self._passed_on: dict[str, set[str]] = {}
self._lock = threading.Lock()
def partner(self, partner_id: str) -> DeliveryPartner:
try:
return self._partners[partner_id]
except KeyError:
raise UnknownPartnerError(f"no partner {partner_id}") from None
def go_online(self, partner_id: str, location: Location | None = None) -> DeliveryPartner:
with self._lock:
partner = self.partner(partner_id)
if location is not None:
partner.location = location
partner.status = PartnerStatus.IDLE
return partner
def update_location(self, partner_id: str, location: Location) -> None:
with self._lock:
self.partner(partner_id).location = location
def offer(self, order_id: str, origin: Location) -> DeliveryOffer:
"""Lease the best idle courier who has not already passed on this order."""
with self._lock:
now = self._clock.now()
live = [o for o in self._offers.values() if o.order_id == order_id and o.is_live(now)]
if live:
return live[0] # dispatch is idempotent: one live offer per order
passed = self._passed_on.setdefault(order_id, set())
free = [p for p in self._partners.values() if p.is_free() and p.id not in passed]
ranked = self._strategy.rank(origin, free)
if not ranked:
raise NoPartnerAvailableError(f"no courier free for order {order_id}")
partner = ranked[0]
offer = DeliveryOffer(self._ids.next_id(), order_id, partner.id, now, now + self._timeout)
partner.status = PartnerStatus.OFFERED
partner.current_order_id = order_id
self._offers[offer.id] = offer
return offer
def accept(self, offer_id: str, partner_id: str) -> DeliveryOffer:
"""Claim the lease. Exactly one caller can move an offer out of PENDING."""
with self._lock:
offer = self._offers.get(offer_id)
if offer is None:
raise OfferStateError(f"unknown offer {offer_id}")
if offer.partner_id != partner_id:
raise OfferStateError(f"offer {offer_id} belongs to {offer.partner_id}")
if not offer.is_live(self._clock.now()):
raise OfferStateError(f"offer {offer_id} is {offer.status} or expired")
offer.status = OfferStatus.ACCEPTED
partner = self.partner(partner_id)
partner.status = PartnerStatus.DELIVERING
return offer
def decline(self, offer_id: str, partner_id: str) -> DeliveryOffer:
with self._lock:
offer = self._offers.get(offer_id)
if offer is None or offer.partner_id != partner_id:
raise OfferStateError(f"offer {offer_id} is not addressed to {partner_id}")
if offer.status is not OfferStatus.PENDING:
raise OfferStateError(f"offer {offer_id} is already {offer.status}")
self._retire(offer, OfferStatus.DECLINED)
return offer
def sweep(self) -> list[DeliveryOffer]:
"""Expire every lease whose timeout has passed. Call it from a timer."""
now = self._clock.now()
with self._lock:
stale = [o for o in self._offers.values() if o.status is OfferStatus.PENDING and now >= o.expires_at]
for offer in stale:
self._retire(offer, OfferStatus.EXPIRED)
return stale
def release(self, offer: DeliveryOffer) -> None:
"""Undo an accepted claim because the order moved under the courier."""
with self._lock:
self._retire(offer, OfferStatus.VOIDED, remember=False)
def void_order(self, order_id: str) -> None:
with self._lock:
for offer in self._offers.values():
if offer.order_id == order_id and offer.status in (OfferStatus.PENDING, OfferStatus.ACCEPTED):
self._retire(offer, OfferStatus.VOIDED, remember=False)
self._passed_on.pop(order_id, None)
def complete(self, partner_id: str) -> DeliveryPartner:
with self._lock:
partner = self.partner(partner_id)
partner.status = PartnerStatus.IDLE
partner.current_order_id = None
partner.deliveries_today += 1
return partner
def offers_for(self, order_id: str) -> list[DeliveryOffer]:
with self._lock:
return [o for o in self._offers.values() if o.order_id == order_id]
def _retire(self, offer: DeliveryOffer, status: OfferStatus, remember: bool = True) -> None:
"""Caller holds the lock. Frees the courier and optionally records the pass."""
offer.status = status
partner = self._partners.get(offer.partner_id)
# Free the courier only if they are still holding *this* order. Retiring a
# stale offer must never take a courier off the delivery they have since
# accepted -- the classic "release a resource someone else now owns" bug.
still_held = partner is not None and partner.current_order_id == offer.order_id
if still_held and partner is not None and partner.status is not PartnerStatus.OFFLINE:
partner.status = PartnerStatus.IDLE
partner.current_order_id = None
if remember:
self._passed_on.setdefault(offer.order_id, set()).add(offer.partner_id)
The bus is deliberately dull: subscribers under the lock, handlers outside it, failures recorded rather than raised.
class EventBus:
"""Topic to handlers, dispatched synchronously with per-handler error isolation.
The lock only guards the subscriber map and the failure log; handlers run
*outside* it, so a slow notification cannot stall the order that published it.
A failing handler is recorded and the next one still runs -- an order must not
roll back because a push notification timed out.
"""
def __init__(self) -> None:
self._lock = threading.Lock()
self._handlers: dict[str, list[Handler]] = {}
self._failures: list[tuple[str, str]] = []
def subscribe(self, topic: str, handler: Handler) -> None:
with self._lock:
self._handlers.setdefault(topic, []).append(handler)
def publish(self, event: Event) -> int:
with self._lock:
handlers = list(self._handlers.get(event.topic, ()))
delivered = 0
for handler in handlers:
try:
handler(event)
delivered += 1
except Exception as exc: # isolation is the whole point of a bus
with self._lock:
self._failures.append((event.topic, str(exc)))
return delivered
def failures(self) -> list[tuple[str, str]]:
with self._lock:
return list(self._failures)
The facade sequences and never computes. Every cross-service step is claim, act, revert on failure — partner_accepts is the canonical example:
class FoodDeliveryService:
"""The one object the API layer holds. It sequences, it does not compute.
Every step is *claim, then act, then revert on failure*: the offer lease is
claimed in ``DeliveryService`` and the order is pinned in ``OrderService``,
and if the second call fails the first is undone. No method ever holds two
service locks at once, so there is no lock order to get wrong.
"""
def __init__(
self,
restaurants: Iterable[Restaurant],
partners: Iterable[DeliveryPartner],
strategy: AssignmentStrategy | None = None,
coupons: CouponBook | None = None,
clock: Clock | None = None,
ids: IdGenerator | None = None,
offer_timeout: float = DeliveryService.OFFER_TIMEOUT_SECONDS,
) -> None:
clock = clock or SystemClock()
self._clock = clock
self.bus = EventBus()
self.notifications = NotificationService(self.bus)
self.orders = OrderService(restaurants, clock=clock, ids=ids or SequentialIdGenerator("O"), coupons=coupons)
self.delivery = DeliveryService(partners, strategy, clock=clock, offer_timeout=offer_timeout)
self.payments = PaymentService(clock=clock)
self._ratings: list[Rating] = []
self._lock = threading.Lock()
# -- customer ---------------------------------------------------------------
def browse(self, location: Location, radius_km: float = 5.0) -> list[Restaurant]:
return self.orders.nearby(location, radius_km)
def place_order(
self, cart: Cart, deliver_to: Location, method: PaymentMethod, coupon_code: str | None = None
) -> Order:
order = self.orders.place(cart, deliver_to, coupon_code)
try:
self.payments.authorize(order, method)
except PaymentDeclinedError:
self.orders.transition(order.id, OrderStatus.CANCELLED)
raise
self._publish("order.placed", order)
return order
def cancel_order(self, order_id: str) -> Order:
"""Flip the order first: a courier accepting at the same instant now loses."""
order = self.orders.transition(order_id, OrderStatus.CANCELLED)
self.delivery.void_order(order_id)
self.payments.void(order_id)
self._publish("order.cancelled", order)
return order
def rate_delivery(self, order_id: str, stars: int, comment: str = "") -> Rating:
order = self.orders.order(order_id)
if order.status is not OrderStatus.DELIVERED or order.partner_id is None:
raise OrderStateError(f"order {order_id} was not delivered")
rating = Rating(order_id, order.partner_id, stars, comment)
partner = self.delivery.partner(order.partner_id)
with self._lock:
self._ratings.append(rating)
total = partner.rating * partner.ratings_count + stars
partner.ratings_count += 1
partner.rating = round(total / partner.ratings_count, 2)
return rating
# -- restaurant -------------------------------------------------------------
def restaurant_accepts(self, order_id: str) -> DeliveryOffer | None:
order = self.orders.transition(order_id, OrderStatus.ACCEPTED)
self._publish("order.accepted", order)
self.orders.transition(order_id, OrderStatus.PREPARING)
return self.dispatch(order_id) # dispatch while the food cooks
def restaurant_rejects(self, order_id: str) -> Order:
order = self.orders.transition(order_id, OrderStatus.REJECTED)
self.payments.void(order_id)
self._publish("order.rejected", order)
return order
def mark_ready(self, order_id: str) -> Order:
order = self.orders.transition(order_id, OrderStatus.READY)
self._publish("order.ready", order)
return order
# -- dispatch ---------------------------------------------------------------
def dispatch(self, order_id: str) -> DeliveryOffer | None:
"""Offer the order to the next-best courier. None means nobody is left."""
order = self.orders.order(order_id)
if not order.is_assignable() or order.partner_id is not None:
return None
try:
return self.delivery.offer(order_id, self.orders.restaurant(order.restaurant_id).location)
except NoPartnerAvailableError:
return None
def partner_accepts(self, offer_id: str, partner_id: str) -> Order:
offer = self.delivery.accept(offer_id, partner_id) # claim the lease
try:
order = self.orders.attach_partner(offer.order_id, partner_id)
except OrderStateError:
self.delivery.release(offer) # the order was cancelled under the courier
raise
self._publish("order.assigned", order)
return order
def partner_declines(self, offer_id: str, partner_id: str) -> DeliveryOffer | None:
offer = self.delivery.decline(offer_id, partner_id)
return self.dispatch(offer.order_id)
def sweep_offers(self) -> list[DeliveryOffer]:
"""Expire stale leases and cascade each order to the next courier."""
expired = self.delivery.sweep()
for offer in expired:
self.dispatch(offer.order_id)
return expired
def pick_up(self, order_id: str) -> Order:
order = self.orders.order(order_id)
if order.partner_id is None:
raise OrderStateError(f"order {order_id} has no courier")
order = self.orders.transition(order_id, OrderStatus.PICKED_UP)
self._publish("order.picked_up", order)
return order
def deliver(self, order_id: str) -> Order:
order = self.orders.transition(order_id, OrderStatus.DELIVERED)
self.payments.capture(order_id)
if order.partner_id:
self.delivery.complete(order.partner_id)
self._publish("order.delivered", order)
return order
def _publish(self, topic: str, order: Order) -> None:
payload = {
"order_id": order.id,
"customer_id": order.customer_id,
"restaurant": order.restaurant_id,
"total": str(order.total),
}
if order.partner_id:
payload["partner"] = order.partner_id
self.bus.publish(Event(topic, self._clock.now(), payload))
Running python -m lld.food_delivery.demo walks a timeout, a decline and a delivery:
open near home: ['Curry Corner']
O-1 placed: subtotal 19.00 USD, SAVE10 -1.90 USD, fee 2.50 USD, total 19.60 USD
O-1 accepted and cooking; OF-1 offered to p1 until +30 s
OF-1 expired (expired); p1 is idle again
cascaded to p2, who declines
cascaded again to p3, who accepts
O-1 delivered by p3; payment captured 19.60 USD
p3 rating is now 4.84
customer: we sent O-1 to r1
customer: r1 is cooking O-1
customer: p3 is bringing O-1
customer: O-1 is on the counter
customer: p3 picked up O-1
customer: O-1 delivered by p3
Concurrency and edge cases¶
Which lock protects what. Three, and no method ever holds two of them:
OrderService._lockguards the order registry and every status change.transitionandattach_partnerare check-and-flip: two callers cannot both move an order out ofPREPARING, and a courier cannot be attached to an order that is no longer assignable.DeliveryService._lockguards courier statuses, the offer registry and the per-order set of couriers who already passed.offerreads the idle set, ranks it and flips the winner toOFFEREDin one critical section.PaymentService._lockguards the payment registry, with the same check-and-flip onAUTHORIZED → CAPTURED | VOIDED.EventBushas a fourth lock over its subscriber map, but that map is written once at wiring time.
Why nesting is banned, and what replaces it. The facade needs two services to agree, so the temptation is to hold both locks. Instead every cross-service step is claim, act, revert: partner_accepts claims the lease in DeliveryService, then pins the courier in OrderService, and calls release if the second step fails. There is no lock order to get wrong because there are never two locks held, and the recovery path is one line you can point at on the board.
No double assignment. Two dispatchers running for two different orders can pick the same courier from the same idle list — but only one of them executes partner.status = OFFERED under the lock. The other finds nobody idle at that rank and walks down its own ranking, or reports that nobody is free. dispatch is also idempotent: if a live offer already exists for the order, offer returns it instead of leasing a second courier.
The lease timeout. A courier who closes their phone would otherwise pin an order forever. sweep expires every offer past expires_at, frees the courier, and records them in _passed_on[order_id] so the cascade never comes back to them. Call it from a timer; the test calls it directly after advancing a FakeClock by 31 seconds, which is why there are no sleeps in the suite.
Cancel versus accept. Both calls may legitimately succeed — a courier can accept a millisecond before the customer cancels. What must never happen is a courier left believing they are delivering a dead order. cancel_order flips the order first, then calls void_order, which retires any live or accepted offer and returns the courier to IDLE. So the post-condition the test asserts is not "exactly one wins" but "if the order is cancelled, the courier is idle" — which is the invariant that actually matters.
Sizing the locks. A city doing 500k orders a day is 500,000 / 86,400 ≈ 6 orders/s on average and roughly 18/s at a 3x peak. Each order takes the order lock about eight times over its life, so about 150 acquisitions per second; an uncontended lock costs about 17 ns, so the lock is four orders of magnitude away from mattering. Say that out loud before anyone proposes sharding a mutex.
Edge cases handled: an item selling out between browsing and checkout, an empty cart, a closed kitchen, a cart mixing two restaurants, a coupon below its minimum spend, an unknown coupon code versus no code at all, a declined authorization cancelling the order, a rejected order voiding the authorization, accepting an expired offer, accepting somebody else's offer, declining until the cascade is exhausted, and rating an order that was never delivered.
Common mistake
Assigning the courier by writing order.partner_id = partner.id and calling it done. That is a write, not a lease: nothing stops a second dispatcher writing over it, nothing expires when the courier goes silent, and nothing tells you who already declined. The offer object with an expiry is the entire difference between a toy and a dispatch system — and it is also the answer to "how would you scale this?", because a lease is exactly what you would put in Redis with a TTL.
Extensibility and follow-ups¶
- ETA and surge: an
EtaServicecomposed fromRestaurant.prep_minutesand courier distance. Surge is aDiscountStrategywith a negative discount, or better, aPricingStrategysibling so the two do not fight over one field. - Batching two orders onto one courier:
DeliveryPartner.current_order_idbecomescurrent_order_ids: list[str]with a capacity, andis_freebecomeshas_capacity. Every strategy keeps working because it only reads. - Restaurant throttling: a
Restaurant.max_open_orderscheck insiderestaurant_accepts, or aSpecificationcomposed with the existing open-and-in-range filter. - Refund saga: a cancel after pickup cannot void an authorization, so it becomes capture-then-refund with compensating steps.
PaymentServicealready modelsCAPTURED → REFUNDED; the saga is the ordering of those steps plus retries. - Dispatch on a worker pool:
sweep_offersbecomes a scheduled job and each cascade becomes a Command on a queue. That is also the moment the offer registry moves to Redis with a real TTL and the process stops being the source of truth. - Scale: at that point it is a system design question — geo-sharded dispatch, WebSocket courier location ingestion, and an outbox instead of an in-process bus. The HLD case study is the same problem with those constraints.
Interview tip
When you are asked "how do you assign a delivery partner?", do not start with the ranking algorithm — that is the easy half and it is a Strategy. Start with "an assignment is a lease with an expiry, and only one caller can move it out of PENDING". Ranking is a policy you can change in a class; the lease is the correctness argument, and it is what separates an SDE2 answer from an SDE1 one.
Tests¶
tests/test_food_delivery.py has 20 cases. The two concurrency tests are the ones to walk through. The first races eight dispatches against three couriers and asserts three distinct leases — and, just as importantly, that a lease is not an assignment:
def test_concurrent_dispatch_never_offers_one_courier_two_orders(clock: FakeClock) -> None:
service = build(clock, couriers=3)
orders = [service.place_order(full_cart(f"c{i}"), HOME, PaymentMethod.CARD) for i in range(8)]
with ThreadPoolExecutor(max_workers=8) as pool:
offers = [o for o in pool.map(lambda o: service.restaurant_accepts(o.id), orders) if o is not None]
leased = [o.partner_id for o in offers]
assert len(leased) == 3 and len(set(leased)) == 3 # three couriers, three leases, no overlap
assert all(service.delivery.partner(p).status is PartnerStatus.OFFERED for p in leased)
assert sum(1 for o in orders if o.partner_id is not None) == 0 # a lease is not an assignment
def test_only_one_thread_can_accept_the_same_offer(clock: FakeClock) -> None:
service = build(clock, couriers=1)
order = service.place_order(full_cart(), HOME, PaymentMethod.CARD)
offer = service.restaurant_accepts(order.id)
assert offer is not None
def accept(_: int) -> bool:
try:
service.partner_accepts(offer.id, "p1")
except OfferStateError:
return False
return True
with ThreadPoolExecutor(max_workers=12) as pool:
results = list(pool.map(accept, range(12)))
assert results.count(True) == 1 and order.partner_id == "p1"
The second interleaves twenty accepts with twenty cancels and asserts the invariant that actually matters:
def test_cancel_racing_accept_never_strands_a_courier(clock: FakeClock) -> None:
"""Both calls may succeed; what must never happen is a courier left on a dead order."""
service = build(clock, couriers=20)
pairs = []
for i in range(20):
order = service.place_order(full_cart(f"c{i}"), HOME, PaymentMethod.CARD)
offer = service.restaurant_accepts(order.id)
assert offer is not None
pairs.append((order, offer))
def run(task: tuple) -> None:
action, order, offer = task
try:
if action == "accept":
service.partner_accepts(offer.id, offer.partner_id)
else:
service.cancel_order(order.id)
except (OfferStateError, OrderStateError):
pass
tasks = [(a, order, offer) for order, offer in pairs for a in ("accept", "cancel")]
with ThreadPoolExecutor(max_workers=16) as pool:
list(pool.map(run, tasks))
for order, offer in pairs:
partner = service.delivery.partner(offer.partner_id)
if order.status is OrderStatus.CANCELLED:
assert partner.status is PartnerStatus.IDLE and partner.current_order_id is None
else:
assert order.partner_id == partner.id and partner.status is PartnerStatus.DELIVERING
The rest cover: price snapshotting and coupon application, with the menu changed afterwards to prove the snapshot holds; sold-out items, empty carts and closed kitchens; the transition table through a six-case parametrize of legal and illegal moves; the lease making a second order undispatchable and dispatch being idempotent; the 30-second timeout cascading to a courier who has not passed, and the stale offer being unusable; retiring a stale offer leaving a courier who has since accepted another order alone, because freeing a resource someone else now owns is the failure this design exists to avoid; declining until the cascade is exhausted; authorize-then-capture on the happy path and authorize-then-void on rejection; the three assignment strategies each picking a different courier from the same three candidates; and the bus isolating a handler that throws. Run them with uv run pytest code/lld/food_delivery -q.
45-minute pacing¶
| Minutes | What to do | What to say or write |
|---|---|---|
| 0–5 | Clarify | Three actors. When is the courier assigned? Can the customer cancel late? Authorize or capture? Out of scope: tracking, batching, surge. |
| 5–10 | State machine first | Draw the eight order statuses and the arrows. Say "this is a table, not a class hierarchy", and write ORDER_TRANSITIONS. |
| 10–18 | Entities and class diagram | Restaurant, Menu, Cart, Order + OrderItem snapshot, DeliveryPartner, DeliveryOffer. Then the three services and the facade over them. |
| 18–34 | Code | OrderService.transition → DeliveryService.offer (the lease) → accept → FoodDeliveryService.partner_accepts with the revert. Say "claim, act, revert" as you write it. |
| 34–40 | Concurrency | Three locks, never nested. The double-assignment argument, the timeout sweep, and why cancel-versus-accept is about the courier and not about who wins. |
| 40–45 | Extensions | Batching, ETA, refund saga, then hand off to the HLD version: Redis leases with a TTL and geo-sharded dispatch. |
Related¶
- Design Uber (with a DoorDash variant) — the same dispatch problem at city scale
- Design Uber (LLD) with driver matching — the sibling problem, with a geospatial index in front of the offer
- Design a restaurant management system — what happens inside the kitchen after
ACCEPTED - State — the transition table and when classes beat a dict
- Strategy — courier ranking and coupons
- Event Bus — topics, synchronous dispatch and handler isolation
- Primary source: Stripe API documentation, "Place a hold on a card" — the authorize-then-capture split this design mirrors