Skip to content

Design a restaurant management system

TL;DR

  • You build the point of sale: seat a party (one table, or two joined), open a tab, take an order off a nested menu, fire a kitchen ticket, serve it, split the bill and hand the table to the bussers.
  • Three decisions carry the interview: one lock per table, acquired in sorted id order, so joining tables 4 and 7 is all-or-nothing and deadlock-free; the menu as a Composite, so a combo prices and validates itself; order edits as Command objects, which is what makes "undo that" and "the docket is already on the pass, get a manager" two lines instead of two subsystems.
  • Money.allocate does every split. A bill divided three ways adds back up to the cent, and that is a thing interviewers actually check.

Problem statement

"Design the software a restaurant runs on. Hosts seat walk-ins and reservations at tables of different sizes; servers take orders against a menu with sections, modifiers and combos; the kitchen works a queue of tickets and signals when food is ready; the bill applies a discount and tax and can be split. Show me the classes, the table and order state machines, and what happens when two hosts seat two parties at the same table at the same moment."

Requirements

Functional

  • Tables with a capacity and a status; parties may be seated on a joined group of tables.
  • Reservations hold a table for a slot; walk-ins join a FIFO waitlist with a quoted wait.
  • A menu of sections containing items; items carry priced modifiers; combos price several items together at a discount; a dish can be marked unavailable for the night.
  • One order (tab) per seated party, with quantity and modifier edits, and undo while the order is still open.
  • Sending the order creates a kitchen ticket that moves queued to preparing to ready to served; the kitchen display and the servers' pagers are both notified.
  • After sending, the only legal edit is a void, and it takes a manager.
  • Bills apply a discount policy, then tax, then a split strategy; the shares must sum exactly to the total.
  • Payments close the tab; the tables move to cleaning and back to available.
  • Staff have roles; a daily report per shift gives tabs, revenue and tables used.

Non-functional and constraints

  • Correct under concurrency: table status is the contended resource on a Friday night.
  • Money never uses floats; every split routes through Money.allocate.
  • In-memory and single-process; time and ids are injected so the report and the waitlist are testable.

Out of scope: online ordering and delivery (see the food delivery problem), inventory and ingredient deduction, payroll, multi-branch.

Clarifying questions and assumptions

Question to ask Assumption taken here
Can a party occupy more than one table? Yes. That is what makes the locking interesting, and it is the difference between a toy answer and a real one.
What happens to an edit after the ticket is fired? It becomes a void with a manager's authorisation, and the kitchen is told. Silently editing a sent ticket is how restaurants lose food cost.
Does a section have a price? Yes, the sum of its children — which is exactly what a combo needs. That single decision is why Composite fits here.
Is the waitlist strictly FIFO? Ordered, but a party that does not fit is skipped rather than blocking everyone behind it. Say this out loud; interviewers like the nuance.
Do we deduct ingredients? No. It is a listener on the ticket-sent event, so it is a follow-up rather than a redesign.
Which price does the bill use? The price snapshotted onto the order line. A menu change at 8 pm must not move a tab opened at 7 pm.
How do we handle tipping and tax order? Discount first, then tax on the discounted amount, then the split. Ask — jurisdictions differ, and getting the order wrong is a real bug.

Core entities and relationships

  • Restaurant — the aggregate: a FloorPlan, a menu root, a tax rate and staff.
  • FloorPlan 1 → * Table, plus one lock per table. Table has a capacity and a TableStatus; the whole seating flow is transitions on it.
  • MenuComponent (abstract) with MenuItem (leaf), MenuSection (composite) and ComboItem (an orderable composite). price(), is_available() and leaves() are uniform across all three.
  • Order 1 → * OrderItem 1 → * Modifier. An order references one or more table ids and carries an OrderStatus.
  • OrderCommand — AddItem, ChangeQuantity, AddModifier, VoidItem. PointOfSale keeps the applied list per order, which is both an undo stack and an audit log.
  • Kitchen 1 → * KitchenTicket, with KitchenListener observers: KitchenDisplay (the screen over the pass) and WaiterPager (only cares about ready).
  • Bill 1 → * BillLine, plus the shares from a BillSplitStrategy; Payment closes it.
  • PointOfSale — the Facade the terminal calls; Reservation and WaitlistEntry are the two front-of-house records; Staff, StaffRole and Shift cover people and reporting.

Class diagram

Structure: the floor, the tab and the money.

classDiagram
    class Restaurant {
        +name: str
        +tax_rate: Decimal
        +staff: tuple
    }
    class FloorPlan {
        -_locks: Dict~str,Lock~
        +tables_locked(table_ids)
        +suggest(party_size) tuple
        +seat(table_ids, party_size, order_id, reservation_id)
        +hold_for_reservation(table_ids, reservation_id)
        +clear(table_ids)
        +mark_clean(table_id)
    }
    class Table {
        +id: str
        +capacity: int
        +status: TableStatus
        +occupy(order_id)
        +start_cleaning()
        +mark_clean()
        +release()
    }
    class Reservation {
        +guest_name: str
        +party_size: int
        +slot_at: float
        +seated: bool
    }
    class WaitlistEntry {
        +guest_name: str
        +party_size: int
        +quoted_wait_minutes: int
    }
    class Order {
        +id: str
        +table_ids: tuple
        +status: OrderStatus
        +transition_to(target)
        +live_items() List~OrderItem~
        +subtotal() Money
    }
    class OrderItem {
        +name: str
        +unit_price: Money
        +quantity: int
        +voided: bool
        +line_total() Money
    }
    class Modifier {
        +name: str
        +price_delta: Money
    }
    class KitchenTicket {
        +order_id: str
        +lines: tuple
        +status: TicketStatus
    }
    class Bill {
        +subtotal: Money
        +discount: Money
        +tax: Money
        +total: Money
        +shares: tuple
        +shares_add_up() bool
    }
    class BillLine {
        +description: str
        +amount: Money
    }
    class Payment {
        +amount: Money
        +method: PaymentMethod
    }
    class Staff {
        +id: str
        +role: StaffRole
    }
    class Shift {
        +name: str
        +contains(when) bool
    }
    Restaurant "1" *-- "1" FloorPlan
    FloorPlan "1" *-- "many" Table
    Order "1" *-- "many" OrderItem
    OrderItem "1" o-- "many" Modifier
    Order --> "many" Table
    KitchenTicket --> Order
    Bill "1" *-- "many" BillLine
    Bill --> Order
    Payment --> Bill
    Reservation --> Table
    Order --> Staff

Behaviour: the Composite menu, the Command edits, the Observer kitchen and the two strategies.

