Low-Level Design (Object-Oriented Design) Module 14 – Case studies: money and commerce
Low-level design: food ordering and delivery
Design a food ordering and delivery app in code: restaurants and menus, one order lifecycle across three parties, refunds by stage and riders as a strategy.
What you will learn
- Model restaurants, menus, orders, kitchens and delivery partners
- Implement the order lifecycle across customer, restaurant and rider
- Choose riders with a pluggable strategy and handle cancellations
- Calculate the refund owed when an order is cancelled at each stage
- Test the design with a lunch-hour simulation and failure drills
Before you start
What you’ll learn · Before you start
What you will learn
- Model restaurants, menus, orders, kitchens and delivery partners
- Implement the order lifecycle across customer, restaurant and rider
- Choose riders with a pluggable strategy and handle cancellations
- Calculate the refund owed when an order is cancelled at each stage
- Test the design with a lunch-hour simulation and failure drills
Before you start
On this page
For a low-level design (LLD) round, a food ordering and delivery app comes down to a few classes: they take an order from a customer, have a restaurant cook it and a rider carry it, and keep the money right when anyone changes their mind. Four decisions carry the design. One Order object holds the whole lifecycle, and a transition table says which party may move it from which state: the customer places and cancels, the restaurant accepts, cooks and marks the food ready, the rider picks up and delivers, and the system rejects an order nobody accepts in time. The refund for a cancellation comes from a table keyed by the state the order was in. The rider is chosen by a dispatch strategy. And time comes from a clock the code is given, so a whole lunch hour runs in a test.
The steps follow the answer framework. City-wide dispatch with many servers is the system design track’s food delivery case; this model stays in one process.
Not affiliated
Based on public information about Swiggy and Zomato. MySmartCoPilot is not affiliated with Swiggy and Zomato, and Swiggy and Zomato’s real design may differ from what this lesson describes.
The prompt and the questions that shape it
The prompt is one line: “Design a food delivery app like Swiggy or Zomato.” Customers, restaurants and riders all touch the same order, so the first questions are about who may do what, and when:
- Can an order hold dishes from two restaurants? No: one restaurant per order, one kitchen and one pick-up.
- Must a restaurant accept every order? No. It accepts or rejects, and an order not accepted within 5 minutes is rejected for it.
- Who can cancel, and what does it cost? The customer, until a rider has the food; the restaurant, between accepting and handing over. The refund depends on how far the order got.
- How is a rider chosen? The nearest free rider, for now; the rule must be replaceable.
- What if no rider is free? The order waits in a queue, and the next free rider takes the oldest order.
Requirements and what is out of scope
- R1 A restaurant has a place, a menu of items with prices in paise and a preparation time.
- R2 An order belongs to one customer and one restaurant; its lines copy each item’s price when it is placed.
- R3 An order moves through placed, accepted, preparing, ready, picked up and delivered, and ends rejected or cancelled if it does not get there.
- R4 Each move is allowed only to its party: the restaurant accepts, rejects, starts, marks ready and may cancel while accepted or preparing; the rider picks up and delivers; the customer may cancel until the pick-up.
- R5 An order not accepted within 5 minutes is rejected by the system.
- R6 A cancellation refunds what the refund table gives for the order’s state at that moment, once.
- R7 Every move is recorded with its minute and its party.
- R8 A move the table does not allow is refused, and the order does not change.
- R9 A rider is chosen by a strategy when the restaurant accepts; with no rider free, the order waits, oldest first.
- R10 A rider carries one order at a time.
- N1 Time comes from an injected clock, never from the system. N2 Money is whole paise. N3 Every change to one order happens under that order’s lock.
Out of scope: the payment itself (the model records what to refund), search, ratings and real street maps.
Classes, and who owns which rule
- Order (entity): the lines, the fee, the state, the rider and the history. It owns R3, R4 and R8 through one
method,
move(event, actor, now), that looks the move up in the transition table. - Restaurant, MenuItem and Line: the menu and what was bought at which price (R1, R2).
- Rider (entity): a place on the street grid and a state of its own: available, riding, waiting or offline.
- OrderService: every use case of the three parties, the queue of orders without a rider and the clock (R5, R9).
- RefundPolicy (a table of data) and Dispatch (a strategy): what differs between businesses.
The food-delivery model's class diagram
Text description of the diagram
The diagram shows eight classes in UML notation. Each box names a class, then lists its attributes with their types and, below a line, its operations.
- OrderService has waiting, a queue of orders without a rider, and the operations place, which returns an Order, accept(id), start(id) and cancel(id, actor), which returns the refund. An arrow labelled "runs" leads from it to zero or more Orders, and two arrows labelled "uses" lead to exactly one Dispatch and exactly one RefundPolicy.
- Order has a state, a history of (minute, state, actor) entries, a delivery fee and a refund in paise, and the operation move(event, actor, now), which returns the state it left. A filled diamond shows that an order is composed of one or more Lines; each Line has a menu item and a quantity.
- An arrow labelled "from" leads from Order to exactly one Restaurant, which has a place, a menu from code to menu item, and its preparation minutes. An arrow labelled "carried by" leads from Order to zero or one Rider, which has a place and a rider state.
- Dispatch is an interface with the operation choose(order, riders, now), which returns a Rider. NearestAvailable realises it, shown by a dashed line with a hollow triangle.
- RefundPolicy has customer_rules, a map from state to refund rule, and the operation refund(order, stage, actor), which returns paise.
The order decides its own moves; the service decides what involves more than one object: finding a rider, timing out, refunding. A status string with setters lets any caller write any state, so a delivered order could be cancelled; a table of allowed moves makes every illegal move fail the same way.
The order lifecycle across three parties
A food order's lifecycle, and who moves it
Text description of the diagram
The diagram is a UML state machine with eight states drawn as rounded boxes: placed, accepted, preparing, ready, picked up, delivered, rejected and cancelled. Each arrow is labelled with who makes the move and the event.
- A filled dot marks the start. The arrow "customer: place" leads from it to placed.
- From placed, "restaurant: accept" leads to accepted. "Restaurant: reject" and "system: no answer in 5 min" both lead to rejected. "Customer: cancel" leads to cancelled.
- From accepted, "restaurant: start" leads to preparing, and "customer or restaurant: cancel" leads to cancelled.
- From preparing, "restaurant: ready" leads to ready, and "customer or restaurant: cancel" leads to cancelled.
- From ready, "rider: pick up" leads to picked up, and "customer: cancel" leads to cancelled.
- From picked up, "rider: deliver" leads to delivered. There is no cancel arrow from picked up.
- No arrow leaves delivered, rejected or cancelled: they are final.
Cancelling is the move with the most rules, so its refund is data, not code:
| State when the customer cancels | Food refunded | Delivery fee refunded |
|---|---|---|
| placed | 100 % | 100 % |
| accepted | 100 % | 100 % |
| preparing | 50 % | 100 % |
| ready | 0 % | 0 % |
| picked up or later | the cancel is refused | the cancel is refused |
When the restaurant rejects or cancels, or the order times out, the customer gets everything back. Amounts are rounded down to the paisa.
The first complete design, run on a lunch hour
The script plays one lunch hour in a city of 250-metre blocks, where a rider covers one block a minute: three
restaurants, four riders and 13 orders. It says only what people do and when; kitchens and riders move by
themselves, through a scheduler that runs events in time order with heapq.
delivery/lunch.py
"""A lunch hour of the food-delivery model: three restaurants, four riders and 13 orders, with a cancellation at
every stage, a rejection, an order that times out, a cancel refused after pick-up and an order that waits for a
rider. The kitchens and the riders move by themselves; the script below only says what people do, and when.
Every refusal is printed, and the hour goes on. Then each order's history and money are listed.
"""
from model import Actor, MenuItem, Place, Refused, Restaurant, Rider, clock_text, rupees
from policies import NearestAvailable, RefundPolicy
from service import OrderService, Scheduler
def menu(*items: MenuItem) -> dict[str, MenuItem]:
return {i.code: i for i in items}
TANDOOR = Restaurant("T", "Tandoor House", Place(2, 3), menu(
MenuItem("PT", "paneer tikka", 28900), MenuItem("DM", "dal makhani", 24900), MenuItem("NA", "naan", 4900)), prep_minutes=15)
DOSA = Restaurant("D", "Dosa Corner", Place(8, 1), menu(
MenuItem("MD", "masala dosa", 12900), MenuItem("ID", "idli", 8900), MenuItem("FC", "filter coffee", 4900)), prep_minutes=10)
BOWL = Restaurant("G", "Green Bowl", Place(5, 7), menu(
MenuItem("SB", "salad bowl", 23900), MenuItem("SM", "smoothie", 14900)), prep_minutes=12)
RIDERS = [Rider("r1", "Imran", Place(0, 0)), Rider("r2", "Sunita", Place(6, 2)), Rider("r3", "Joseph", Place(4, 8)),
Rider("r4", "Lakshmi", Place(13, 12))]
FEE = 3500
clock = Scheduler()
app = OrderService([TANDOOR, DOSA, BOWL], RIDERS, NearestAvailable(), RefundPolicy(), clock)
script = [
(0, lambda: app.place("O1", "asha", "T", {"PT": 1, "NA": 2}, Place(1, 9), FEE)),
(1, lambda: app.accept("O1")),
(2, lambda: app.start("O1")),
(2, lambda: app.place("O2", "ravi", "D", {"MD": 2, "FC": 2}, Place(11, 4), FEE)),
(3, lambda: app.accept("O2")),
(3, lambda: app.start("O2")),
(4, lambda: app.place("O3", "meera", "G", {"SB": 1, "SM": 1}, Place(7, 11), FEE)),
(5, lambda: app.cancel("O3")), # while placed
(5, lambda: app.place("O4", "kabir", "T", {"DM": 1, "NA": 2}, Place(3, 0), FEE)),
(6, lambda: app.accept("O4")),
(7, lambda: app.cancel("O4")), # while accepted
(8, lambda: app.place("O5", "leela", "D", {"ID": 2, "FC": 1}, Place(10, 0), FEE)),
(9, lambda: app.accept("O5")),
(10, lambda: app.start("O5")),
(10, lambda: app.place("O6", "omar", "G", {"SB": 2}, Place(9, 9), FEE)),
(11, lambda: app.accept("O6")),
(11, lambda: app.start("O6")),
(12, lambda: app.place("O7", "priya", "T", {"PT": 2}, Place(0, 6), FEE)),
(13, lambda: app.reject("O7")), # the restaurant has run out of paneer
(14, lambda: app.cancel("O5")), # while preparing
(15, lambda: app.place("O8", "dev", "D", {"MD": 1}, Place(12, 3), FEE)), # the restaurant never answers
(18, lambda: app.place("O9", "farah", "G", {"SB": 1, "SM": 2}, Place(2, 10), FEE)),
(19, lambda: app.accept("O9")),
(19, lambda: app.start("O9")),
(20, lambda: app.place("O10", "hari", "T", {"DM": 2, "NA": 4}, Place(4, 1), FEE)),
(21, lambda: app.accept("O10")),
(22, lambda: app.start("O10")),
(23, lambda: app.cancel("O6")), # while ready, with a rider on the way
(24, lambda: app.place("O11", "jaya", "D", {"MD": 1, "FC": 1}, Place(9, 4), FEE)),
(24, lambda: app.accept("O11")),
(24, lambda: app.start("O11")),
(25, lambda: app.place("O12", "kiran", "D", {"ID": 3}, Place(6, 0), FEE)),
(25, lambda: app.accept("O12")),
(26, lambda: app.start("O12")),
(26, lambda: app.place("O13", "ira", "T", {"PT": 1, "NA": 1}, Place(5, 4), FEE)),
(27, lambda: app.accept("O13")), # every rider is busy
(27, lambda: app.start("O13")),
(30, lambda: app.cancel("O10", Actor.RESTAURANT)), # the kitchen ran out of dal
(34, lambda: app.cancel("O9")), # too late: the food is on its way
]
print("Tandoor House (15 min), Dosa Corner (10 min), Green Bowl (12 min); riders Imran, Sunita, Joseph, Lakshmi")
for minute, step in script:
clock.run_until(minute)
try:
step()
except Refused as refusal:
print(f"{clock_text(clock.now)} refused: {refusal}")
clock.run_until(120)
print("\nEach order: its history, what was paid and what came back")
for order in app.orders.values():
steps = ", ".join(f"{state.value} {clock_text(t)}" for t, state, _ in order.history)
print(f"{order.id:>3} {order.customer:<6} {steps}")
print(f" paid {rupees(order.paid)}, refunded {rupees(order.refund)}")
busy = ", ".join(f"{r.name} {r.busy_minutes} min" for r in RIDERS)
print(f"riding time: {busy}") Output
Tandoor House (15 min), Dosa Corner (10 min), Green Bowl (12 min); riders Imran, Sunita, Joseph, Lakshmi 12:00 O1 asha: placed at Tandoor House, 387.00 + 35.00 delivery = 422.00 12:01 O1 asha: accepted by Tandoor House 12:01 O1 asha: Imran assigned, 5 min from the restaurant 12:02 O1 asha: the kitchen starts; ready at 12:17 12:02 O2 ravi: placed at Dosa Corner, 356.00 + 35.00 delivery = 391.00 12:03 O2 ravi: accepted by Dosa Corner 12:03 O2 ravi: Sunita assigned, 3 min from the restaurant 12:03 O2 ravi: the kitchen starts; ready at 12:13 12:04 O3 meera: placed at Green Bowl, 388.00 + 35.00 delivery = 423.00 12:05 O3 meera: cancelled by the customer while placed; refund 423.00 of 423.00 12:05 O4 kabir: placed at Tandoor House, 347.00 + 35.00 delivery = 382.00 12:06 O1 asha: Imran waits at Tandoor House 12:06 O2 ravi: Sunita waits at Dosa Corner 12:06 O4 kabir: accepted by Tandoor House 12:06 O4 kabir: Joseph assigned, 7 min from the restaurant 12:07 O4 kabir: cancelled by the customer while accepted; refund 382.00 of 382.00 12:08 O5 leela: placed at Dosa Corner, 227.00 + 35.00 delivery = 262.00 12:09 O5 leela: accepted by Dosa Corner 12:09 O5 leela: Joseph assigned, 11 min from the restaurant 12:10 O5 leela: the kitchen starts; ready at 12:20 12:10 O6 omar: placed at Green Bowl, 478.00 + 35.00 delivery = 513.00 12:11 O6 omar: accepted by Green Bowl 12:11 O6 omar: Lakshmi assigned, 13 min from the restaurant 12:11 O6 omar: the kitchen starts; ready at 12:23 12:12 O7 priya: placed at Tandoor House, 578.00 + 35.00 delivery = 613.00 12:13 O2 ravi: ready; Sunita is already waiting 12:13 O2 ravi: picked up by Sunita, 6 min to the customer 12:13 O7 priya: rejected by Tandoor House; refund 613.00 12:14 O5 leela: cancelled by the customer while preparing; refund 148.50 of 262.00 12:15 O8 dev: placed at Dosa Corner, 129.00 + 35.00 delivery = 164.00 12:17 O1 asha: ready; Imran is already waiting 12:17 O1 asha: picked up by Imran, 7 min to the customer 12:18 O9 farah: placed at Green Bowl, 537.00 + 35.00 delivery = 572.00 12:19 O2 ravi: delivered by Sunita, 17 min after it was placed 12:19 O9 farah: accepted by Green Bowl 12:19 O9 farah: Joseph assigned, 2 min from the restaurant 12:19 O9 farah: the kitchen starts; ready at 12:31 12:20 O8 dev: not accepted within 5 min, so it is rejected; refund 164.00 12:20 O10 hari: placed at Tandoor House, 694.00 + 35.00 delivery = 729.00 12:21 O9 farah: Joseph waits at Green Bowl 12:21 O10 hari: accepted by Tandoor House 12:21 O10 hari: Sunita assigned, 10 min from the restaurant 12:22 O10 hari: the kitchen starts; ready at 12:37 12:23 O6 omar: ready; Lakshmi is on the way 12:23 O6 omar: cancelled by the customer while ready; refund 0.00 of 513.00 12:24 O1 asha: delivered by Imran, 24 min after it was placed 12:24 O11 jaya: placed at Dosa Corner, 178.00 + 35.00 delivery = 213.00 12:24 O11 jaya: accepted by Dosa Corner 12:24 O11 jaya: Imran assigned, 15 min from the restaurant 12:24 O11 jaya: the kitchen starts; ready at 12:34 12:25 O12 kiran: placed at Dosa Corner, 267.00 + 35.00 delivery = 302.00 12:25 O12 kiran: accepted by Dosa Corner 12:25 O12 kiran: Lakshmi assigned, 16 min from the restaurant 12:26 O12 kiran: the kitchen starts; ready at 12:36 12:26 O13 ira: placed at Tandoor House, 338.00 + 35.00 delivery = 373.00 12:27 O13 ira: accepted by Tandoor House 12:27 O13 ira: no rider is free; the order waits for one 12:27 O13 ira: the kitchen starts; ready at 12:42 12:30 O10 hari: cancelled by the restaurant while preparing; refund 729.00 of 729.00 12:30 O13 ira: Sunita assigned, 10 min from the restaurant 12:31 O9 farah: ready; Joseph is already waiting 12:31 O9 farah: picked up by Joseph, 6 min to the customer 12:34 O11 jaya: ready; Imran is on the way 12:34 refused: O9: a customer cannot cancel an order that is picked up 12:36 O12 kiran: ready; Lakshmi is on the way 12:37 O9 farah: delivered by Joseph, 19 min after it was placed 12:39 O11 jaya: picked up by Imran, 4 min to the customer
Text longer than 4,000 characters is cut here; run the example yourself to see all of it.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lunch.py
delivery/service.py
"""The order service of the food-delivery model: every use case of the customer, the restaurant and the rider goes
through it, and it moves time with a scheduler it is given (N1).
Scheduler a clock that runs events in time order (heapq), ties in the order they were added
OrderService place, accept, reject, start, cancel; the kitchen and the riders move by themselves
"""
from __future__ import annotations
from collections import deque
from heapq import heappop, heappush
from itertools import count
from model import Actor, Line, Order, Place, Refused, Restaurant, Rider, RiderState, State, clock_text, rupees
from policies import Dispatch, RefundPolicy
class Scheduler:
def __init__(self) -> None:
self.now = 0
self._queue: list = []
self._seq = count()
def at(self, minute: int, action) -> None:
heappush(self._queue, (minute, next(self._seq), action))
def run_until(self, minute: int) -> None:
while self._queue and self._queue[0][0] <= minute:
self.now, _, action = heappop(self._queue)
action()
self.now = max(self.now, minute)
class OrderService:
ACCEPT_WITHIN = 5 # R5: minutes a restaurant has to accept an order before it times out
READY_ALERT = 10 # minutes food may wait at the counter with no rider before someone is told
def __init__(self, restaurants: list[Restaurant], riders: list[Rider], dispatch: Dispatch, refunds: RefundPolicy,
scheduler: Scheduler, log=print) -> None:
self.restaurants = {r.id: r for r in restaurants}
self.riders = riders
self.dispatch = dispatch
self.refunds = refunds
self.clock = scheduler
self.orders: dict[str, Order] = {}
self.waiting: deque[Order] = deque() # accepted orders without a rider, oldest first (R9)
self.alerts: list[str] = []
self.log = log
def _say(self, order: Order, text: str) -> None:
self.log(f"{clock_text(self.clock.now)} {order.id} {order.customer}: {text}")
# ---- the customer --------------------------------------------------------------------------------------------
def place(self, order_id: str, customer: str, restaurant_id: str, items: dict[str, int], drop: Place, fee: int) -> Order:
restaurant = self.restaurants[restaurant_id]
unknown = [code for code in items if code not in restaurant.menu]
if unknown or not items:
raise Refused(f"{order_id}: {restaurant.name} has no {', '.join(unknown) or 'items in this order'}")
lines = tuple(Line(restaurant.menu[code], qty) for code, qty in items.items()) # R2: prices copied now
order = Order(order_id, customer, restaurant, drop, lines, fee, self.clock.now)
order.history.append((self.clock.now, State.PLACED, Actor.CUSTOMER))
self.orders[order_id] = order
self._say(order, f"placed at {restaurant.name}, {rupees(order.items)} + {rupees(fee)} delivery = {rupees(order.paid)}")
self.clock.at(self.clock.now + self.ACCEPT_WITHIN, lambda: self._time_out(order))
return order
def cancel(self, order_id: str, actor: Actor = Actor.CUSTOMER) -> int:
order = self.orders[order_id]
stage = order.state
order.move("cancel", actor, self.clock.now) # R4: refused once the food is on its way
refund = self._refund(order, stage, actor)
self._say(order, f"cancelled by the {actor.value} while {stage.value}; refund {rupees(refund)} of {rupees(order.paid)}")
self._release_rider(order)
return refund
# ---- the restaurant ----------------------------------------------------------------------------------------
def accept(self, order_id: str) -> None:
order = self.orders[order_id]
order.move("accept", Actor.RESTAURANT, self.clock.now)
self._say(order, f"accepted by {order.restaurant.name}")
self._find_rider(order)
def reject(self, order_id: str) -> None:
order = self.orders[order_id]
order.move("reject", Actor.RESTAURANT, self.clock.now)
refund = self._refund(order, State.PLACED, Actor.RESTAURANT)
self._say(order, f"rejected by {order.restaurant.name}; refund {rupees(refund)}")
def start(self, order_id: str) -> None:
order = self.orders[order_id]
order.move("start", Actor.RESTAURANT, self.clock.now)
ready_at = self.clock.now + order.restaurant.prep_minutes
self._say(order, f"the kitchen starts; ready at {clock_text(ready_at)}")
self.clock.at(ready_at, lambda: self._ready(order))
def _ready(self, order: Order) -> None:
if order.state is not State.PREPARING: # cancelled while it cooked: nothing to announce
return
order.move("ready", Actor.RESTAURANT, self.clock.now)
self.clock.at(self.clock.now + self.READY_ALERT, lambda: self._still_waiting(order))
rider = order.rider
if rider and rider.state is RiderState.WAITING:
self._say(order, f"ready; {rider.name} is already waiting")
self._pick_up(rider, order)
else:
self._say(order, "ready" + (f"; {rider.name} is on the way" if rider else "; no rider yet"))
# ---- riders ------------------------------------------------------------------------------------------------
def _find_rider(self, order: Order) -> None:
rider = self.dispatch.choose(order, self.riders, self.clock.now)
if rider is None:
self.waiting.append(order)
self._say(order, "no rider is free; the order waits for one")
return
rider.state, rider.order, order.rider = RiderState.TO_RESTAURANT, order, rider
minutes = rider.place.blocks_to(order.restaurant.place)
rider.busy_minutes += minutes
self._say(order, f"{rider.name} assigned, {minutes} min from the restaurant")
self.clock.at(self.clock.now + minutes, lambda: self._at_restaurant(rider, order))
def _at_restaurant(self, rider: Rider, order: Order) -> None:
if rider.order is not order: # released on the way
return
rider.place = order.restaurant.place
if order.state is State.READY:
self._pick_up(rider, order)
else:
rider.state = RiderState.WAITING
self._say(order, f"{rider.name} waits at {order.restaurant.name}")
def _pick_up(self, rider: Rider, order: Order) -> None:
order.move("pick up", Actor.RIDER, self.clock.now)
rider.state = RiderState.TO_CUSTOMER
minutes = order.restaurant.place.blocks_to(order.drop)
rider.busy_minutes += minutes
self._say(order, f"picked up by {rider.name}, {minutes} min to the customer")
self.clock.at(self.clock.now + minutes, lambda: self._deliver(rider, order))
def _deliver(self, rider: Rider, order: Order) -> None:
if rider.state is RiderState.OFFLINE: # only the rider's app can report a delivery
return
order.move("deliver", Actor.RIDER, self.clock.now)
rider.place = order.drop
self._say(order, f"delivered by {rider.name}, {self.clock.now - order.placed_at} min after it was placed")
self._free(rider)
def _release_rider(self, order: Order) -> None:
if order in self.waiting:
self.waiting.remove(order)
rider = order.rider
if rider and rider.order is order:
if rider.state is RiderState.WAITING:
rider.place = order.restaurant.place
self._free(rider)
def _free(self, rider: Rider) -> None:
rider.state, rider.order = RiderState.AVAILABLE, None
if self.waiting: # R9: the oldest order without a rider is served first
self._find_rider(self.waiting.popleft())
def rider_offline(self, rider_id: str) -> None:
"""A rider's app stops answering. Before pick-up the order goes back to the front of the queue; after it, the
food is with that rider, so nobody else can deliver it and people are told instead."""
rider = next(r for r in self.riders if r.id == rider_id)
order, rider.order, rider.state = rider.order, None, RiderState.OFFLINE
if order is None:
return
if order.state is State.PICKED_UP:
self.alerts.append(f"{order.id}: {rider.name} went offline carrying the food")
self._say(order, f"{rider.name} went offline with the food; support and the customer are told")
return
order.rider = None
self._say(order, f"{rider.name} went offline before the pick-up; the order goes back to the front of the queue")
self.waiting.appendleft(order)
free = [r for r in self.riders if r.state is RiderState.AVAILABLE]
if free:
self._find_rider(self.waiting.popleft())
def _still_waiting(self, order: Order) -> None:
if order.state is State.READY and (order.rider is None or order.rider.state is not RiderState.WAITING):
self.alerts.append(f"{order.id}: ready for {self.READY_ALERT} min with no rider at the counter")
self._say(order, f"ready for {self.READY_ALERT} min with no rider at the counter; the customer is told")
# ---- time and money ------------------------------------------------------------------------------------------
def _time_out(self, order: Order) -> None:
if order.state is State.PLACED:
order.move("time out", Actor.SYSTEM, self.clock.now)
refund = self._refund(order, State.PLACED, Actor.SYSTEM)
self._say(order, f"not accepted within {self.ACCEPT_WITHIN} min, so it is rejected; refund {rupees(refund)}")
def _refund(self, order: Order, stage: State, actor: Actor) -> int:
if not order.refunded: # R6: one refund per order, whatever happens to the request
order.refund, order.refunded = self.refunds.refund(order, stage, actor), True
return order.refund delivery/model.py
"""Restaurants, menus, orders and riders of the food-delivery model (lesson lld/food-ordering-and-delivery), and the
order's lifecycle as a transition table. Each rule names its requirement (R1 to R10 in the lesson).
Money is whole paise and time is whole minutes from an injected clock (N1, N2). A place is a point on a street grid
of 250-metre blocks, and a rider covers one block a minute, so a distance in blocks is also a time in minutes.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
def rupees(paise: int) -> str:
sign = "-" if paise < 0 else ""
return f"{sign}{abs(paise) // 100:,}.{abs(paise) % 100:02d}"
def clock_text(minute: int) -> str:
"""Minutes after 12:00 as a time of day: 7 -> "12:07"."""
return f"{12 + minute // 60}:{minute % 60:02d}"
@dataclass(frozen=True)
class Place:
x: int
y: int
def blocks_to(self, other: Place) -> int:
return abs(self.x - other.x) + abs(self.y - other.y) # streets on a grid: no diagonals
@dataclass(frozen=True)
class MenuItem:
code: str
name: str
price: int # paise
@dataclass
class Restaurant:
id: str
name: str
place: Place
menu: dict[str, MenuItem]
prep_minutes: int
class State(Enum):
PLACED = "placed"
ACCEPTED = "accepted"
PREPARING = "preparing"
READY = "ready"
PICKED_UP = "picked up"
DELIVERED = "delivered"
REJECTED = "rejected"
CANCELLED = "cancelled"
class Actor(Enum):
CUSTOMER = "customer"
RESTAURANT = "restaurant"
RIDER = "rider"
SYSTEM = "system"
S, A = State, Actor
# R3, R4: the only moves an order can make, and who may make each one. Anything else is refused (R8).
TRANSITIONS: dict[tuple[State, str, Actor], State] = {
(S.PLACED, "accept", A.RESTAURANT): S.ACCEPTED,
(S.PLACED, "reject", A.RESTAURANT): S.REJECTED,
(S.PLACED, "time out", A.SYSTEM): S.REJECTED,
(S.ACCEPTED, "start", A.RESTAURANT): S.PREPARING,
(S.PREPARING, "ready", A.RESTAURANT): S.READY,
(S.READY, "pick up", A.RIDER): S.PICKED_UP,
(S.PICKED_UP, "deliver", A.RIDER): S.DELIVERED,
**{(s, "cancel", A.CUSTOMER): S.CANCELLED for s in (S.PLACED, S.ACCEPTED, S.PREPARING, S.READY)},
**{(s, "cancel", A.RESTAURANT): S.CANCELLED for s in (S.ACCEPTED, S.PREPARING)},
}
FINAL = {S.DELIVERED, S.REJECTED, S.CANCELLED}
class Refused(Exception):
"""A request the rules refuse; the order is left exactly as it was."""
@dataclass(frozen=True)
class Line:
item: MenuItem
qty: int
@property
def amount(self) -> int:
return self.item.price * self.qty
@dataclass
class Order:
id: str
customer: str
restaurant: Restaurant
drop: Place
lines: tuple[Line, ...]
delivery_fee: int
placed_at: int
state: State = State.PLACED
rider: Rider | None = None
history: list[tuple[int, State, Actor]] = field(default_factory=list)
refund: int = 0
refunded: bool = False
@property
def items(self) -> int:
return sum(line.amount for line in self.lines) # R2: prices copied into the lines when the order was placed
@property
def paid(self) -> int:
return self.items + self.delivery_fee
def move(self, event: str, actor: Actor, now: int) -> State:
"""R8: apply one event of the table, or refuse it and change nothing."""
target = TRANSITIONS.get((self.state, event, actor))
if target is None:
raise Refused(f"{self.id}: a {actor.value} cannot {event} an order that is {self.state.value}")
before = self.state
self.state = target
self.history.append((now, target, actor)) # R7
return before
class RiderState(Enum):
AVAILABLE = "available"
TO_RESTAURANT = "riding to the restaurant"
WAITING = "waiting for the food"
TO_CUSTOMER = "riding to the customer"
OFFLINE = "offline"
@dataclass(eq=False)
class Rider:
id: str
name: str
place: Place
state: RiderState = RiderState.AVAILABLE
order: Order | None = None
busy_minutes: int = 0 delivery/policies.py
"""What varies between food-delivery businesses, kept out of the order: the refund owed when an order is cancelled
(R6, a table of data) and the choice of a rider (R9, a strategy).
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
from model import Actor, Order, Rider, RiderState, State
@dataclass(frozen=True)
class RefundRule:
items_percent: int
fee_percent: int
# R6: what a customer who cancels gets back, by the order's state at that moment. Rounded down to the paisa.
CUSTOMER_CANCELS = {
State.PLACED: RefundRule(100, 100),
State.ACCEPTED: RefundRule(100, 100), # the kitchen has not started
State.PREPARING: RefundRule(50, 100), # half the food is paid for; no rider has carried anything
State.READY: RefundRule(0, 0), # the food is made and a rider is on the way or waiting
}
# R6: when the restaurant rejects or cancels, or the order times out, the customer gets everything back.
NOT_THE_CUSTOMER = RefundRule(100, 100)
class RefundPolicy:
def __init__(self, customer_rules: dict[State, RefundRule] | None = None) -> None:
self.customer_rules = customer_rules or CUSTOMER_CANCELS
def refund(self, order: Order, stage: State, actor: Actor) -> int:
rule = self.customer_rules[stage] if actor is Actor.CUSTOMER else NOT_THE_CUSTOMER
return order.items * rule.items_percent // 100 + order.delivery_fee * rule.fee_percent // 100
class Dispatch(Protocol):
name: str
def choose(self, order: Order, riders: list[Rider], now: int) -> Rider | None: ...
class NearestAvailable:
"""R9: the available rider closest to the restaurant, by blocks; a tie goes to the rider listed first."""
name = "nearest available rider"
def choose(self, order: Order, riders: list[Rider], now: int) -> Rider | None:
free = [r for r in riders if r.state is RiderState.AVAILABLE]
return min(free, key=lambda r: r.place.blocks_to(order.restaurant.place), default=None) Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
What to read in the output:
- O1 and O2 arrive in 24 and 17 minutes. Imran waits 11 minutes at Tandoor House: he was sent at acceptance, and the kitchen needed 15 minutes.
- O3 to O6 are cancelled at every stage: placed and accepted give back everything, preparing gives back 148.50 of 262.00, and ready gives back nothing.
- O7 is rejected by the restaurant and O8 times out after 5 minutes; both are refunded in full.
- O9 cannot be cancelled after the pick-up, and O10 is cancelled by the restaurant while it cooks.
- O13 finds every rider busy and waits; when O10 is cancelled at 12:30, Sunita is freed and takes it at once.
Imran’s 11 minutes at the counter come from the simplest of three dispatch timings: this run sends a rider the moment a restaurant accepts. Sending a rider just in time means waiting until the rider would reach the counter about when the food is ready. Batching puts two orders from one restaurant on a single trip.
Python Online Compiler Paste the four files, then add a fifth rider and see which orders arrive sooner.Self-check and an extension exercise
The quiz checks the edges of the run. The exercise builds such a batch for one rider, as long as both orders still arrive within the promised time.
Check yourself
5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Exercise · Medium · Python
Batch two orders for one rider without breaking a delivery promise
A rider who is going to a restaurant anyway could carry a second order from it. Write plan_batch(first, second, rider_at, now, promise=40) in batching.py. It returns a Plan for one rider to carry both orders, or None when the batch is not allowed. Places are blocks of a street grid, and a rider covers one block a minute, so blocks_to() is also a number of minutes.
- E1 Both orders come from the same restaurant (the same
Place); otherwise there is no batch. - E2 Their ready times are at most 5 minutes apart; otherwise there is no batch.
- E3 The rider leaves
rider_atat minutenowand picks both orders up at the later of its arrival and the two ready times: that minute ispick_up_at. - E4 Of the two orders in which the drops can be made, prefer the one with the shorter ride from the restaurant. When both are equally long, deliver first the order that was placed first.
- E5 Each order must arrive within its promise:
delivered_at <= placed_at + promise. If the preferred drop order breaks a promise, use the other one; if neither keeps both promises, returnNone.
A Plan lists the order ids in the order they are delivered (drops), pick_up_at, and delivered_at, a dict from order id to the minute it arrives.
Starter code · batching.py
"""Batching for the food-delivery model of lld/food-ordering-and-delivery: may one rider carry a second order, and
in which order are the two drops made? Places are blocks of a street grid, and a rider covers one block a minute.
Write plan_batch(): the rules E1 to E5 are in the exercise's prompt, and the sample tests name them.
"""
from dataclasses import dataclass
PROMISE = 40 # minutes from placing an order to its delivery
@dataclass(frozen=True)
class Place:
x: int
y: int
def blocks_to(self, other: "Place") -> int:
return abs(self.x - other.x) + abs(self.y - other.y)
@dataclass(frozen=True)
class Order:
id: str
restaurant: Place
drop: Place
placed_at: int
ready_at: int
@dataclass(frozen=True)
class Plan:
drops: tuple[str, ...] # order ids in the order they are delivered
pick_up_at: int
delivered_at: dict[str, int] # order id -> the minute it is delivered
def plan_batch(first: Order, second: Order, rider_at: Place, now: int, promise: int = PROMISE) -> Plan | None:
"""E1 to E5: a plan for one rider to carry both orders, or None."""
raise NotImplementedError The sample tests · test_batching.py
from batching import Order, Place, Plan, plan_batch
KITCHEN = Place(5, 5)
def order(oid, placed, ready, drop, restaurant=KITCHEN):
return Order(oid, restaurant, drop, placed_at=placed, ready_at=ready)
def test_e4_the_shorter_route_is_chosen():
"""A's drop is 5 blocks away and B's 4: delivering B first is 13 blocks, A first 14"""
a, b = order("A", 0, 12, Place(5, 0)), order("B", 2, 14, Place(9, 5))
assert plan_batch(a, b, rider_at=Place(5, 8), now=10) == Plan(("B", "A"), 14, {"B": 18, "A": 27})
def test_e1_two_restaurants_are_not_a_batch():
"""the second order comes from another kitchen"""
a, b = order("A", 0, 12, Place(5, 0)), order("B", 2, 14, Place(9, 5), restaurant=Place(6, 5))
assert plan_batch(a, b, rider_at=Place(5, 8), now=10) is None
def test_e2_ready_times_too_far_apart():
"""food ready at 12 and at 18 is 6 minutes apart: the first would wait too long"""
a, b = order("A", 0, 12, Place(5, 0)), order("B", 2, 18, Place(9, 5))
assert plan_batch(a, b, rider_at=Place(5, 8), now=10) is None
def test_e2_five_minutes_apart_is_allowed():
"""food ready at 12 and at 17 may still ride together"""
a, b = order("A", 0, 12, Place(5, 0)), order("B", 2, 17, Place(9, 5))
plan = plan_batch(a, b, rider_at=Place(5, 8), now=10)
assert plan is not None and plan.pick_up_at == 17
def test_e3_the_rider_waits_for_the_later_food():
"""the rider is at the counter at 10; the food is ready at 12 and 16, so both leave at 16"""
a, b = order("A", 0, 12, Place(5, 0)), order("B", 2, 16, Place(9, 5))
assert plan_batch(a, b, rider_at=KITCHEN, now=10).pick_up_at == 16
def test_e3_the_food_waits_for_a_far_rider():
"""a rider 40 blocks away arrives at 50, long after the food is ready"""
a, b = order("A", 0, 12, Place(5, 0)), order("B", 2, 14, Place(9, 5))
plan = plan_batch(a, b, rider_at=Place(25, 25), now=10, promise=100)
assert plan.pick_up_at == 50 and plan.delivered_at == {"B": 54, "A": 63}
def test_e4_a_tie_goes_to_the_order_placed_first():
"""both routes are 12 blocks, so B, placed at minute 1, is delivered before A, placed at 3"""
a, b = order("A", 3, 12, Place(5, 9)), order("B", 1, 12, Place(5, 1))
assert plan_batch(a, b, rider_at=KITCHEN, now=12).drops == ("B", "A")
def test_e5_the_shorter_route_would_break_a_promise():
"""B first is shorter, but A, placed at 0, would arrive at 41; A first keeps both promises"""
a, b = order("A", 0, 28, Place(5, 0)), order("B", 25, 30, Place(5, 8))
assert plan_batch(a, b, rider_at=KITCHEN, now=30) == Plan(("A", "B"), 30, {"A": 35, "B": 43})
def test_e5_no_route_keeps_both_promises():
"""leaving at 36, A arrives at 41 at the earliest: no batch"""
a, b = order("A", 0, 28, Place(5, 0)), order("B", 25, 30, Place(5, 8))
assert plan_batch(a, b, rider_at=KITCHEN, now=36) is None
def test_e5_a_longer_promise_allows_the_shorter_route():
"""with a promise of 50 minutes, B first is allowed again: B at 39, A at 47"""
a, b = order("A", 0, 28, Place(5, 0)), order("B", 25, 30, Place(5, 8))
assert plan_batch(a, b, rider_at=KITCHEN, now=36, promise=50) == Plan(("B", "A"), 36, {"B": 39, "A": 47}) A hint
Compute the pick-up minute once, then write a small helper that rides one order of drops and returns its length and the minute of each delivery: the first drop is pick_up_at plus the blocks from the restaurant, and the second adds the blocks between the two drops. Put the two orders in the order they were placed before you sort the two routes by length: a stable sort then keeps the earlier order first when the lengths tie. Try the routes in that order and return the first that keeps both promises.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Cancellations and refunds, with the race and the retries that break them Pro: opens with a Pro, Premium or Ultimate pass
A cancel is easy until it meets another request for the same order or a payment provider that does not answer. This part tests the refund table row by row, reproduces the race between a cancel and an accept on real threads, and shows a refund retried until the customer is paid three times, then once.
In this part:
- The refund table, tested row by row
- Cancel against accept: the race reproduced on threads
- A cancel sent twice and a refund retried, paid once
2 runnable examples (Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; nothing renews unless you choose to. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Read a complete sample first: Low-level design: parking lot (the model)
Assigning riders: at acceptance, just in time or in batches, measured Pro: opens with a Pro, Premium or Ultimate pass
The nearest free rider is a fine first rule, but it is only one of three choices: which rider, when, and with which other orders. This part measures three policies on the same seeded lunch hour and reads what the numbers say about the design.
In this part:
- Dispatch as a strategy, and when to call it
- One lunch hour under three policies
- What the numbers say, and what the model leaves out
1 runnable example (Python) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; nothing renews unless you choose to. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Failure drills, follow-ups, a level rubric and a self-mock Pro: opens with a Pro, Premium or Ultimate pass
A delivery round usually closes with accidents and changed requirements. Three failure drills run against the service here, then the usual follow-ups are mapped to the classes they touch, and a rubric sorts answers by level.
In this part:
- Three failure drills, run
- Follow-up questions and where each change lands
- Patterns used and rejected
- What each level is expected to cover, as a rubric
- A 45-minute self-mock
1 runnable example (Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; nothing renews unless you choose to. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher to mid · Warm-up
Why model a food order's lifecycle as a table of allowed moves instead of a status field with setters?
Because three parties and a timer change the same order, and each may make only some moves from some states. A status field with setters lets any caller write any value, so a delivered order can be cancelled or a rejected one picked up, and the rules end up scattered over every caller. A table keyed by the current state, the event and the party lists every allowed move in one place: one method looks the move up, refuses anything missing with the same error and leaves the order unchanged, and records each move with its minute and its party. Adding a state or a party becomes one new row, and a test can walk the table to check every pair.
-
Mid to senior
A customer taps cancel at the same moment the restaurant taps accept on the same order. What can go wrong, what should each side end up seeing, and how does the design make the outcome consistent?
-
Mid to senior
A restaurant rejects an order after the customer has paid, and the refund call to the payment provider times out. Design the refund path so that the customer is paid back exactly once, whatever fails.
-
Senior to staff
At lunch, sending the nearest free rider the moment a restaurant accepts leaves riders waiting at counters while other orders wait for a rider. What would you change in the design, and how would you show that it helped?
Key takeaways
- Keep one Order with a transition table of moves and the party allowed to make each, so every illegal move fails the same way and every move is recorded.
- Copy prices into the order’s lines when it is placed, and keep the refund for each state in a table.
- Choose riders through a strategy, and let orders without a rider wait in a queue, oldest first.
- Drive time from a clock the code is given, so a lunch hour runs as a test.
References
- Unified Modeling Language (UML) specification, version 2.5.1 (state machines) (Object Management Group)
- heapq: Heap queue algorithm (Python Software Foundation)
- collections.deque (Python Software Foundation)
- threading: Lock and Barrier objects (Python Software Foundation)
- Pyodide: WebAssembly constraints (threading) (Pyodide project)
- Idempotent requests (Stripe)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress