The 100 problems, solved / Booking, allocation and simulators / 055 · Design a restaurant management system

Design a restaurant management system

Tables, orders, kitchen queue, bills and tips as collaborating classes.

Concepts this design leans on

01The design, in the six-step framework

1. Requirements and use cases

A restaurant management system is a textbook OOD case; what separates a good answer is keeping each class to one job and making the order's life cycle explicit instead of a pile of booleans.

  • A waiter seats a party at a table and opens an order for it.
  • A waiter adds items to the order, with customisations (extra cheese, no onion), and sends it to the kitchen.
  • The kitchen takes the next order from its queue, marks items ready, and the waiter is notified.
  • A cashier closes the order into a bill with tax, service charge and a tip, and the table becomes free.
  • A manager edits the menu (price changes do not alter orders already placed).

Out of scope: reservations and the waitlist (see that problem), inventory, payments beyond recording the total. Constraints: an order's state transitions must be enforced (no sending a closed order); menu edits must not change past bills, so an order line snapshots its price; the kitchen queue should be fair (FIFO) and support several cooks.

2. Core entities and relationships

  • MenuItem: an immutable name and price; the Menu maps ids to current items.
  • OrderLine: one menu item at the price when ordered, a quantity and a list of Customisation values.
  • Order: lines plus a status enum and the transitions allowed from each state.
  • Table: id, seats, and the open order if any.
  • Kitchen: a FIFO of orders to cook and the cooks that drain it.
  • Bill: computed from an order with tax, service and tip; immutable once issued.
  • Restaurant: the facade used by the waiter and cashier; wires the parts and relays kitchen events.
FacadeOrder life cycleMenu and money0..*1..*1..*createsRestauranttablesmenukitchenlistenersopen_order(table_id)add_item(order_id, item_id, qty, customs)send(order_id)next_order()mark_ready(order_id)close(order_id, tip) -> BillOrderidtable_idlines: list[OrderLine]status: OrderStatusadd(line)transition(to)total()OrderLineitem_nameunit_priceqtycustomisationsprice()Kitchenqueue: deque[Order]enqueue(order)next_order()Menuitems: dict[str, MenuItem]get(item_id)set_price(item_id, price)BillsubtotaltaxservicetiptotalTableidseatsorder_id: str | None

An order composes its lines: a line cannot exist without its order, and a line copies the price so the Menu is only aggregated, not owned. The kitchen uses orders by reference; it does not own them, which is why closing an order is the restaurant's job, not the kitchen's.

3. Interfaces and key methods

from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Optional, Protocol

class OrderStatus(Enum):
    OPEN = auto()        # waiter is adding lines
    SENT = auto()        # in the kitchen queue
    COOKING = auto()     # a cook took it
    READY = auto()       # food is on the pass
    SERVED = auto()
    CLOSED = auto()

@dataclass(frozen=True)
class MenuItem:
    id: str
    name: str
    price: int           # minor units (paise, cents)

@dataclass(frozen=True)
class OrderLine:
    item_name: str
    unit_price: int      # snapshot at order time
    qty: int
    customisations: tuple = ()
    def price(self) -> int:
        return self.unit_price * self.qty

class OrderListener(Protocol):
    def on_status(self, order: "Order") -> None:
        """Called after every transition."""

class Order:
    def add(self, line: OrderLine) -> None:
        """Only while OPEN."""
    def transition(self, to: OrderStatus) -> None:
        """Raises InvalidTransition unless (status, to) is allowed."""
    def total(self) -> int:
        """Sum of line prices."""

Invariants: Order.status only moves along the allowed edges; a line's unit_price never changes after creation; a table has at most one open order; a bill is issued exactly once per order and the order is CLOSED in the same step.

4. Design patterns and the choices behind them

An explicit state machine for the order

The force: "sent", "ready", "served" and "closed" are flags that interact; a closed order must not be sent. A status enum with a table of allowed transitions is the state idea in its lightest form: one method, transition, enforces every rule and raises on an illegal move. The heavier State pattern with one class per state is not needed because the behaviour per state is tiny; the table is enough and is easier to read in an interview.

Observer for kitchen events

The force: when the kitchen marks an order ready, the waiter's device, the table display and the manager's dashboard all want to know, and the kitchen should not know about any of them. The restaurant registers OrderListener objects and calls on_status after every transition, which is the observer pattern. The rejected alternative, the kitchen calling the waiter directly, couples the two halves and breaks the moment a second consumer appears.

One job per class

The force: a 45-minute design tends to grow a Restaurant that does everything. Keeping money in Bill, cooking order in Kitchen, and transitions in Order follows single responsibility; the facade only wires them. Multiplicities on the diagram (one table to at most one open order, one order to many lines) are worth saying out loud, which is what the relationships notation is for; the nouns themselves come from the use-case walk.

5. Core flow and code

  1. The waiter calls open_order(table_id); the restaurant checks the table is free and creates an OPEN order linked to it.
  2. add_item looks the item up in the Menu, snapshots the price into an OrderLine and calls Order.add.
  3. send transitions the order to SENT and enqueues it in the Kitchen.
  4. A cook calls Restaurant.next_order, which pops from the Kitchen (transition to COOKING), and later mark_ready; the restaurant's listeners are told each time.
  5. The cashier calls close(order_id, tip): under its lock the restaurant moves a ready order through SERVED to CLOSED, builds the Bill and frees the table.