classDiagram
    class MenuComponent {
        <<abstract>>
        +id: str
        +name: str
        +price() Money
        +is_available() bool
        +leaves() List~MenuItem~
        +find(component_id) MenuComponent
    }
    class MenuItem {
        +unit_price: Money
        +available: bool
    }
    class MenuSection {
        +add(child) MenuSection
        +children() List~MenuComponent~
        +require(component_id) MenuComponent
    }
    class ComboItem
    class OrderCommand {
        <<interface>>
        +apply(order)
        +undo(order)
        +describe() str
    }
    class AddItem
    class ChangeQuantity
    class AddModifier
    class VoidItem
    class Kitchen {
        -_lock: Lock
        +send(order, lines) KitchenTicket
        +advance(ticket_id) KitchenTicket
        +void_line(ticket_id, description) KitchenTicket
        +queue_depth() int
    }
    class KitchenListener {
        <<interface>>
        +on_ticket_event(event, ticket)
    }
    class KitchenDisplay
    class WaiterPager
    class DiscountPolicy {
        <<interface>>
        +discount(order, subtotal) Money
    }
    class PercentageDiscount
    class LargePartyDiscount
    class BillSplitStrategy {
        <<interface>>
        +split(order, total) tuple
    }
    class EvenSplit
    class ByItemSplit
    class PointOfSale {
        -_orders_lock: Lock
        +seat_party(party_size, server_id, table_ids, reservation_id) Order
        +apply_edit(order_id, command)
        +send_to_kitchen(order_id) KitchenTicket
        +bill(order_id, split) Bill
    }
    MenuComponent <|-- MenuItem
    MenuComponent <|-- MenuSection
    MenuSection <|-- ComboItem
    MenuSection "1" o-- "many" MenuComponent
    OrderCommand <|.. AddItem
    OrderCommand <|.. ChangeQuantity
    OrderCommand <|.. AddModifier
    OrderCommand <|.. VoidItem
    KitchenListener <|.. KitchenDisplay
    KitchenListener <|.. WaiterPager
    Kitchen --> "many" KitchenListener : notifies
    DiscountPolicy <|.. PercentageDiscount
    DiscountPolicy <|.. LargePartyDiscount
    BillSplitStrategy <|.. EvenSplit
    BillSplitStrategy <|.. ByItemSplit
    PointOfSale --> Kitchen
    PointOfSale --> MenuComponent
    PointOfSale --> OrderCommand
    PointOfSale --> DiscountPolicy
    PointOfSale --> BillSplitStrategy

Design patterns applied

Pattern Where Why it earns its place
Composite MenuComponent, MenuItem, MenuSection, ComboItem The menu is genuinely a tree, and the uniform price() / is_available() interface is what lets a combo be ordered exactly like a dish. The two composites differ in one line and the difference is meaningful: a section is available if any child is, a combo only if every child is.
Command OrderCommand with four implementations Every edit becomes an object, so undo is history.pop().undo(order) and the audit log is the same list. It also gives voids a natural home: VoidItem refuses in its constructor unless a manager authorised it.
State TableStatus guards on Table, Order.transition_to with ORDER_TRANSITIONS, Kitchen.NEXT_STATUS Three small machines. Two are transition tables, one is a guard set on the entity that owns the lock. None of them deserves a class per state.
Observer KitchenListener, KitchenDisplay, WaiterPager The display wants every event, the pager wants only ready. Filtering in the listener rather than in the kitchen is the whole point of the pattern.
Strategy DiscountPolicy, BillSplitStrategy The two rules that change most often. Splits are the interesting one: ByItemSplit shares tax and discount pro rata and still sums exactly, because it hands ratios to Money.allocate.
Facade PointOfSale The terminal calls one object. Locking, kitchen, menu, discounts and splits stay behind it.

What was deliberately not used: a Chain of Responsibility for order approval (server, then manager, then owner). It looks tempting for voids, but there are exactly two levels and one condition, so a guard clause in VoidItem.__init__ is clearer and cheaper. Mention that you considered it and rejected it — knowing when a pattern is overkill scores higher than knowing one more pattern.

Key flows

Seat, order, fire, serve, bill, clear. Note that the kitchen never talks to the floor.

sequenceDiagram
    autonumber
    actor S as Server
    participant POS as PointOfSale
    participant FP as FloorPlan
    participant K as Kitchen
    participant KD as KitchenDisplay
    participant WP as WaiterPager
    S->>POS: seat_party(4, server, T2+T4)
    POS->>FP: seat(tables, party_size, order_id)
    FP->>FP: lock tables in sorted id order
    alt every table free and enough seats
        FP->>FP: AVAILABLE to OCCUPIED
        POS-->>S: Order(OPEN)
    else any table taken
        FP-->>POS: TableUnavailableError
        POS-->>S: try another table
    end
    S->>POS: add_item, change_quantity, undo
    S->>POS: send_to_kitchen(order_id)
    POS->>POS: order OPEN to SENT
    POS->>K: send(order, docket lines)
    K-)KD: queued
    K->>K: advance to PREPARING then READY
    K-)WP: ready
    WP-->>S: pickup for T2, T4
    S->>POS: mark_served then bill(order_id, EvenSplit(3))
    POS->>POS: subtotal, discount, tax, allocate shares
    POS-->>S: Bill
    S->>POS: pay then clear_table
    POS->>FP: OCCUPIED to CLEANING

Table lifecycle. CLEANING is what stops the host seating a party at a table that still has plates on it.

stateDiagram-v2
    [*] --> AVAILABLE : table opened
    AVAILABLE --> RESERVED : booking holds the slot
    RESERVED --> OCCUPIED : the holder arrives
    RESERVED --> AVAILABLE : no-show or cancellation
    AVAILABLE --> OCCUPIED : walk-in seated
    OCCUPIED --> CLEANING : tab paid and cleared
    CLEANING --> AVAILABLE : busser signs it off

Order lifecycle. The SENT state is the fence: on one side edits are free, on the other they need a manager.

stateDiagram-v2
    [*] --> OPEN : party seated, tab opened
    OPEN --> SENT : docket fired to the kitchen
    SENT --> OPEN : next course added to the same tab
    SENT --> SERVED : food delivered to the table
    SERVED --> BILLED : bill printed with the split
    BILLED --> CLOSED : payment taken
    OPEN --> CLOSED : empty tab closed out
    CLOSED --> [*]

Implementation

Write the enums first — three status types, and being explicit that they are different machines is half the model.

code/lld/restaurant_management/models.py — enums
class TableStatus(StrEnum):
    AVAILABLE = "available"
    RESERVED = "reserved"  # held for a booking in the next slot
    OCCUPIED = "occupied"
    CLEANING = "cleaning"  # party left, busser not finished


