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
| Decision | Consequence |
|---|---|
| State lives on the item; the order’s state is computed | one slow item cannot hide behind an order-level flag |
| The kitchen subscribes to orders (Observer) instead of being called | a new screen or printer is a registration, not an edit to Order |
| Splitting is exact integer allocation | three ways to split $10.00 still sum to $10.00 |
Decision 1 — lifecycle on the item
- Put the state machine on
OrderItem;Order.stateis a computed property = the least-advanced live item (minover 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, plusVOIDED. VOIDEDreachable only fromPLACED/FIRED(before the food exists); no exit fromCOOKINGexceptREADY— cancelling a cooking dish is a comp (money, onBill), not a void.- Store legal moves as a dict
state -> set of reachable states; illegal move raisesValueError(fail loud, not silent). ItemStateis anIntEnumsominis meaningful;VOIDED = 99sits 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
Orderholds a subscriber list; onadvanceit walks the list callingnotify(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. OrderEventisfrozen(immutable);OrderObserveris aProtocol(structural, no inheritance); usefield(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) == totalholds by construction (leftoveris defined as total minus the floors). Fairness:max - min <= 1.- Every policy calls the one
allocate:split_equaluses weights[1]*n,split_by_shareuses the shares,split_by_itemuses food subtotals. This is Strategy over one shared function. - Tax and tip: allocate by each payer’s subtotal, not evenly (run
allocatea 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 separate | Because |
|---|---|
MenuItem vs OrderItem | order line captures price at order time; an 8pm price change must not reprice a 7pm check |
Order vs Bill | kitchen’s unit of work vs cashier’s unit of money; PAID belongs to Bill, order ends at SERVED |
Table vs Reservation | furniture 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 booleanis_occupiedcannot 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
Courseas a first-class object (subset with a trigger) plus aCourseTimerobserver; item state machine unchanged.CourseTimermutates the domain, so it is a policy, and it is a re-entrancy hazard (setfired=Truebefore the nestedadvance). - Split across payment methods:
allocateunchanged;Billgains open -> partially paid -> paid.apply_tendercheckstaken > totalbefore 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" Tablewas a lie. Fix with polymorphism (Fulfillment->DineIn/Takeaway), not a nullabletable— aNonecannot carry behaviour (different terminal step, timing, and cover-counting).