InterviewPrepKit

Home / Cheat Sheet / Object-Oriented Design

Cheat sheet

How to design a restaurant-management system

Read the full lesson →

Model a restaurant as three lifecycles running at once: a table turns over in hours, a dish moves through the kitchen in minutes, and the money settles in one burst at the end, so no single status field can be right about all three.

The three decisions

DecisionConsequence
State lives on the item; the order’s state is computedone slow item cannot hide behind an order-level flag
The kitchen subscribes to orders (Observer) instead of being calleda new screen or printer is a registration, not an edit to Order
Splitting is exact integer allocationthree ways to split $10.00 still sum to $10.00

Decision 1 — lifecycle on the item

  • Put the state machine on OrderItem; Order.state is a computed property = the least-advanced live item (min over items, voided filtered out).
  • One source of truth, so order and items can never disagree. No sync problem.
  • Item states: PLACED -> FIRED -> COOKING -> READY -> SERVED, plus VOIDED.
  • VOIDED reachable only from PLACED/FIRED (before the food exists); no exit from COOKING except READY — cancelling a cooking dish is a comp (money, on Bill), not a void.
  • Store legal moves as a dict state -> set of reachable states; illegal move raises ValueError (fail loud, not silent).
  • ItemState is an IntEnum so min is meaningful; VOIDED = 99 sits off the scale so accidental comparisons stand out.
  • Not the State pattern: one event (advance), so a transition table is the right size.
PLACED ─fire→ FIRED ─cook→ COOKING ─plate→ READY ─deliver→ SERVED
   └────────────┴──void──→ VOIDED   (only before food exists)

Decision 2 — kitchen is an Observer

  • Order holds a subscriber list; on advance it walks the list calling notify(OrderEvent). It never names its consumers.
  • Consumers: kitchen display (per station), runner app, ticket printer, analytics sink. Each filters on ev.station; routing lives in the subscriber, not the publisher.
  • Commit the transition first, then publish — a broken screen must never roll back food already cooking.
  • Collect delivery failures (undelivered), never propagate them.
  • OrderEvent is frozen (immutable); OrderObserver is a Protocol (structural, no inheritance); use field(default_factory=list) to avoid the shared-mutable-default bug.

Observer costs and the one rule

  • Flow is no longer readable end to end; fine at 4 subscribers, a debugging problem at 40.
  • Ordering across subscribers is not guaranteed — never depend on it; read item state directly if you need sequence.
  • Over a network it stops being an Observer and becomes a queue plus a snapshot on reconnect.
  • Rule: an observer must not mutate the domain. If it does, it is a policy and must be named as one.

Decision 3 — splitting so parts add up

  • The three obvious splits of $10.00 three ways are all wrong: floor and round give 999 (short a cent), ceil gives 1002 (over by two).
  • Fix: largest-remainder method in allocate(total, weights) — floor every share, hand leftover units to the largest discarded fractions, break ties by index (deterministic).
  • sum(parts) == total holds by construction (leftover is defined as total minus the floors). Fairness: max - min <= 1.
  • Every policy calls the one allocate: split_equal uses weights [1]*n, split_by_share uses the shares, split_by_item uses food subtotals. This is Strategy over one shared function.
  • Tax and tip: allocate by each payer’s subtotal, not evenly (run allocate a second time), or the water-drinker subsidises the steak.
  • Money is always integer minor units, single currency — no floating point (binary float cannot represent 0.10 exactly; sums drift).
  • Record which policy ran on the bill; the amount owed depends on the choice at the till.

Core object separations

Keep separateBecause
MenuItem vs OrderItemorder line captures price at order time; an 8pm price change must not reprice a 7pm check
Order vs Billkitchen’s unit of work vs cashier’s unit of money; PAID belongs to Bill, order ends at SERVED
Table vs Reservationfurniture outlives parties; a reservation is a claim on a time window, can exist before a table is assigned

Relationship gotchas: Order *-- OrderItem is composition (delete order, delete lines); Order o-- Observer is aggregation (a screen outlives orders — composition would unregister it on close).

Table lifecycle and the state everyone forgets

  • FREE -> RESERVED -> SEATED -> DIRTY -> FREE.
  • DIRTY (vacated, not yet cleared) is the state first drafts skip; a boolean is_occupied cannot express “empty but not ready”.
  • Faster bussing buys covers: cutting clearing from 15 to 5 min is about 7.1 extra covers a night — invisible in a system that cannot model the transition.

Extensions

  • Course timing: add Course as a first-class object (subset with a trigger) plus a CourseTimer observer; item state machine unchanged. CourseTimer mutates the domain, so it is a policy, and it is a re-entrancy hazard (set fired=True before the nested advance).
  • Split across payment methods: allocate unchanged; Bill gains open -> partially paid -> paid. apply_tender checks taken > total before appending. Hard part is a second card declining after the first is captured — authorize-then-capture, or a durable journal.
  • Takeaway with no table: the diagram’s Order --> "1" Table was a lie. Fix with polymorphism (Fulfillment -> DineIn / Takeaway), not a nullable table — a None cannot carry behaviour (different terminal step, timing, and cover-counting).
Want the full picture? The lesson has the derivations, worked examples, and diagrams this card compresses into bullets. Read the full lesson →
Report a bug