class OrderStatus(StrEnum):
    OPEN = "open"  # the server is still adding items
    SENT = "sent"  # a kitchen ticket exists; edits now need a void
    SERVED = "served"
    BILLED = "billed"
    CLOSED = "closed"  # paid in full


class TicketStatus(StrEnum):
    QUEUED = "queued"
    PREPARING = "preparing"
    READY = "ready"
    SERVED = "served"


class StaffRole(StrEnum):
    HOST = "host"
    SERVER = "server"
    COOK = "cook"
    MANAGER = "manager"


class PaymentMethod(StrEnum):
    CARD = "card"
    CASH = "cash"

The floor. Table carries its own transition guards because they run inside the table lock.

code/lld/restaurant_management/models.py — floor
@dataclass(frozen=True, slots=True)
class Staff:
    id: str
    name: str
    role: StaffRole


@dataclass(frozen=True, slots=True)
class Shift:
    """A named service period; the daily report is grouped by it."""

    name: str
    starts_at: float
    ends_at: float

    def contains(self, when: float) -> bool:
        return self.starts_at <= when < self.ends_at


@dataclass(slots=True)
class Table:
    """One physical table. ``status`` is the contended field on a busy Friday."""

    id: str
    capacity: int
    status: TableStatus = TableStatus.AVAILABLE
    order_id: str | None = None
    reserved_for: str | None = None

    def reserve(self, reservation_id: str) -> None:
        if self.status is not TableStatus.AVAILABLE:
            raise TableStateError(f"table {self.id} is {self.status}, cannot be reserved")
        self.status = TableStatus.RESERVED
        self.reserved_for = reservation_id

    def occupy(self, order_id: str) -> None:
        if self.status not in (TableStatus.AVAILABLE, TableStatus.RESERVED):
            raise TableStateError(f"table {self.id} is {self.status}, cannot seat a party")
        self.status = TableStatus.OCCUPIED
        self.order_id = order_id
        self.reserved_for = None

    def start_cleaning(self) -> None:
        if self.status is not TableStatus.OCCUPIED:
            raise TableStateError(f"table {self.id} is {self.status}, not occupied")
        self.status = TableStatus.CLEANING
        self.order_id = None

    def mark_clean(self) -> None:
        if self.status is not TableStatus.CLEANING:
            raise TableStateError(f"table {self.id} is {self.status}, not being cleaned")
        self.status = TableStatus.AVAILABLE

    def release(self) -> None:
        """Cancel a reservation or roll back a partial seating."""
        if self.status not in (TableStatus.RESERVED, TableStatus.OCCUPIED):
            raise TableStateError(f"table {self.id} is {self.status}, nothing to release")
        self.status = TableStatus.AVAILABLE
        self.order_id = None
        self.reserved_for = None


@dataclass(slots=True)
class Reservation:
    id: str
    guest_name: str
    party_size: int
    slot_at: float
    table_ids: tuple[str, ...] = ()
    seated: bool = False


@dataclass(slots=True)
class WaitlistEntry:
    id: str
    guest_name: str
    party_size: int
    joined_at: float
    quoted_wait_minutes: int

The tab. The unit price is snapshotted onto the line, so a menu edit mid-service cannot move an open bill.

code/lld/restaurant_management/models.py — order
@dataclass(frozen=True, slots=True)
class Modifier:
    """Extra cheese, no onions, medium rare. Priced, so the bill stays honest."""

    name: str
    price_delta: Money = Money(0)


@dataclass(slots=True)
class OrderItem:
    id: str
    component_id: str
    name: str
    unit_price: Money  # snapshot: a menu price change must not move an open bill
    quantity: int = 1
    modifiers: tuple[Modifier, ...] = ()
    voided: bool = False

    def line_total(self) -> Money:
        unit = self.unit_price
        for modifier in self.modifiers:
            unit = unit + modifier.price_delta
        return unit * self.quantity


ORDER_TRANSITIONS: dict[OrderStatus, frozenset[OrderStatus]] = {
    OrderStatus.OPEN: frozenset({OrderStatus.SENT, OrderStatus.CLOSED}),
    OrderStatus.SENT: frozenset({OrderStatus.OPEN, OrderStatus.SERVED}),
    OrderStatus.SERVED: frozenset({OrderStatus.BILLED}),
    OrderStatus.BILLED: frozenset({OrderStatus.CLOSED}),
    OrderStatus.CLOSED: frozenset(),
}


@dataclass(slots=True)
class Order:
    """One tab, one table (or one joined group of tables)."""

    id: str
    table_ids: tuple[str, ...]
    server_id: str
    opened_at: float
    items: list[OrderItem] = field(default_factory=list)
    status: OrderStatus = OrderStatus.OPEN

    def transition_to(self, target: OrderStatus) -> None:
        if target not in ORDER_TRANSITIONS[self.status]:
            raise OrderStateError(f"order {self.id}: {self.status} -> {target} is not allowed")
        self.status = target

    def item(self, item_id: str) -> OrderItem:
        for item in self.items:
            if item.id == item_id:
                return item
        raise UnknownItemError(f"order {self.id} has no line {item_id!r}")

    def live_items(self) -> list[OrderItem]:
        return [i for i in self.items if not i.voided]

    def subtotal(self) -> Money:
        total = Money(0)
        for item in self.live_items():
            total = total + item.line_total()
        return total


@dataclass(slots=True)
class KitchenTicket:
    id: str
    order_id: str
    table_ids: tuple[str, ...]
    lines: tuple[str, ...]
    created_at: float
    status: TicketStatus = TicketStatus.QUEUED

The Composite. Look at the last two methods: MenuSection.is_available uses any, ComboItem.is_available uses all, and that is the entire difference between a category and a combo.

code/lld/restaurant_management/menu.py — composite
class MenuComponent(ABC):
    """Uniform interface: everything on the menu has an id, a price and availability."""

    def __init__(self, component_id: str, name: str) -> None:
        self.id = component_id
        self.name = name

    @abstractmethod
    def price(self) -> Money: ...

    @abstractmethod
    def is_available(self) -> bool: ...

    @abstractmethod
    def leaves(self) -> list[MenuItem]:
        """Every orderable dish underneath this node (itself, if it is a leaf)."""

    def find(self, component_id: str) -> MenuComponent | None:
        return self if self.id == component_id else None

    def describe(self, indent: int = 0) -> str:
        mark = "" if self.is_available() else " (86'd)"
        return f"{' ' * indent}{self.name} {self.price()}{mark}"


