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.allocatedoes 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.Tablehas a capacity and aTableStatus; the whole seating flow is transitions on it. - MenuComponent (abstract) with MenuItem (leaf), MenuSection (composite) and ComboItem (an orderable composite).
price(),is_available()andleaves()are uniform across all three. - Order
1 → *OrderItem1 → *Modifier. An order references one or more table ids and carries anOrderStatus. - OrderCommand —
AddItem,ChangeQuantity,AddModifier,VoidItem.PointOfSalekeeps 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.
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.
@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.
@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.
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.
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.
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.
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.
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:
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.
FloorPlan._locks— onethreading.LockperTable, 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.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.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
KitchenListenerthat subtracts a recipe's ingredients onqueuedand 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
Orderwith notable_idsand 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.
Restaurantis already an ordinary object; a chain is onePointOfSaleper branch plus a reporting service that aggregatesdaily_report. Nothing in the seating flow changes. - Tipping and payroll. Tips are a
BillLineadded after tax and a share adjustment in the split strategy; payroll consumesShiftand 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_idon, which is the same sorted acquisition you already have intables_locked. - Table combinations.
suggestcurrently tries a single table then a pair. ASeatingStrategywould 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:
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.
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. |
Related¶
- Design a food delivery system (Swiggy, Zomato, DoorDash) — where the online-ordering follow-up leads
- Composite — the menu tree and the orderable combo
- State — the table, order and ticket machines
- Design a hotel management system — the sibling contended-resource problem, over date ranges instead of tables
- Design a movie ticket booking system (BookMyShow) — the same sorted multi-resource locking, over seats