import itertools, threading
from collections import deque
from dataclasses import dataclass, field
from typing import Optional

class InvalidTransition(Exception):
    pass

ALLOWED = {
    OrderStatus.OPEN: {OrderStatus.SENT},
    OrderStatus.SENT: {OrderStatus.COOKING},
    OrderStatus.COOKING: {OrderStatus.READY},
    OrderStatus.READY: {OrderStatus.SERVED},
    OrderStatus.SERVED: {OrderStatus.CLOSED},
}

@dataclass
class Order:
    id: str
    table_id: int
    lines: list = field(default_factory=list)
    status: OrderStatus = OrderStatus.OPEN

    def add(self, line: OrderLine) -> None:
        if self.status is not OrderStatus.OPEN:
            raise InvalidTransition("order already sent")
        self.lines.append(line)

    def transition(self, to: OrderStatus) -> None:
        if to not in ALLOWED.get(self.status, set()):
            raise InvalidTransition(f"{self.status.name} -> {to.name}")
        self.status = to

    def total(self) -> int:
        return sum(l.price() for l in self.lines)

@dataclass(frozen=True)
class Bill:
    order_id: str
    subtotal: int
    tax: int
    service: int
    tip: int
    @property
    def total(self) -> int:
        return self.subtotal + self.tax + self.service + self.tip

class Menu:
    def __init__(self, items: list):
        self.items = {i.id: i for i in items}
    def get(self, item_id: str) -> MenuItem:
        return self.items[item_id]
    def set_price(self, item_id: str, price: int) -> None:
        old = self.items[item_id]
        self.items[item_id] = MenuItem(old.id, old.name, price)   # orders keep their snapshot

@dataclass
class Table:
    id: int
    seats: int
    order_id: Optional[str] = None

class Kitchen:
    def __init__(self):
        self.queue: deque = deque()
        self._lock = threading.Lock()
    def enqueue(self, order: Order) -> None:
        with self._lock:
            self.queue.append(order)
    def next_order(self) -> Optional[Order]:
        with self._lock:                       # several cooks share the queue
            if not self.queue:
                return None
            order = self.queue.popleft()
        order.transition(OrderStatus.COOKING)
        return order

class Restaurant:
    TAX_RATE, SERVICE_RATE = 5, 10            # percent

    def __init__(self, tables: list, menu: Menu):
        self.tables = {t.id: t for t in tables}
        self.menu = menu
        self.kitchen = Kitchen()
        self.orders: dict = {}
        self.listeners: list = []
        self._ids = itertools.count(1)
        self._lock = threading.RLock()        # one open order per table, one bill per order

    def _notify(self, order: Order) -> None:
        for l in self.listeners:
            l.on_status(order)

    def open_order(self, table_id: int) -> Order:
        table = self.tables[table_id]
        with self._lock:
            if table.order_id is not None:
                raise ValueError("table already has an open order")
            order = Order(f"O{next(self._ids)}", table_id)
            self.orders[order.id] = order
            table.order_id = order.id
            return order

    def add_item(self, order_id: str, item_id: str, qty: int = 1, customisations: tuple = ()) -> None:
        item = self.menu.get(item_id)
        self.orders[order_id].add(OrderLine(item.name, item.price, qty, customisations))

    def send(self, order_id: str) -> None:
        order = self.orders[order_id]
        order.transition(OrderStatus.SENT)
        self.kitchen.enqueue(order)
        self._notify(order)

    def next_order(self) -> Optional[Order]:
        order = self.kitchen.next_order()
        if order is not None:
            self._notify(order)
        return order

    def mark_ready(self, order_id: str) -> None:
        order = self.orders[order_id]
        order.transition(OrderStatus.READY)
        self._notify(order)

    def close(self, order_id: str, tip: int = 0) -> Bill:
        order = self.orders[order_id]
        with self._lock:
            if order.status is OrderStatus.READY:
                order.transition(OrderStatus.SERVED)
                self._notify(order)
            order.transition(OrderStatus.CLOSED)
            sub = order.total()
            bill = Bill(order.id, sub, sub * self.TAX_RATE // 100, sub * self.SERVICE_RATE // 100, tip)
            self.tables[order.table_id].order_id = None
            self._notify(order)
            return bill

Money is in integer minor units throughout; a float price is the first thing a reviewer flags. Customisations are a tuple of strings on the line rather than a subclass per variant, because they are data the kitchen reads, not behaviour.

6. Extensions, concurrency and what interviewers probe

  • "Customisations that change the price" (extra cheese costs more). Make Customisation a small value with a surcharge and have OrderLine.price add them, or wrap the line with a decorator if surcharges stack in order; the state machine and the bill do not change.
  • "Split the bill" or "discounts". Both are functions from an order to money; put them in a BillingPolicy the restaurant asks, not in Order.
  • "Several cooks." Kitchen.next_order pops under a lock so two cooks never take the same order; the transition happens after the pop, outside the lock, because the order itself is handled by one cook at a time. Two waiters or two cashiers are a different race: open_order and close check then change, so both run under the restaurant's lock.
  • "Menu price changes while a table is eating." Lines snapshot the price; the bill uses the lines. State this before the interviewer asks.
  • Test first: add after send raises; a closed table can be reopened; the bill total equals subtotal plus charges plus tip.
  • Common mistake: a Restaurant class holding every rule, and order status as a set of booleans that can be inconsistent (ready and open at once).

02Where this question was reported