class MenuItem(MenuComponent):
    """A leaf: one dish, one price, its own allowed modifiers."""

    def __init__(
        self,
        component_id: str,
        name: str,
        unit_price: Money,
        available: bool = True,
        modifiers: tuple[Modifier, ...] = (),
    ) -> None:
        super().__init__(component_id, name)
        self.unit_price = unit_price
        self.available = available
        self.modifiers = modifiers

    def price(self) -> Money:
        return self.unit_price

    def is_available(self) -> bool:
        return self.available

    def leaves(self) -> list[MenuItem]:
        return [self]


class MenuSection(MenuComponent):
    """A composite: Starters, Mains, Desserts. Available if anything in it is."""

    def __init__(self, component_id: str, name: str, children: list[MenuComponent] | None = None) -> None:
        super().__init__(component_id, name)
        self._children: list[MenuComponent] = list(children or [])

    def add(self, child: MenuComponent) -> MenuSection:
        self._children.append(child)
        return self

    def children(self) -> list[MenuComponent]:
        return list(self._children)

    def price(self) -> Money:
        total = Money(0)
        for child in self._children:
            total = total + child.price()
        return total

    def is_available(self) -> bool:
        return any(child.is_available() for child in self._children)

    def leaves(self) -> list[MenuItem]:
        return [leaf for child in self._children for leaf in child.leaves()]

    def find(self, component_id: str) -> MenuComponent | None:
        if self.id == component_id:
            return self
        for child in self._children:
            found = child.find(component_id)
            if found is not None:
                return found
        return None

    def require(self, component_id: str) -> MenuComponent:
        found = self.find(component_id)
        if found is None:
            raise UnknownItemError(f"no menu component {component_id!r}")
        return found

    def describe(self, indent: int = 0) -> str:
        lines = [f"{' ' * indent}{self.name}"]
        lines.extend(child.describe(indent + 2) for child in self._children)
        return "\n".join(lines)


class ComboItem(MenuSection):
    """A composite you can order: sum of its parts, minus a discount, all-or-nothing."""

    def __init__(
        self,
        component_id: str,
        name: str,
        children: list[MenuComponent],
        discount: Decimal = Decimal("0.15"),
    ) -> None:
        super().__init__(component_id, name, children)
        self._keep = Decimal(1) - discount

    def price(self) -> Money:
        return super().price() * self._keep

    def is_available(self) -> bool:
        return all(child.is_available() for child in self.children())

Edits as Commands. VoidItem validates the manager in its constructor, so an unauthorised void cannot even be built, let alone applied.

code/lld/restaurant_management/commands.py — commands
class OrderCommand(Protocol):
    """Every edit is an object: it can be applied, undone and written to an audit log."""

    def apply(self, order: Order) -> None: ...

    def undo(self, order: Order) -> None: ...

    def describe(self) -> str: ...


class AddItem:
    def __init__(self, item: OrderItem) -> None:
        self._item = item

    def apply(self, order: Order) -> None:
        if order.status is not OrderStatus.OPEN:
            raise OrderStateError(f"order {order.id} is {order.status}; send a new course instead")
        order.items.append(self._item)

    def undo(self, order: Order) -> None:
        order.items = [i for i in order.items if i.id != self._item.id]

    def describe(self) -> str:
        return f"add {self._item.quantity} x {self._item.name}"


class ChangeQuantity:
    def __init__(self, item_id: str, quantity: int) -> None:
        self._item_id = item_id
        self._quantity = quantity
        self._previous: int | None = None

    def apply(self, order: Order) -> None:
        if order.status is not OrderStatus.OPEN:
            raise OrderStateError(f"order {order.id} is {order.status}; quantities are fixed")
        item = order.item(self._item_id)
        self._previous = item.quantity
        item.quantity = self._quantity

    def undo(self, order: Order) -> None:
        if self._previous is not None:
            order.item(self._item_id).quantity = self._previous

    def describe(self) -> str:
        return f"set line {self._item_id} to {self._quantity}"


class AddModifier:
    def __init__(self, item_id: str, modifier: Modifier) -> None:
        self._item_id = item_id
        self._modifier = modifier

    def apply(self, order: Order) -> None:
        if order.status is not OrderStatus.OPEN:
            raise OrderStateError(f"order {order.id} is {order.status}; modifiers are fixed")
        item = order.item(self._item_id)
        item.modifiers = (*item.modifiers, self._modifier)

    def undo(self, order: Order) -> None:
        item = order.item(self._item_id)
        item.modifiers = tuple(m for m in item.modifiers if m != self._modifier)

    def describe(self) -> str:
        return f"add {self._modifier.name} to {self._item_id}"


class VoidItem:
    """The only edit allowed after the ticket is sent, and it needs a manager."""

    def __init__(self, item_id: str, reason: str, authorised_by: Staff) -> None:
        if authorised_by.role is not StaffRole.MANAGER:
            raise OrderStateError(f"{authorised_by.role} cannot void a sent line")
        self._item_id = item_id
        self.reason = reason
        self.authorised_by = authorised_by

    def apply(self, order: Order) -> None:
        order.item(self._item_id).voided = True

    def undo(self, order: Order) -> None:
        order.item(self._item_id).voided = False

    def describe(self) -> str:
        return f"void {self._item_id} ({self.reason}) by {self.authorised_by.name}"


def line_price(name: str, unit_price: Money) -> str:
    """Small helper used by the kitchen docket renderer."""
    return f"{name} @ {unit_price}"

Now the contended part. tables_locked sorts, seat checks every table and the seat count before it occupies anything, and suggest is a lock-free hint that seat re-validates.

