Design a food delivery system
Restaurants, orders, riders and assignment with order state transitions.
- State machines and the State pattern — Behaviour that depends on a mode, without a wall of conditionals.
- Strategy — Swapping an algorithm at runtime by passing an object.
- Observer and event listeners — Telling interested objects something happened, without knowing who they are.
- From requirements to classes — Nouns, verbs and the use-case walk that finds the classes.
- Law of Demeter and tell-don't-ask — Talk to your neighbours, not your neighbours' insides.
01The design, in the six-step framework
1. Requirements and use cases
A food delivery system joins three actors around one order: the customer who places it, the restaurant that prepares it and the rider who carries it. The design question is how an order moves between them without each class knowing everyone's internals.
- A customer browses restaurants and menus and places an order.
- The restaurant accepts or rejects the order and marks it ready.
- The system assigns a rider by a pluggable rule (nearest, least busy).
- The rider picks up and delivers; the customer is notified at every change.
- A customer cancels only before the restaurant accepts.
Out of scope: payments (see the payment processor), maps and ETA, ratings. Non-functional constraints: order transitions are a strict state machine; the assignment rule is swappable; notifications are decoupled from the order.
2. Core entities and relationships
- Restaurant and MenuItem: what can be ordered and at what price.
- Order and OrderItem: the customer's selection, totals and a state with a transition table.
- Rider: location, availability and current order.
- AssignmentStrategy: an interface that picks a rider for an order.
- OrderObserver: anything that wants to know about transitions; a notifier implements it.
- DeliveryService: the orchestrator exposing the use cases.
Orders compose their items because an item line means nothing outside its order. Riders are aggregated: they exist whether or not an order references them, and an order holds at most one.
3. Interfaces and key methods
class AssignmentStrategy(Protocol):
def pick(self, order: Order, riders: list[Rider]) -> Optional[Rider]:
"""Choose an available rider or None if nobody can take it."""
class OrderObserver(Protocol):
def on_change(self, order: Order, old: OrderState, new: OrderState) -> None: ...
class Order:
def move(self, to: OrderState) -> OrderState:
"""Apply a legal transition and return the previous state; raise IllegalTransition."""
def total(self) -> int: ...
class DeliveryService:
def place(self, customer_id: str, restaurant_id: str, items: dict[str, int]) -> Order: ...
def accept(self, order_id: str) -> Order:
"""Restaurant accepts; a rider is assigned immediately if one is free."""
def ready(self, order_id: str) -> Order: ...
def picked_up(self, order_id: str) -> Order: ...
def deliver(self, order_id: str) -> Order:
"""Marks delivered and frees the rider."""
def cancel(self, order_id: str) -> Order: ...
Invariants: a rider is busy if and only if some order in ASSIGNED, PICKED_UP or READY references them; every transition notifies every observer exactly once; cancel is legal only in PLACED.
4. Design patterns and the choices behind them
Order lifecycle as a state table
PLACED, ACCEPTED, ASSIGNED, READY, PICKED_UP, DELIVERED, CANCELLED. A transition table checked in move is the minimal state machine; the states have the same behaviour and differ only in what comes next, so state classes would be ceremony.
Rider assignment as a strategy
Nearest rider, least busy rider, highest rated: the rule changes with the business. An AssignmentStrategy object keeps that rule out of the service; the service only asks for a pick.
Observers for notifications
The customer app, the restaurant screen and an analytics log all want to hear about transitions. DeliveryService publishes to observers after each move, so adding the analytics log is a new observer, not an edit to five methods.
Tell, don't ask
The service never reaches into order.rider.location.x. It tells the strategy to pick and tells the order to move. That is the law of Demeter in practice, and the reason the use-case methods stay five lines each; the nouns came straight from the requirements.
Two more decisions are worth stating. The restaurant's acceptance is a separate step from placement because kitchens reject orders for stock or hours, and the system should not search for a rider before the kitchen has committed. And READY can happen before ASSIGNED on a quiet night or after it on a busy one, which is why the table allows both orders; a candidate who forces one order has not thought about the second case.
5. Core flow and code
placebuilds anOrderin PLACED from menu items and notifies.acceptmoves to ACCEPTED, asks the strategy for a rider, and if one is free marks them busy and moves to ASSIGNED.ready,picked_upanddeliveradvance the state;deliverfrees the rider.- Every
_transitioncall notifies observers with the old and new state.
from __future__ import annotations
import itertools, math
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional, Protocol
class IllegalTransition(Exception): ...
class OrderState(Enum):
PLACED = "placed"; ACCEPTED = "accepted"; ASSIGNED = "assigned"; READY = "ready"
PICKED_UP = "picked_up"; DELIVERED = "delivered"; CANCELLED = "cancelled"
ALLOWED = {
OrderState.PLACED: {OrderState.ACCEPTED, OrderState.CANCELLED},
OrderState.ACCEPTED: {OrderState.ASSIGNED, OrderState.READY},
OrderState.ASSIGNED: {OrderState.READY},
OrderState.READY: {OrderState.ASSIGNED, OrderState.PICKED_UP},
OrderState.PICKED_UP: {OrderState.DELIVERED},
OrderState.DELIVERED: set(), OrderState.CANCELLED: set(),
}
@dataclass(frozen=True)
class MenuItem:
id: str
name: str
price: int
@dataclass
class Restaurant:
id: str
location: tuple[float, float]
menu: dict[str, MenuItem] = field(default_factory=dict)
@dataclass
class Rider:
id: str
location: tuple[float, float]
busy: bool = False
@dataclass
class OrderItem:
item: MenuItem
qty: int
@dataclass
class Order:
id: str
customer_id: str
restaurant: Restaurant
items: list[OrderItem]
state: OrderState = OrderState.PLACED
rider: Optional[Rider] = None
def total(self) -> int:
return sum(i.item.price * i.qty for i in self.items)
def move(self, to: OrderState) -> OrderState:
if to not in ALLOWED[self.state]:
raise IllegalTransition(f"{self.state.value} -> {to.value}")
old, self.state = self.state, to
return old
class AssignmentStrategy(Protocol):
def pick(self, order: Order, riders: list[Rider]) -> Optional[Rider]: ...
class NearestRider:
def pick(self, order: Order, riders: list[Rider]) -> Optional[Rider]:
free = [r for r in riders if not r.busy]
if not free:
return None
rx, ry = order.restaurant.location
return min(free, key=lambda r: math.hypot(r.location[0] - rx, r.location[1] - ry))
class OrderObserver(Protocol):
def on_change(self, order: Order, old: OrderState, new: OrderState) -> None: ...
class DeliveryService:
def __init__(self, assigner: AssignmentStrategy) -> None:
self.assigner = assigner
self.orders: dict[str, Order] = {}
self.riders: dict[str, Rider] = {}
self.observers: list[OrderObserver] = []
self._ids = itertools.count(1)
def _transition(self, order: Order, to: OrderState) -> Order:
old = order.move(to)
for o in self.observers:
o.on_change(order, old, to)
return order
def place(self, customer_id: str, restaurant: Restaurant, items: dict[str, int]) -> Order:
lines = [OrderItem(restaurant.menu[i], q) for i, q in items.items()]
order = Order(f"o{next(self._ids)}", customer_id, restaurant, lines)
self.orders[order.id] = order
for o in self.observers:
o.on_change(order, OrderState.PLACED, OrderState.PLACED)
return order
def _try_assign(self, order: Order) -> None:
rider = self.assigner.pick(order, list(self.riders.values()))
if rider is not None:
rider.busy, order.rider = True, rider
self._transition(order, OrderState.ASSIGNED)
def accept(self, order_id: str) -> Order:
order = self._transition(self.orders[order_id], OrderState.ACCEPTED)
self._try_assign(order)
return order
def ready(self, order_id: str) -> Order:
order = self._transition(self.orders[order_id], OrderState.READY)
if order.rider is None:
self._try_assign(order)
return order
def picked_up(self, order_id: str) -> Order:
return self._transition(self.orders[order_id], OrderState.PICKED_UP)
def deliver(self, order_id: str) -> Order:
order = self._transition(self.orders[order_id], OrderState.DELIVERED)
if order.rider:
order.rider.busy = False
return order
def cancel(self, order_id: str) -> Order:
return self._transition(self.orders[order_id], OrderState.CANCELLED)
6. Extensions, concurrency and what interviewers probe
- No rider available: the order stays ACCEPTED or READY and
_try_assignruns again when a rider frees up; add a call indeliverthat scans waiting orders, or an observer that does. - Batching two orders from one restaurant onto one rider is a new strategy that returns the rider already heading there; the order class gains nothing.
- Concurrency: two orders accepted at once may both pick the same free rider. Guard
_try_assignwith a lock around pick-and-mark-busy; it is the only check-then-act in the design. - Payments and refunds hang off transitions: an observer that charges on ACCEPTED and refunds on CANCELLED keeps money out of the order class.
- Test first: cancel after accept raises; deliver frees the rider; with two riders at distances 1 and 5 the nearest is picked; every transition produces exactly one observer call.
- State on the rider: today a rider is busy or free. A rider state machine (offline, available, en route to restaurant, delivering) mirrors the order and lets the strategy prefer riders already near the restaurant; the two machines are linked through the service, never directly.
- Common mistake: giving
Ridera reference back toDeliveryServiceso it can "complete" itself. Keep the riders dumb; the service drives.