The 100 problems, solved / Commerce and money / 084 · Design a food delivery system

Design a food delivery system

Restaurants, orders, riders and assignment with order state transitions.

Concepts this design leans on

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.
CatalogueOrdersPeople and rules****notifies0..1Restaurantid: strmenu: dictlocation: tupleMenuItemid: strname: strprice: intDeliveryServiceorders: dictriders: dictassigner: AssignmentStrategyobservers: listplace(customer, restaurant, items)accept(order_id)ready(order_id)deliver(order_id)Orderid: stritems: liststate: OrderStaterider: Rider | Nonemove(to)OrderItemitem: MenuItemqty: intRiderid: strlocation: tuplebusy: boolAssignmentStrategypick(order, riders)OrderObserveron_change(order, old, new)

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

  1. place builds an Order in PLACED from menu items and notifies.
  2. accept moves to ACCEPTED, asks the strategy for a rider, and if one is free marks them busy and moves to ASSIGNED.
  3. ready, picked_up and deliver advance the state; deliver frees the rider.
  4. Every _transition call 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_assign runs again when a rider frees up; add a call in deliver that 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_assign with 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 Rider a reference back to DeliveryService so it can "complete" itself. Keep the riders dumb; the service drives.

02Where this question was reported

  • Educative · Grokking LLD breakout session
  • Codemia · Codemia Hard
  • GitHub · awesome-low-level-design, Hard
  • Educative · Educative deep dive: State for order transitions, PaymentProcessor interface
  • GitHub · Machine coding 'FoodKart' (Flipkart)
  • thita.ai · thita.ai LLD sheet, applied