code/lld/restaurant_management/services.py — floor plan
class FloorPlan:
    """The tables and the lock that makes seating atomic *per table*.

    Two hosts seating two parties at different tables never contend; two hosts
    reaching for table 4 serialise on table 4 alone.
    """

    def __init__(self, tables: Sequence[Table]) -> None:
        self._tables: dict[str, Table] = {t.id: t for t in tables}
        self._locks: dict[str, threading.Lock] = {}
        self._registry_lock = threading.Lock()

    def _lock_for(self, table_id: str) -> threading.Lock:
        with self._registry_lock:
            return self._locks.setdefault(table_id, threading.Lock())

    @contextmanager
    def tables_locked(self, table_ids: Sequence[str]) -> Iterator[None]:
        """Sorted acquisition: joining tables 7 and 4 always takes 4 first."""
        acquired: list[threading.Lock] = []
        try:
            for table_id in sorted(set(table_ids)):
                lock = self._lock_for(table_id)
                lock.acquire()
                acquired.append(lock)
            yield
        finally:
            for lock in reversed(acquired):
                lock.release()

    def table(self, table_id: str) -> Table:
        try:
            return self._tables[table_id]
        except KeyError:
            raise UnknownItemError(f"no table {table_id!r}") from None

    def tables(self) -> list[Table]:
        return sorted(self._tables.values(), key=lambda t: t.id)

    def suggest(self, party_size: int) -> tuple[str, ...] | None:
        """Lock-free hint: the smallest single free table, else the smallest free pair."""
        free = [t for t in self.tables() if t.status is TableStatus.AVAILABLE]
        fits = sorted((t for t in free if t.capacity >= party_size), key=lambda t: t.capacity)
        if fits:
            return (fits[0].id,)
        for i, first in enumerate(free):
            for second in free[i + 1 :]:
                if first.capacity + second.capacity >= party_size:
                    return tuple(sorted((first.id, second.id)))
        return None

    def seat(
        self,
        table_ids: Sequence[str],
        party_size: int,
        order_id: str,
        reservation_id: str | None = None,
    ) -> None:
        """All-or-nothing: check every table, check the seats, then occupy every table."""
        with self.tables_locked(table_ids):
            tables = [self.table(t) for t in table_ids]
            blocked = [
                t.id
                for t in tables
                if not (
                    t.status is TableStatus.AVAILABLE
                    or (t.status is TableStatus.RESERVED and t.reserved_for == reservation_id)
                )
            ]
            if blocked:
                raise TableUnavailableError(f"tables not free: {', '.join(sorted(blocked))}")
            validate_party(party_size, sum(t.capacity for t in tables))
            for table in tables:
                table.occupy(order_id)

    def hold_for_reservation(self, table_ids: Sequence[str], reservation_id: str) -> None:
        with self.tables_locked(table_ids):
            tables = [self.table(t) for t in table_ids]
            blocked = [t.id for t in tables if t.status is not TableStatus.AVAILABLE]
            if blocked:
                raise TableUnavailableError(f"cannot reserve: {', '.join(sorted(blocked))}")
            for table in tables:
                table.reserve(reservation_id)

    def clear(self, table_ids: Sequence[str]) -> None:
        with self.tables_locked(table_ids):
            for table_id in table_ids:
                self.table(table_id).start_cleaning()

    def mark_clean(self, table_id: str) -> None:
        with self.tables_locked([table_id]):
            self.table(table_id).mark_clean()

    def release(self, table_ids: Sequence[str]) -> None:
        with self.tables_locked(table_ids):
            for table_id in table_ids:
                self.table(table_id).release()

The kitchen owns one lock over the ticket board and notifies listeners outside it, so a slow display never stalls a cook.

code/lld/restaurant_management/services.py — kitchen
class KitchenListener(Protocol):
    """Observer interface: the kitchen display and the waiter pager both implement it."""

    def on_ticket_event(self, event: str, ticket: KitchenTicket) -> None: ...


class KitchenDisplay:
    """The screen over the pass. It never polls; the kitchen pushes to it."""

    def __init__(self) -> None:
        self._lock = threading.Lock()
        self._board: dict[str, KitchenTicket] = {}

    def on_ticket_event(self, event: str, ticket: KitchenTicket) -> None:
        with self._lock:
            self._board[ticket.id] = ticket

    def render(self) -> str:
        with self._lock:
            live = [t for t in self._board.values() if t.status is not TicketStatus.SERVED]
            return " | ".join(f"{t.id} {','.join(t.table_ids)} {t.status}" for t in sorted(live, key=lambda t: t.id))


class WaiterPager:
    """The other observer: it only cares about READY, and it buzzes the server."""

    def __init__(self) -> None:
        self._lock = threading.Lock()
        self._pages: list[str] = []

    def on_ticket_event(self, event: str, ticket: KitchenTicket) -> None:
        if event == "ready":
            with self._lock:
                self._pages.append(f"pickup for {','.join(ticket.table_ids)} ({ticket.id})")

    def pages(self) -> list[str]:
        with self._lock:
            return list(self._pages)


class Kitchen:
    """The ticket board. ``_lock`` guards it; listeners are notified outside the lock."""

    NEXT_STATUS = {
        TicketStatus.QUEUED: TicketStatus.PREPARING,
        TicketStatus.PREPARING: TicketStatus.READY,
        TicketStatus.READY: TicketStatus.SERVED,
    }

    def __init__(self, clock: Clock | None = None, ids: IdGenerator | None = None) -> None:
        self._clock = clock or SystemClock()
        self._ids = ids or SequentialIdGenerator("KT")
        self._tickets: dict[str, KitchenTicket] = {}
        self._listeners: list[KitchenListener] = []
        self._lock = threading.Lock()

    def subscribe(self, listener: KitchenListener) -> None:
        self._listeners.append(listener)

    def send(self, order: Order, lines: Sequence[str]) -> KitchenTicket:
        ticket = KitchenTicket(
            id=self._ids.next_id(),
            order_id=order.id,
            table_ids=order.table_ids,
            lines=tuple(lines),
            created_at=self._clock.now(),
        )
        with self._lock:
            self._tickets[ticket.id] = ticket
        self._notify("queued", ticket)
        return ticket

    def advance(self, ticket_id: str) -> KitchenTicket:
        """QUEUED to PREPARING to READY to SERVED, one step per call."""
        with self._lock:
            ticket = self._require(ticket_id)
            nxt = self.NEXT_STATUS.get(ticket.status)
            if nxt is None:
                raise OrderStateError(f"ticket {ticket_id} is already {ticket.status}")
            ticket.status = nxt
        self._notify(str(nxt), ticket)
        return ticket

    def void_line(self, ticket_id: str, description: str) -> KitchenTicket:
        """Tell the line that a dish is off. The docket keeps the strike-through."""
        with self._lock:
            ticket = self._require(ticket_id)
            ticket.lines = (*ticket.lines, f"VOID {description}")
        self._notify("voided", ticket)
        return ticket

    def board(self) -> list[KitchenTicket]:
        with self._lock:
            return sorted(self._tickets.values(), key=lambda t: t.id)

    def queue_depth(self) -> int:
        with self._lock:
            return sum(1 for t in self._tickets.values() if t.status is TicketStatus.QUEUED)

    def _require(self, ticket_id: str) -> KitchenTicket:
        try:
            return self._tickets[ticket_id]
        except KeyError:
            raise UnknownItemError(f"no kitchen ticket {ticket_id!r}") from None

    def _notify(self, event: str, ticket: KitchenTicket) -> None:
        for listener in self._listeners:
            listener.on_ticket_event(event, ticket)

The facade. bill is worth reading closely: subtotal, then discount, then tax on the discounted amount, then the split — in that order, always.

code/lld/restaurant_management/pos.py — point of sale
class PointOfSale:
    """One object for the terminal to call. Everything else stays behind it."""

    def __init__(
        self,
        restaurant: Restaurant,
        kitchen: Kitchen,
        discount: DiscountPolicy | None = None,
        clock: Clock | None = None,
        ids: IdGenerator | None = None,
    ) -> None:
        self._restaurant = restaurant
        self._floor = restaurant.floor
        self._kitchen = kitchen
        self._discount = discount or NoDiscount()
        self._clock = clock or SystemClock()
        self._ids = ids or SequentialIdGenerator("ORD")
        self._orders: dict[str, Order] = {}
        self._history: dict[str, list[OrderCommand]] = {}
        self._tickets: dict[str, str] = {}  # order id -> latest ticket id
        self._reservations: dict[str, Reservation] = {}
        self._waitlist: list[WaitlistEntry] = []
        self._bills: dict[str, Bill] = {}
        self._payments: list[Payment] = []
        self._orders_lock = threading.Lock()

    # -- front of house ----------------------------------------------------------------
    def book(self, guest_name: str, party_size: int, slot_at: float, table_ids: Sequence[str]) -> Reservation:
        reservation = Reservation(
            id=self._ids.next_id(),
            guest_name=guest_name,
            party_size=party_size,
            slot_at=slot_at,
            table_ids=tuple(table_ids),
        )
        self._floor.hold_for_reservation(table_ids, reservation.id)
        with self._orders_lock:
            self._reservations[reservation.id] = reservation
        return reservation

    def join_waitlist(self, guest_name: str, party_size: int) -> WaitlistEntry:
        """Walk-ins queue in arrival order; the quote is the queue depth times a turn."""
        with self._orders_lock:
            entry = WaitlistEntry(
                id=self._ids.next_id(),
                guest_name=guest_name,
                party_size=party_size,
                joined_at=self._clock.now(),
                quoted_wait_minutes=15 * (len(self._waitlist) + 1),
            )
            self._waitlist.append(entry)
            return entry

    def seat_next_walk_in(self, server_id: str) -> tuple[WaitlistEntry, Order] | None:
        """FIFO, but skip a party that still does not fit rather than blocking the queue."""
        with self._orders_lock:
            candidates = list(self._waitlist)
        for entry in candidates:
            tables = self._floor.suggest(entry.party_size)
            if tables is None:
                continue
            try:
                order = self.seat_party(entry.party_size, server_id, tables)
            except TableUnavailableError:
                continue
            with self._orders_lock:
                self._waitlist = [e for e in self._waitlist if e.id != entry.id]
            return entry, order
        return None

    def seat_party(
        self,
        party_size: int,
        server_id: str,
        table_ids: Sequence[str] | None = None,
        reservation_id: str | None = None,
    ) -> Order:
        """Open a tab and occupy the tables atomically. The order id is minted first."""
        if table_ids is None:
            suggested = self._floor.suggest(party_size)
            if suggested is None:
                raise TableUnavailableError(f"nothing free for a party of {party_size}")
            table_ids = suggested
        order_id = self._ids.next_id()
        self._floor.seat(table_ids, party_size, order_id, reservation_id)
        order = Order(
            id=order_id,
            table_ids=tuple(sorted(table_ids)),
            server_id=server_id,
            opened_at=self._clock.now(),
        )
        with self._orders_lock:
            self._orders[order.id] = order
            self._history[order.id] = []
            if reservation_id is not None and reservation_id in self._reservations:
                self._reservations[reservation_id].seated = True
        return order

    # -- the tab -----------------------------------------------------------------------
    def add_item(
        self,
        order_id: str,
        component_id: str,
        quantity: int = 1,
        modifiers: tuple[Modifier, ...] = (),
    ) -> OrderItem:
        component = self._restaurant.menu.require(component_id)
        if not component.is_available():
            raise ItemUnavailableError(f"{component.name} is off the menu tonight")
        item = OrderItem(
            id=self._ids.next_id(),
            component_id=component.id,
            name=component.name,
            unit_price=component.price(),
            quantity=quantity,
            modifiers=modifiers,
        )
        self.apply_edit(order_id, AddItem(item))
        return item

    def apply_edit(self, order_id: str, command: OrderCommand) -> None:
        """Every edit goes through a Command, so the history is an audit log for free."""
        with self._orders_lock:
            order = self._require(order_id)
            command.apply(order)
            self._history[order_id].append(command)

    def undo_last_edit(self, order_id: str) -> str:
        with self._orders_lock:
            order = self._require(order_id)
            if order.status is not OrderStatus.OPEN:
                raise OrderStateError(f"order {order_id} is {order.status}; void the line instead")
            history = self._history[order_id]
            if not history:
                raise OrderStateError(f"order {order_id} has nothing to undo")
            command = history.pop()
            command.undo(order)
            return command.describe()

    def send_to_kitchen(self, order_id: str) -> KitchenTicket:
        with self._orders_lock:
            order = self._require(order_id)
            lines = [f"{i.quantity} x {i.name}" + self._modifier_suffix(i) for i in order.live_items()]
            if not lines:
                raise OrderStateError(f"order {order_id} has nothing to send")
            order.transition_to(OrderStatus.SENT)
        ticket = self._kitchen.send(order, lines)
        with self._orders_lock:
            self._tickets[order_id] = ticket.id
        return ticket

    def void_line(self, order_id: str, item_id: str, reason: str, manager: Staff) -> None:
        """The only edit allowed once the docket is on the pass, and it needs a manager."""
        with self._orders_lock:
            order = self._require(order_id)
            item = order.item(item_id)
            command = VoidItem(item_id, reason, manager)
            command.apply(order)
            self._history[order_id].append(command)
            ticket_id = self._tickets.get(order_id)
        if ticket_id is not None:
            self._kitchen.void_line(ticket_id, f"{item.quantity} x {item.name}")

    def next_course(self, order_id: str) -> None:
        """SENT back to OPEN so the dessert course joins the same tab."""
        with self._orders_lock:
            self._require(order_id).transition_to(OrderStatus.OPEN)

    def mark_served(self, order_id: str) -> None:
        with self._orders_lock:
            self._require(order_id).transition_to(OrderStatus.SERVED)

    # -- money -------------------------------------------------------------------------
    def bill(self, order_id: str, split: BillSplitStrategy | None = None) -> Bill:
        """Subtotal, discount, tax, then the split - in that order, always."""
        strategy = split or NoSplit()
        with self._orders_lock:
            order = self._require(order_id)
            subtotal = order.subtotal()
            discount = self._discount.discount(order, subtotal)
            taxable = subtotal - discount
            tax = taxable * self._restaurant.tax_rate
            total = taxable + tax
            lines = tuple(
                BillLine(f"{i.quantity} x {i.name}", i.line_total()) for i in order.live_items()
            )
            bill = Bill(
                id=self._ids.next_id(),
                order_id=order.id,
                lines=lines,
                subtotal=subtotal,
                discount=discount,
                tax=tax,
                total=total,
                shares=strategy.split(order, total),
            )
            order.transition_to(OrderStatus.BILLED)
            self._bills[bill.id] = bill
            return bill

    def pay(self, bill_id: str, method: PaymentMethod) -> Payment:
        with self._orders_lock:
            bill = self._bills.get(bill_id)
            if bill is None:
                raise UnknownItemError(f"no bill {bill_id!r}")
            order = self._require(bill.order_id)
            order.transition_to(OrderStatus.CLOSED)
            payment = Payment(
                id=self._ids.next_id(),
                bill_id=bill.id,
                amount=bill.total,
                method=method,
                paid_at=self._clock.now(),
            )
            self._payments.append(payment)
            return payment

    def clear_table(self, order_id: str) -> tuple[str, ...]:
        """OCCUPIED to CLEANING for every table on the tab. Bussers finish the job."""
        with self._orders_lock:
            order = self._require(order_id)
            if order.status is not OrderStatus.CLOSED:
                raise OrderStateError(f"order {order_id} is {order.status}, not paid")
            tables = order.table_ids
        self._floor.clear(tables)
        return tables

    # -- reporting ---------------------------------------------------------------------
    def daily_report(self, shift: Shift) -> dict[str, object]:
        with self._orders_lock:
            paid = [p for p in self._payments if shift.contains(p.paid_at)]
            revenue = Money(0)
            for payment in paid:
                revenue = revenue + payment.amount
            covers = sum(
                len(o.table_ids) for o in self._orders.values() if shift.contains(o.opened_at)
            )
            return {"shift": shift.name, "tabs": len(paid), "revenue": revenue, "tables_used": covers}

    def order(self, order_id: str) -> Order:
        with self._orders_lock:
            return self._require(order_id)

    def waitlist(self) -> list[WaitlistEntry]:
        with self._orders_lock:
            return list(self._waitlist)

    def _require(self, order_id: str) -> Order:
        try:
            return self._orders[order_id]
        except KeyError:
            raise UnknownItemError(f"unknown order {order_id!r}") from None

    @staticmethod
    def _modifier_suffix(item: OrderItem) -> str:
        return f" ({', '.join(m.name for m in item.modifiers)})" if item.modifiers else ""

Splitting is where the cents go missing, so both strategies delegate to Money.allocate:

code/lld/restaurant_management/strategies.py — splits
class BillSplitStrategy(Protocol):
    """Turn one total into the shares the guests will actually pay."""

    def split(self, order: Order, total: Money) -> tuple[Money, ...]: ...


class NoSplit:
    def split(self, order: Order, total: Money) -> tuple[Money, ...]:
        return (total,)


class EvenSplit:
    """N ways. ``Money.allocate`` gives the odd cents to the first guests, deterministically."""

    def __init__(self, ways: int) -> None:
        if ways < 1:
            raise ValidationError("a bill splits at least one way")
        self._ways = ways

    def split(self, order: Order, total: Money) -> tuple[Money, ...]:
        return tuple(total.allocate([1] * self._ways))


class ByItemSplit:
    """Each guest pays for what they ordered; tax and discount ride along pro rata.

    The ratios are the guests' item subtotals in cents, so the discount and the tax
    are shared in the same proportion as the food - and the shares still sum exactly.
    """

    def __init__(self, groups: tuple[tuple[str, ...], ...]) -> None:
        if not groups:
            raise ValidationError("by-item split needs at least one guest group")
        self._groups = groups

    def split(self, order: Order, total: Money) -> tuple[Money, ...]:
        ratios: list[int] = []
        for group in self._groups:
            group_total = Money(0, total.currency)
            for item_id in group:
                group_total = group_total + order.item(item_id).line_total()
            ratios.append(group_total.cents)
        if sum(ratios) == 0:
            raise ValidationError("every guest group is empty; nothing to split")
        return tuple(total.allocate(ratios))

Running python -m lld.restaurant_management.demo walks one service:

prix fixe 35.70 USD vs a la carte 42.00 USD (15% off)
risotto orderable: False; mains section still on: True
ID-1 holds T3, table is now reserved
waitlist: Iyer party of 2, quoted 15 min
seated Iyer at T1 -> order ID-3
undo: set line ID-5 to 1; tab is now 93.90 USD
KT-1 -> kitchen: ['2 x Prix fixe', '3 x Garden salad']
edit after send rejected: order ID-3 is sent; quantities are fixed
pager: ['pickup for T1 (KT-1)']; board: KT-1 T1 ready
bill 71.40 USD - 5.00 USD discount + 5.31 USD tax = 71.71 USD
split three ways: ['23.91 USD', '23.90 USD', '23.90 USD'] (adds up: True)
tables T1 -> cleaning
joining tables rejected: tables not free: T1
party of 8 joins T2+T4 -> order ID-9
dinner report: 1 tabs, 71.71 USD taken, 3 tables used

The split line is the one to point at: 71.71 divided three ways is 23.9033…, and the odd cent lands on the first guest deterministically rather than disappearing.

Concurrency and edge cases

Which lock protects what.

  1. FloorPlan._locks — one threading.Lock per Table, created lazily under _registry_lock. It guards table status, the occupying order id and the reservation hold. Two hosts reaching for table 4 serialise on table 4; a host seating table 9 is unaffected.
  2. PointOfSale._orders_lock — guards the order registry, the per-order command history, the reservation book, the waitlist and the bills. It is never held while a table lock is held.
  3. Kitchen._lock — guards the ticket board only. Listeners are notified after it is released.

Lock ordering. Joining tables for a party of eight acquires them in sorted id order, so a host asking for T4, T2 and a host asking for T2, T4 both take T2 first. Without the sort this is the textbook ABBA deadlock, and it is the single most likely thing the interviewer will probe. The cost is nothing: an uncontended mutex is about 17 ns, so a two-table seating spends under 50 ns acquiring.

Table double-assignment. seat checks every table and the combined capacity under the locks, then occupies. A host who loses the race gets TableUnavailableError naming the tables that went; nothing is half-seated. The concurrency test fires 20 hosts at six overlapping seating plans and asserts that no table id appears in two winning orders and that the set of claimed tables is exactly the set of occupied ones.

Edits after the ticket is sent. Order.transition_to moves OPEN → SENT when the docket fires. ChangeQuantity and AddModifier refuse outside OPEN; undo_last_edit refuses too, telling the caller to void instead. VoidItem marks the line, and PointOfSale.void_line appends a strike-through to the kitchen ticket so the line cooks see it. A second course reopens the tab with next_course, which is SENT → OPEN again — the same order, a new docket.

Split-bill rounding. EvenSplit calls total.allocate([1] * ways); ByItemSplit builds the ratios from each guest group's item subtotal in cents. Both inherit the guarantee that the shares sum exactly to the total, remainder cents distributed deterministically to the earliest shares. Bill.shares_add_up() asserts it, and the parametrised test pins the awkward cases (10.00 three ways, 0.05 four ways).

Kitchen queue updates. The board is a dict under one lock; advance is a single step through NEXT_STATUS, so a double-tap on "ready" raises rather than skipping to served. The display keeps its own copy so rendering never touches the kitchen's lock.

Reservation versus walk-in. A held table is RESERVED, so seat rejects a walk-in unless the caller passes the matching reservation id. The waitlist is ordered but not blocking: seat_next_walk_in walks the queue and seats the first party that fits, so a party of six waiting for a six-top does not stop the two-top behind it from being seated.

Other edge cases handled: an unavailable dish is refused at add_item, not at the pass; a party larger than the combined capacity is refused; sending an empty order raises; clearing a table before payment raises; the daily report is bounded by an injected Shift rather than "today" on the server.

Common mistake

Letting the kitchen mutate table state, or the floor plan know about orders. It feels efficient and it welds two independently changing subsystems together — the moment you add a second kitchen station or a self-service tablet, everything breaks. Keep the arrows one-way: PointOfSale calls both, the kitchen publishes events, and nothing calls back up.

Extensibility and follow-ups

  • Ingredient deduction. A third KitchenListener that subtracts a recipe's ingredients on queued and refuses to when stock is short — the same hook that already feeds the display. Marking a dish unavailable then becomes automatic instead of a manager tapping a screen.
  • Online ordering. A delivery order is an Order with no table_ids and a courier instead of a server; the kitchen path is identical. Where it stops being an LLD question is dispatch and ETAs, which is the food delivery problem.
  • Multi-branch. Restaurant is already an ordinary object; a chain is one PointOfSale per branch plus a reporting service that aggregates daily_report. Nothing in the seating flow changes.
  • Tipping and payroll. Tips are a BillLine added after tax and a share adjustment in the split strategy; payroll consumes Shift and the server id already on every order.
  • Persistence. Orders, tickets and bills go behind repositories; table status becomes a row you take SELECT ... FOR UPDATE ... ORDER BY table_id on, which is the same sorted acquisition you already have in tables_locked.
  • Table combinations. suggest currently tries a single table then a pair. A SeatingStrategy would let a restaurant express its own rules (never split a party of two across two-tops, keep the corner booth for six) without touching the lock code.

Interview tip

When you reach the menu, ask "can a combo contain a combo?" before you design it. If yes you need Composite, if no a flat list is enough — and either answer, asked out loud, tells the interviewer you pick structures from requirements rather than from habit. Then draw the tree with one combo in it and point at price() on both node types.

Tests

tests/test_restaurant_management.py has 15 cases. The edit fence is the one to walk through, because it is the rule that turns a naive POS into a real one:

code/lld/restaurant_management/tests/test_restaurant_management.py — edits after the ticket is sent
def test_edits_are_free_until_the_ticket_is_sent_then_need_a_manager(clock: FakeClock) -> None:
    pos, _, kitchen = build_pos(clock)
    order = pos.seat_party(2, SERVER.id, ["T1"])
    soup = pos.add_item(order.id, "m-soup", quantity=2)
    pos.apply_edit(order.id, ChangeQuantity(soup.id, 4))
    assert order.subtotal() == Money.of("24.00")
    assert pos.undo_last_edit(order.id) == f"set line {soup.id} to 4"
    assert order.subtotal() == Money.of("12.00")  # back to two soups

    ticket = pos.send_to_kitchen(order.id)
    with pytest.raises(OrderStateError):
        pos.apply_edit(order.id, ChangeQuantity(soup.id, 1))  # the docket is on the pass
    with pytest.raises(OrderStateError, match="cannot void"):
        pos.void_line(order.id, soup.id, "changed their mind", SERVER)
    pos.void_line(order.id, soup.id, "changed their mind", MANAGER)
    assert order.subtotal() == Money.of("0.00")
    assert kitchen.board()[0].lines[-1] == "VOID 2 x Tomato soup"
    assert ticket.status is TicketStatus.QUEUED  # the void does not advance the ticket

The concurrency test asserts the floor invariant rather than a winner: whatever the interleaving, the claimed tables and the occupied tables are the same set.

code/lld/restaurant_management/tests/test_restaurant_management.py — concurrency
def test_twenty_hosts_race_for_four_tables_and_never_double_seat(clock: FakeClock) -> None:
    pos, floor, _ = build_pos(clock)
    plans = [["T1"], ["T2", "T4"], ["T3"], ["T4"], ["T2"], ["T2", "T3"]]

    def seat(i: int) -> tuple[str, ...] | None:
        try:
            return pos.seat_party(2, SERVER.id, plans[i % len(plans)]).table_ids
        except TableUnavailableError:
            return None

    with ThreadPoolExecutor(max_workers=10) as pool:
        results = list(pool.map(seat, range(20)))

    claimed = [t for tables in results if tables is not None for t in tables]
    assert len(claimed) == len(set(claimed))  # no table seated by two parties
    occupied = [t.id for t in floor.tables() if t.status is TableStatus.OCCUPIED]
    assert sorted(claimed) == occupied  # every claim is on the floor, nothing else is

The rest cover: the full seat-to-clear path with the display and the pager; combo pricing and an unavailable dish; any versus all availability on the two composites; even splits including the odd-cent cases via parametrize; a by-item split sharing tax and discount pro rata; modifiers on the line total and on the docket; a reservation blocking a walk-in but not its holder; the waitlist skipping a party that does not fit; validation failures; and the discount threshold feeding the daily report. Run them with uv run pytest code/lld/restaurant_management -q.

45-minute pacing

Minutes What to do What to say or write
0–5 Clarify Can a party span tables? Can a combo nest? What happens to an edit after the fire? Park inventory and delivery.
5–11 Entities Table, Order, OrderItem, Modifier, KitchenTicket, Bill. Say "three status enums, three separate machines".
11–17 Menu Draw the tree with one combo. Write price() and is_available() on both composite types and name the any / all difference.
17–24 State machines Table (five states) and Order (five). Mark SENT as the edit fence and CLEANING as the reseat fence.
24–35 Code tables_locked (say "sorted") → seat (say "check all, then occupy all") → OrderCommand and VoidItem → bill (say "discount, tax, split, in that order").
35–41 Concurrency and money The table lock, the ABBA deadlock the sort prevents, and Money.allocate for the split. Describe the 20-host test.
41–45 Extensions Ingredient deduction as a listener, online orders as a table-less order, branches as more instances, seating strategy as the next seam.