“Design the system a restaurant runs on: tables, reservations, orders to the kitchen, and the bill at the end.”
What this chapter is
This chapter builds an object model — a set of classes plus the relationships between them — for the software a restaurant runs on: seating parties at tables, pushing their food through a kitchen, and collecting the money at the end.
Three ideas come out of that problem:
- Why the lifecycle of an order belongs to each individual dish rather than to the order as a whole.
- How to let a kitchen screen, a bar screen, a printer and a runner’s phone all react to the same event without any of them being named inside the order. That is the Observer pattern: one object announces what happened to a list of subscribers who registered an interest.
- How to split a bill three ways so that the three parts add up to the bill exactly, to the cent, every time.
By the end you should be able to draw the class diagram and defend every arrow in it, explain why a single status field on an order cannot represent the most ordinary event in a restaurant, and show on running Python that the three obvious ways to split ten dollars three ways are all wrong.
You do not need to have read anything else in this repository. No restaurant experience is assumed — every piece of kitchen vocabulary is defined where it first appears.
Why this problem is on the list
Nothing in a restaurant happens atomically, and nothing happens alone. Atomically means in one indivisible instant with no in-between.
A steak is fired — released to the kitchen to start cooking — then cooked, then plated, over twenty minutes. The gin next to it is poured in twenty seconds. A table is free, then reserved, then seated, then covered in dirty plates and not free at all.
So this is the workflow problem in the set. The objects are easy; the lifecycles are the design. Candidates lose it in two places:
- treating an order as one state instead of a set of item states, and
- splitting a bill three ways so that the parts do not add up to the bill.
Three decisions carry the design. The middle column is what each one buys — the thing you get for free once the decision is made.
| The decision | The consequence | Section |
|---|---|---|
| State lives on the item, and the order’s state is computed | one slow item cannot be hidden by an order-level flag | Decision 1 |
| The kitchen subscribes to orders rather than being called by them | a bar printer and a runner app are registrations, not edits | Decision 2 |
| Splitting is an exact integer allocation | three ways to split $10.00 still sums to $10.00 | Decision 3 |
The ask
Strip the problem to one sentence before drawing anything. The sentence tells you how many independent lifecycles you are being asked to model.
One line: seat parties, move their food through a kitchen, and collect exactly the amount owed from however many people want to pay.
Read the three clauses as three clocks running at once:
- a table turns over on a scale of hours,
- a dish moves through the kitchen on a scale of minutes,
- the money settles in one burst at the end.
Any design that tries to run all three off one status field will be wrong about at least two of them.
What goes in, and what comes out
Fix the shape of the system before drawing classes. Name the calls a caller makes and what each hands back, so that later you know what the tests are allowed to assert on.
Four operations make up the entire public surface. Read each IN line as a call you could type and each OUT line as everything that happens because of it.
IN order.advance("i1", FIRED) the item id, and the state to move it to
OUT nothing is returned. Two observable changes happen: that item's state
is now FIRED, and every subscriber has been handed an OrderEvent
describing the change. An illegal move raises ValueError instead
IN order.state a property, not a stored field
OUT the LEAST-advanced state among the items that are still live, so an
order whose drinks are served and whose steak is cooking reads COOKING
IN split_by_item(items, n=3, tax=4_70, tip=10_60)
OUT a list of n integers, in cents, that sums to EXACTLY the bill total.
Same input, same output, every time — the tie-break is deterministic
IN apply_tender(68_30, tenders_so_far, Tender(40_00, "visa", "a1"))
OUT the string "PARTIALLY_PAID" or "PAID", and the tender is appended.
Paying more than the bill raises ValueError rather than being accepted
A tender is one act of payment against a bill — one card swipe, one handful of cash. A bill paid by three people has three tenders.
Two signatures that carry the design
order.state is a computed property: a value derived from the items every time it is read, never stored. That is what makes it impossible for the order and its items to disagree — there is no second copy to fall out of step.
Every splitting function returns a list of integers in minor units, meaning whole cents rather than dollars-and-fractions. The reason is the invariant — a property that must hold after every operation, no matter what the inputs were. Here the invariant is “the parts sum to the whole,” and it is only expressible in exact arithmetic.
The output is mostly not a return value
Most of what this design does is observable change, not returned data: an item’s state, the contents of each subscriber’s queue, and the list of tenders against a bill.
The tests later in this chapter assert on hot.queue, runner.ready, o.state and o.undelivered rather than on what a method handed back. Naming those observation points before writing code is what makes the code testable.
Clarifying questions that change the design
Ask only questions whose answers move a class boundary. Each of these four adds or deletes a class rather than adjusting one — the two answers in each row are two different systems.
| Question | Answered yes | Answered no |
|---|---|---|
| Do items within one order move independently? | state lives on OrderItem and the order’s state is computed from them | one status field on Order, and the design cannot express “the drinks arrived” |
| Can one bill be paid by several people? | payment is a list of tenders with a sum invariant, and the bill gains a partially-paid state | a bill has one payment and one terminal state |
| Are there courses, or does food go out when it is ready? | Course is an object in its own right, with its own trigger for sending the next one to the kitchen | timing is per item, and the kitchen decides it |
| Takeaway as well as dine-in? | an order is not guaranteed to have a table, and that changes a number on the diagram | Order -> Table is 1 and stays 1 |
Ask that second question in the first minute. “Split the bill” sounds like a reporting feature and it is a change to the payment model: a bill that can be half paid is a different object from a bill that is either paid or not.
Actors and use cases
An actor is anyone who starts an interaction with the system. Six touch this one. The middle column is worth learning because interviewers use this vocabulary without explaining it.
| Actor | Use case | What it touches |
|---|---|---|
| Host | take a reservation, seat a party, mark a table clean | Table lifecycle |
| Server | open an order, add items, fire them, mark served | Order, OrderItem |
| Kitchen (line cook) | see a ticket, start it, mark it ready | reads events, writes item state |
| Runner / expo | see what is ready, deliver it | reads events |
| Cashier | close the bill, split it, take tenders | Bill, Tender |
| Manager | void an item, comp a course, read the covers report | everything, with an audit trail |
The five words in that table
All five recur throughout the chapter.
- Fire. To release an item to the kitchen to start cooking. The order is taken earlier; firing is a separate decision so that the mains do not land while the starters are still being eaten.
- Expo. Short for expediter — the person at the counter between kitchen and dining room who checks each plate and dispatches a runner to carry it out.
- Void. To cancel an item before it exists, so nobody pays for it.
- Comp. Short for complimentary. To give away something that was already made. That is a decision about money rather than about food, which is why the two are separate operations.
- Cover. One diner served. A table of four seated twice in a night is eight covers, and covers are the number restaurants measure themselves in.
Core objects, and why those
The objects here matter less than the separations between them, because the first draft usually merges the wrong pairs: Table holding an Order holding a list of MenuItem, with a status field on the order. Three separations are needed before that design survives a normal Friday night.
| Object | Why it is separate |
|---|---|
MenuItem vs OrderItem | the menu is a catalogue; the order line is an instance of a menu entry with a seat, a station, modifiers and a price captured at the moment it was ordered. Raising the steak price at 8pm must not reprice a check that opened at 7 |
Order vs Bill | the kitchen’s unit of work and the cashier’s unit of money are not the same object. Two parties can share one order and split one bill; one party at a bar can run several orders onto one bill |
Table vs Reservation | a table is furniture and outlives every party; a reservation is a claim on a time window and can exist before any table has been assigned to it |
Two more terms from that table:
- A station is a position in the kitchen with its own equipment and its own queue — hot line, cold larder, bar, pastry. It is the field that decides which screen a ticket appears on.
- A check is the restaurant’s word for the bill. The two are used interchangeably here.
Say this one out loud: PAID is not a state of the food. Put payment on the order’s lifecycle and you have made “the customer paid and then ordered a coffee” unrepresentable, because the order is already in a terminal state. Payment belongs to Bill, and the order’s terminal state is SERVED.
Class diagram
Here is the whole object model — not something to absorb in one pass. The arrowheads carry claims that the boxes do not, and each of those claims is decoded and read back as an English sentence below.
classDiagram
class Restaurant {
+List~Table~ tables
+Menu menu
}
class Table {
+int number
+int seats
+TableState state
}
class Reservation {
+datetime window_start
+int party_size
+ResState state
}
class Menu {
+MenuItem lookup(str)
}
class MenuItem {
+str sku
+int price
+str station
}
class Order {
+str order_id
+ItemState state
+advance(str, ItemState)
+subscribe(OrderObserver)
}
class OrderItem {
+int price
+int seat
+str station
+ItemState state
+advance(ItemState)
}
class OrderObserver {
<<interface>>
+notify(OrderEvent)
}
class Bill {
+int total
+List~Tender~ tenders
+split(SplitPolicy) List~int~
}
class Tender {
+int amount
+str method
}
class SplitPolicy {
<<abstract>>
+split(Bill) List~int~
}
Restaurant "1" *-- "1..*" Table : composition
Restaurant "1" *-- "1" Menu : composition
Restaurant "1" o-- "0..*" Reservation : aggregation
Menu "1" *-- "1..*" MenuItem : composition
Reservation "0..*" --> "0..1" Table : assigned to
Order "1" *-- "1..*" OrderItem : composition
Order "1" --> "1" Table : seated at
Order "1" o-- "0..*" OrderObserver : subscribers
OrderItem "1" --> "1" MenuItem : priced from
Bill "1" --> "1..*" Order : covers
Bill "1" *-- "0..*" Tender : composition
Bill "1" --> "1" SplitPolicy : delegates to
OrderObserver <|.. KitchenDisplay
OrderObserver <|.. RunnerApp
OrderObserver <|.. TicketPrinter
SplitPolicy <|.. EqualSplit
SplitPolicy <|.. ByItem
SplitPolicy <|.. ByShare
Reading the notation
This is a UML class diagram — UML being the Unified Modeling Language, the standard set of shapes for drawing software structure.
- Each box is a class. The lines inside it are its fields and methods, and a leading
+marks a member as public, meaning callable from outside the class. List~Tender~is mermaid’s way of writingList<Tender>, a list whose elements areTenderobjects. Mermaid uses tildes because angle brackets would collide with HTML.- A label in double angle brackets is a stereotype, an extra note about what kind of thing a box is.
<<interface>>marks a pure contract — a list of method signatures with nothing behind them.<<abstract>>marks a class that is never created on its own and exists to be inherited from. - The quoted numbers on the ends of a line are multiplicities, saying how many objects sit at that end:
1is exactly one,0..1is at most one,1..*is one or more,0..*is any number including none. skuis a stock-keeping unit, the catalogue code that identifies one sellable thing.
Four line styles appear, and they mean four different things:
*--is composition, drawn with a filled diamond at the owner’s end: the owner controls the part’s lifetime, so destroying the owner destroys the part.o--is aggregation, drawn with a hollow diamond: a “has a” relationship that does not own a lifetime, so the part can outlive the whole or be shared with others.-->is a plain association: this object holds a reference to that one and can call it.<|.., a dashed line with a hollow triangle, is realization: the class at the tail implements the interface or abstract class at the head.
Reading the arrows back as sentences
- A
Restaurantcomposes one or moreTables and exactly oneMenu, which in turn composes one or moreMenuItems. Close the restaurant and the furniture and the catalogue go with it. - It aggregates any number of
Reservations, including none, because a reservation is a record that can be archived independently of the building. - A
Reservationpoints at at most oneTable. That0..1is the claim that a booking can exist before anyone has decided where to seat it. - An
Ordercomposes one or moreOrderItems, points at oneTable, and aggregates any number ofOrderObservers. - Each
OrderItemis priced from exactly oneMenuItem. Priced from, not containing — that is how the price gets copied at order time instead of being read live. - A
Billcovers one or moreOrders, composes any number ofTenders, and delegates the arithmetic of splitting to oneSplitPolicy. KitchenDisplay,RunnerAppandTicketPrintereach realize theOrderObservercontract.EqualSplit,ByItemandByShareeach realizeSplitPolicy.
Two arrows are worth defending and one is worth distrusting:
Order *-- OrderItemis composition because an order line has no meaning without its order, so deleting the order deletes the lines.Order o-- OrderObserveris aggregation because a kitchen display outlives thousands of orders and must not be destroyed with any of them. Get that one wrong and closing a check unregisters the screen.- The arrow to distrust is
Order --> "1" Table. Extension 3 is the day it turns out to be a lie.
What the code in this chapter covers
The diagram is the whole model; the running code is the part the three decisions turn on. Knowing which is which stops you looking for classes that are not here.
| Diagram box | In the code below |
|---|---|
OrderItem, Order, OrderEvent, OrderObserver | implemented and exercised in Decision 2 |
KitchenDisplay, RunnerApp, TicketPrinter | KitchenDisplay, RunnerApp, and FlakyPrinter standing in for the printer |
EqualSplit, ByItem, ByShare | the functions split_equal, split_by_item, split_by_share, all calling one shared allocate |
Tender | implemented in extension 2, alongside apply_tender |
Restaurant, Table, Reservation, Menu, MenuItem, Bill | diagram only — plain data holders that no decision turns on |
What this class structure assumes
A class diagram is a frozen bet about what will change. Every interface you draw says “I expect this to vary”; every field you hard-code says “I expect this to hold forever”. Naming those bets out loud is the transferable skill here, because this particular restaurant will not be your job — the habit of stating your own assumptions will. The general form of the argument is What a class structure assumes; what follows is this design’s version.
Assumed to vary — and therefore given an interface, a parameter, or a computed value. The third column is what you would be paying if you had guessed wrong.
| What varies | How the design absorbs it | What it would cost to have got this wrong |
|---|---|---|
| Who wants to know that an item moved | The OrderObserver interface plus a subscriber list | Every new screen, printer or reporting store edits Order |
| How a bill is divided | The SplitPolicy interface over one shared allocate function | Three near-copies of the same rounding logic, drifting apart |
| How far along each dish is | State on OrderItem; Order.state is computed from them | A single status on the order, which cannot describe a normal table |
| The menu, its prices and its stations | Data in Menu, copied onto the order line at order time | A price change repricing checks that are already open |
| How many people pay, and how | A list of Tenders with a sum invariant | A single payment field, and split bills become a manual process |
Assumed fixed — and therefore baked into the structure rather than into a parameter:
- The set of item states is closed at six, and the legal moves between them are a dictionary in one method. A seventh situation is a code change rather than a configuration change.
- An item belongs to exactly one station, which is why routing a ticket is a string comparison and not a query.
- Money is a whole number of minor units in a single currency, so there is no rounding and no floating point anywhere.
- Publication of events is synchronous and in-process. The publisher calls each subscriber directly, and every subscriber has been notified by the time
advancereturns. The last bullet of Decision 2 attacks this assumption on purpose. - An order belongs to a table, which is the assumption extension 3 breaks.
What an Observer design specifically assumes. These two are not about restaurants at all. They are what any publish-subscribe design rests on, and an interviewer probing this design is usually probing one of them.
- Subscribers are cheap, quick and harmless. The publisher calls them one after another inside the transition, so a subscriber that blocks for a second has just made every dish in the restaurant a second slower. What breaks when it is false: a subscriber that performs network input or output turns a kitchen ticket into a distributed transaction. The repair is a queue between the publisher and the slow consumer, which is the same repair as the fourth bullet in Decision 2.
- Nobody needs to know the order in which subscribers ran. The subscriber list has an order because it is a list, but nothing about the design promises it, and any consumer that depends on it will break the day someone registers a screen in a different sequence. What breaks when it is false is subtle and late: a report that quietly assumes the reporting store saw
READYbefore the runner did.
What a different assumption would have produced. This is the part worth rehearsing, because it is what interviewers reach for when they want to know whether you chose the design or copied it.
- If items did not move independently — a tasting menu where the whole table is served at once — then a single
statuson theOrderis correct,OrderItembecomes a line of data with no behaviour, and Decision 1 evaporates. The interviewer chose à la carte — ordering dish by dish rather than as a fixed set menu — on purpose. - If there were exactly one consumer of order events, the Observer pattern is overhead and a direct method call is clearer. The pattern earns its keep at the third consumer, and the honest number to give is “four, and I would not add a fifth without a queue”.
- If prices could be recomputed at any time rather than captured,
OrderItem.pricedisappears and every total becomes a live lookup — which is a business decision that says a customer’s bill may change while they eat, and no restaurant makes it. - If money were a floating-point number of dollars, the exact-sum invariant in Decision 3 becomes unstateable, because the parts cannot be made to add to the whole reliably. The argument, with assertions, is in Money is the first design decision; the short version is that binary floating point cannot represent a value like 0.10 exactly, so sums drift by a cent.
- If the screens were across a network instead of in the same process, this stops being an Observer at all and becomes a message queue with a snapshot on reconnect. Same diagram, entirely different failure modes.
Tables, and the state everyone forgets
Before leaving the diagram, look at the smallest lifecycle in it, because it hides the most surprising number in the chapter.
FREE -> RESERVED -> SEATED -> DIRTY -> FREE
DIRTY — the table has been vacated but not yet cleared and wiped — is the state first drafts skip, and it is worth real money.
Two more words before the arithmetic. Bussing is the industry term for clearing and resetting a table between parties. A turn is one use of a seat by one party, so a seat that hosts two parties in a night has turned twice.
Now price ten minutes of bussing. Every line below is one arithmetic step; the number to watch is the last one.
seats 40
dining time per party, hours 2.00
bussing time, hours 0.25 (15 minutes)
service window, hours 5.00
seat cycle 2.00 + 0.25 = 2.25 hours
turns per seat 5.00 / 2.25 = 2.222
covers per night 40 * 2.222 = 88.9
now cut bussing to 5 minutes = 0.083 hours
seat cycle 2.00 + 0.083 = 2.083 hours
turns per seat 5.00 / 2.083 = 2.400
covers per night 40 * 2.400 = 96.0
extra covers 96.0 - 88.9 = 7.1
Read it as a story. One seat is occupied for two hours of dining plus fifteen minutes of clearing, so the full cycle is 2.25 hours. A five-hour service fits 2.222 of those cycles into each seat, and forty seats therefore produce 88.9 covers.
Cut the clearing from fifteen minutes to five — 0.083 of an hour — and the cycle drops to 2.083 hours. The same five hours now fit 2.4 turns, and forty seats produce 96 covers. The difference is 7.1 covers a night.
Ten minutes of bussing is worth seven covers a night. That is why the host’s screen has to distinguish “nobody is sitting there” from “you may seat someone there”. A boolean is_occupied field cannot express the difference, and the extra covers stay invisible in a system that cannot measure a transition it does not model.
Decision 1 — the lifecycle belongs to the item, not the order
An order does not have one state; its dishes do. So the state machine goes on the individual item, the order’s state is computed from the items — and the heavier pattern that the same problem sometimes deserves turns out not to be worth it here.
The one-status-field model, and where it breaks
The tempting model is a single Order.status. It breaks on the most ordinary event in a restaurant: the drinks are on the table and the food is not. With one status field you either lie about the drinks or lie about the steak.
So state lives on OrderItem, and the order’s state is computed as its least-advanced live item.
That is one property rather than a synchronization problem — there is no second copy of the truth to keep in step. It also turns “which table has been waiting longest?” into a query rather than a field somebody has to remember to update.
The item state machine
A state machine is a design where an object is in exactly one named situation at a time and only certain moves out of it are legal. Written as a table, this one has five rows. Each row is one legal move, and anything not in the table is rejected.
| From | Event | To | Who |
|---|---|---|---|
PLACED | server fires the item | FIRED | server |
PLACED / FIRED | server voids the item | VOIDED | server, with a reason |
FIRED | cook picks up the ticket | COOKING | kitchen |
COOKING | cook plates it | READY | kitchen |
READY | runner delivers it | SERVED | runner |
Two rules read straight off that table, and both are the kind of thing an interviewer is listening for.
There is no transition out of COOKING except READY. Once protein is on the flat-top — the flat steel griddle that most of a restaurant’s food is cooked on — a cancellation is a comp rather than a void. That is a money decision, and it belongs to Bill.
VOIDED is reachable only from the two states that exist before the food does. That is exactly the guard that stops a server cancelling a plated dish to hide a mistake.
Why not the State pattern
The State pattern — one class per state, each implementing the same set of events — is not worth it here, and saying so is worth more than naming it.
That pattern pays for itself when several different events behave differently in several different states, because it replaces a grid of if statements with one class per column. The crossover is argued with numbers in ch 07, decision 1.
Here there is essentially one event, advance, and the whole machine is a transition table. A dictionary of legal moves is the right size of solution.
The code
OrderItem is one line on a check. It holds the price it was sold at, the seat and station it belongs to, and its own position in the state machine above. advance is the only way its state changes.
from __future__ import annotations
from dataclasses import dataclass, field
from enum import IntEnum
from typing import Protocol
class ItemState(IntEnum):
PLACED = 0
FIRED = 1
COOKING = 2
READY = 3
SERVED = 4
VOIDED = 99 # off the ordering scale on purpose: it is not progress
@dataclass
class OrderItem:
item_id: str
name: str
price: int # minor units, captured at order time
seat: int
station: str = "hot"
state: ItemState = ItemState.PLACED
def advance(self, to: ItemState) -> None:
legal = {ItemState.PLACED: {ItemState.FIRED, ItemState.VOIDED},
ItemState.FIRED: {ItemState.COOKING, ItemState.VOIDED},
ItemState.COOKING: {ItemState.READY},
ItemState.READY: {ItemState.SERVED},
ItemState.SERVED: set(),
ItemState.VOIDED: set()}
if to not in legal[self.state]:
raise ValueError(f"{self.state.name} -> {to.name} is not a transition")
self.state = to
Four things in that block are load-bearing rather than incidental.
from __future__ import annotationstells Python to keep type annotations as text instead of evaluating them when the class is defined, which is what lets a class refer to types that are not defined yet. It has no run-time cost.IntEnumis an enumeration whose members are also integers, so the members compare and sort. That is not decoration: it is what makesmin(...)over a list of states meaningful in Decision 2, where the order’s state is defined as the minimum over its items.VOIDED = 99sits deliberately outside the 0-to-4 progression. A voided item is not “very advanced”, it is not on the scale at all, and the number is chosen so that any code which accidentally compares it stands out rather than quietly treating a cancelled dish as the most finished thing on the table. The computed property in Decision 2 filters voided items out before taking the minimum, which is the correct handling.@dataclassgenerates the constructor, the equality test and the string form from the field list, so the class body reads as a declaration of what an order line is.
That price: int carries a comment that is really a design decision. Money is stored in minor units, so 32_00 means 3200 cents, or $32.00. The underscore is a digit separator that Python ignores, used here to make the dollars-and-cents boundary readable.
How the transition check works
legal is a dictionary from a state to the set of states reachable from it. Looking up legal[self.state] and testing membership is the entire machine.
An illegal move raises rather than silently doing nothing, so the mistake surfaces at the moment it is made instead of as a wrong state discovered later.
ItemState.SERVED: set() and ItemState.VOIDED: set() are the terminal states, written explicitly as empty sets rather than omitted. An omission would raise KeyError, which is a crash that tells the reader nothing. The empty set produces the informative SERVED -> FIRED is not a transition.
Decision 2 — the kitchen is an Observer, and what that costs
Decision 1 gave every dish a lifecycle of its own. Now those transitions need an audience — screens, printers, a runner’s phone — and letting the kitchen subscribe to order events instead of being called by name is a pattern that quietly introduces failure modes of its own, kept usable by one discipline.
The requirement change that justifies the pattern
When an item is fired, four things have to happen at once:
- the hot line’s screen shows a ticket,
- the bar’s screen shows a different ticket,
- a printer at the pass — the counter where finished plates are handed from kitchen to dining room — prints a paper chit, a small ticket telling the cook what to make,
- an analytics sink — a store kept only so that someone can run reports later — records a timestamp.
Written as direct calls, Order.fire() names all four, and adding the runner’s phone app means editing Order. A display is a consumer of order events, and an order should not know how many consumers exist.
The Observer pattern inverts the direction of that knowledge. The order keeps a list of subscribers, each of which implements one agreed method. When something happens the order walks the list and calls that method, without knowing or caring what any subscriber does with it. Adding a consumer becomes a registration rather than an edit.
The publisher
Three pieces below: the event (what gets announced), the observer contract (what a subscriber must implement), and Order itself (the publisher). Watch the ordering inside advance — it is the part with a rule attached.
@dataclass(frozen=True)
class OrderEvent:
order_id: str
item_id: str
state: ItemState
station: str
class OrderObserver(Protocol):
def notify(self, ev: OrderEvent) -> None: ...
@dataclass
class Order:
order_id: str
items: list[OrderItem] = field(default_factory=list)
_subs: list[OrderObserver] = field(default_factory=list)
undelivered: list[tuple[str, OrderEvent]] = field(default_factory=list)
def subscribe(self, obs: OrderObserver) -> None:
self._subs.append(obs)
@property
def state(self) -> ItemState:
"""Computed: an order is only as advanced as its slowest live item."""
live = [i.state for i in self.items if i.state is not ItemState.VOIDED]
return min(live) if live else ItemState.VOIDED
def advance(self, item_id: str, to: ItemState) -> None:
item = next(i for i in self.items if i.item_id == item_id)
item.advance(to) # commit the transition FIRST
self._publish(OrderEvent(self.order_id, item_id, to, item.station))
def _publish(self, ev: OrderEvent) -> None:
for obs in self._subs:
try:
obs.notify(ev)
except Exception as exc: # a broken screen never rolls back food
self.undelivered.append((f"{type(obs).__name__}:{exc}", ev))
Five idioms there are design decisions in disguise.
@dataclass(frozen=True)onOrderEventmakes the event immutable: once created, no field can be reassigned. An event is a statement about something that already happened, and a subscriber must not be able to edit it before the next subscriber sees it.Protocolis Python’s structural interface. Any object at all with anotify(ev)method counts as anOrderObserver— no inheritance, no registration, no import of the order module from the display module. The...in the body is Python’sEllipsisliteral, used as a “no implementation here” placeholder.field(default_factory=list)gives eachOrderits own empty list. Writing_subs: list = []instead would create one list shared by every order ever constructed, which is the classic mutable-default bug; the factory is called fresh for each instance.@propertymakesstateread like a field —order.state, no parentheses — while staying a computed answer.min(live)works becauseItemStateis anIntEnum, and it returns the least-advanced live item, which is the definition the section opened with. Voided items are filtered out first; an order whose items are all voided reportsVOIDED, which is the only sensible answer left.next(i for i in self.items if i.item_id == item_id)returns the first matching item and raisesStopIterationif there is none, which is a deliberate refusal to invent an item that does not exist.
The ordering inside advance is the part to say out loud. The transition is committed to the item first, and only then is the event published. A broken screen must never roll back food that is already cooking.
Three subscribers
Two of these are ordinary and the third fails on every single event, on purpose. Notice that neither display is mentioned anywhere in Order — the routing lives in the subscriber.
@dataclass
class KitchenDisplay:
station: str
queue: list[str] = field(default_factory=list)
def notify(self, ev: OrderEvent) -> None:
if ev.station != self.station:
return
if ev.state is ItemState.FIRED:
self.queue.append(ev.item_id)
elif ev.state in (ItemState.READY, ItemState.VOIDED):
if ev.item_id in self.queue:
self.queue.remove(ev.item_id)
@dataclass
class RunnerApp:
ready: list[str] = field(default_factory=list)
def notify(self, ev: OrderEvent) -> None:
if ev.state is ItemState.READY:
self.ready.append(ev.item_id)
class FlakyPrinter:
def notify(self, ev: OrderEvent) -> None:
raise IOError("out of paper")
Each display filters on ev.station and ignores everything else. That is how one broadcast becomes per-station routing without the publisher knowing anything about stations.
FlakyPrinter raises IOError — Python’s error type for a failed input or output operation — every time it is called, standing in for the printer that is out of paper on a Friday night.
Running a table
A steak on the hot line and a gin at the bar, with four subscribers watching. The assertions are the specification: each one is a claim about what an outside observer can see.
o = Order("o1", [OrderItem("i1", "steak", 32_00, seat=1),
OrderItem("i2", "gin", 12_00, seat=2, station="bar")])
hot, bar, runner = KitchenDisplay("hot"), KitchenDisplay("bar"), RunnerApp()
for sub in (hot, bar, runner, FlakyPrinter()):
o.subscribe(sub)
assert o.state is ItemState.PLACED
o.advance("i1", ItemState.FIRED)
o.advance("i2", ItemState.FIRED)
assert hot.queue == ["i1"] and bar.queue == ["i2"] # routed by station, not broadcast
o.advance("i1", ItemState.COOKING)
o.advance("i1", ItemState.READY)
assert hot.queue == [] and runner.ready == ["i1"]
assert o.state is ItemState.FIRED # the gin is still waiting, so the order is
assert len(o.undelivered) == 4 # the printer failed every time
try:
o.advance("i1", ItemState.FIRED) # and the machine still refuses to go back
except ValueError as e:
assert "READY -> FIRED" in str(e)
Walk the assertions in order:
- Both items start
PLACED, so the order does. - Firing each one puts its id on its own station’s screen and on no other. That is per-station routing, demonstrated.
- Moving the steak to
COOKINGand thenREADYclears it from the hot screen and puts it in the runner’s list. - At that moment the order’s own state is
FIRED, notREADY, because the gin has not moved and the least-advanced live item is what the order reports. - There have been four transitions and the printer raised on all four, so
undeliveredholds exactly four entries while the food carried on regardless. - The final block confirms the item machine still refuses to go backwards, and the error message names the illegal move. Note that this fifth call raises before publishing anything, which is why
undeliveredstays at four.
What Observer costs, and none of it is theoretical
- The flow is no longer readable end to end. “Why did the bar screen clear?” is answered by finding every subscriber, not by reading one method. That is the standing price of Observer, and it is why a four-subscriber system is fine and a forty-subscriber one is a debugging problem.
- Failure isolation has to be deliberate. The printer above raises on every event. Publishing inside the transition would have rolled the item back and stopped the kitchen because of a paper jam, so the transition commits first and delivery failures are collected into
undeliveredrather than propagated to the caller. - Ordering is not guaranteed and must not be relied on. Nothing promises that the hot screen sees
FIREDbefore the analytics sink does. Any consumer that needs a sequence must read the item’s state directly rather than infer it from the order the events arrived in. - Over a network it is not an Observer any more. A screen on the wall reconnects after a power blip and has missed events. In the same process this pattern is a method call; across a socket it is a queue plus a snapshot sent on connect, and pretending otherwise is the mistake that ships a kitchen display which quietly loses tickets. Say which one you are building.
And the discipline that keeps it usable: an observer may not mutate the domain. It may not change the business objects it is being told about, only its own view of them. A KitchenDisplay that decrements inventory has put business logic behind a name that says it draws pixels, and the next person to unregister it for a test will silently break stock counts.
Decision 3 — splitting a bill so the parts add up
The food is served; now three people want to pay for it. The three obvious ways to divide a bill are all wrong, one algorithm is right, and it has to be applied twice — because tax and tip have to follow what each person actually ate.
Three obvious implementations, all wrong
Three people, a $10.00 check. Each line below is one way a candidate first writes it, and each assertion is the total it actually produces.
naive_floor = [1000 // 3] * 3 # 333, 333, 333
naive_round = [round(1000 / 3)] * 3 # 333, 333, 333
naive_ceil = [-(-1000 // 3)] * 3 # 334, 334, 334
assert sum(naive_floor) == 999 # the restaurant is a cent short
assert sum(naive_round) == 999 # rounding does not save you
assert sum(naive_ceil) == 1002 # and ceiling overcharges by two
The amounts are in cents, so $10.00 is 1000.
//is integer division, which discards the fraction, so1000 // 3is 333 and three of those come to 999.- Ordinary rounding lands in the same place as flooring here, because 333.33 rounds down.
-(-1000 // 3)is the standard Python idiom for rounding up — negate, floor-divide, negate again — giving 334 each and a total of 1002.
A cent sounds like nothing. It is not: the payments have to reconcile against the check, so a bill that does not sum is a bill that cannot be closed automatically, and someone at the till resolves it by hand every time it happens.
Using floating-point dollars instead of integer cents makes it worse rather than better, because binary floating point cannot represent a value like 0.10 exactly and the errors accumulate silently. That argument, with its own assertions, is in ch 09, money.
The largest-remainder method
The fix is the largest-remainder method. Floor every share, count the leftover units, and hand them out one at a time to the shares whose discarded fractions were largest, breaking ties by a fixed order so that the same bill always splits the same way.
It is one function, allocate, and every split policy calls it. The three split_* functions below are the code form of the EqualSplit, ByShare and ByItem boxes on the class diagram — same shape, one function each instead of one class each, because none of them needs state.
def allocate(total: int, weights: list[int]) -> list[int]:
"""Split `total` minor units in proportion to `weights`, exactly.
Floor everything, then give the leftover units to the claims with the
biggest discarded fractions. sum(result) == total by construction, and
ties break by index so the result is deterministic.
"""
w = sum(weights)
if w <= 0:
raise ValueError("weights must sum to a positive number")
base = [total * x // w for x in weights]
leftover = total - sum(base)
order = sorted(range(len(weights)),
key=lambda i: (-((total * weights[i]) % w), i))
for i in order[:leftover]:
base[i] += 1
return base
def split_equal(total: int, n: int) -> list[int]:
return allocate(total, [1] * n)
def split_by_share(total: int, shares: list[int]) -> list[int]:
return allocate(total, shares)
def split_by_item(items: list[tuple[int, list[int]]], n: int,
tax: int = 0, tip: int = 0) -> list[int]:
"""`items` is (price, [payer indexes sharing it]). A shared plate is itself
allocated. Tax and tip then follow what each payer actually ate."""
sub = [0] * n
for price, payers in items:
for i, part in zip(payers, allocate(price, [1] * len(payers))):
sub[i] += part
return [a + b for a, b in zip(sub, allocate(tax + tip, sub))]
The sort key inside allocate is the whole algorithm, so read it slowly:
(total * weights[i]) % wis the remainder that integer division threw away for claimi.%is the modulo operator, which returns what is left over after dividing.- Negating it,
-(...), turns Python’s ascending sort into a descending one, so the biggest discarded fraction comes first. - The second element of the tuple,
i, is the tie-break. Python compares tuples left to right, so when two claims discarded the same fraction the lower index wins, always, on every machine. order[:leftover]takes exactly as many claims as there are spare cents and gives each one a single extra unit.
The result sums to the total by construction rather than by luck, because leftover was defined as the difference between the total and the sum of the floors.
Two smaller idioms. sorted(range(len(weights)), key=...) sorts indexes rather than values, which is what lets the extra cents be applied back to the right positions in base. And [1] * n builds a list of n ones, meaning “everybody’s claim is equal” — that is how equal splitting is expressed as a special case of weighted splitting rather than as its own algorithm.
Proving the invariant
One worked case, then the same property checked exhaustively.
assert split_equal(1000, 3) == [334, 333, 333] # sums to exactly 1000
for total in range(0, 2001): # every check up to $20.00
for n in range(1, 8): # every party up to 7
p = split_equal(total, n)
assert sum(p) == total # the invariant, exhaustively
assert max(p) - min(p) <= 1 # and nobody is stiffed
That loop is worth more in an interview than any amount of arguing. It checks the invariant — the parts sum to the whole — over 2,001 totals times 7 party sizes, which is 14,007 cases, and it runs instantly.
The second assertion checks fairness: no two people’s shares differ by more than one cent. “The algorithm sums correctly” must not be achieved by dumping the remainder on one unlucky diner.
Weighted splitting drops out of the same function. One person who had twice as much as each of the other two pays half.
assert split_by_share(1000, [2, 1, 1]) == [500, 250, 250]
assert sum(split_by_share(1000, [3, 2, 2])) == 1000
The second line is the case that does not divide evenly — weights 3, 2, 2 against 1000 — and it still sums exactly.
Splitting by item, where tax and tip get interesting
Splitting by item is the same machinery applied twice, and the second application is the part people miss: tax and tip must be allocated by what each person ate, not split evenly, or the person who had tap water subsidises the person who had the steak.
Work the numbers first. Everything is in cents.
steak, seat 1 3200
gin, seat 2 1200
shared plate, three ways 900
food total 3200 + 1200 + 900 = 5300
tax at 8.875% 5300 * 0.08875 = 470.375 -> 470
tip at 20% 5300 * 0.20 = 1060
bill total 5300 + 470 + 1060 = 6830
In words: one diner has a $32.00 steak, another a $12.00 gin, and all three share a $9.00 plate, so the food comes to $53.00. A combined sales tax rate of 8.875% on that is 470.375 cents, which the till charges as a whole number of cents, $4.70. A 20% tip is $10.60. The check is $68.30.
Now split it. Each entry in bill is a price and the list of payer indexes sharing it, so (9_00, [0, 1, 2]) is the shared plate split across all three.
bill = [(32_00, [0]), (12_00, [1]), (9_00, [0, 1, 2])]
out = split_by_item(bill, n=3, tax=4_70, tip=10_60)
assert out == [45_10, 19_33, 3_87]
assert sum(out) == 68_30 # to the cent
split_by_item runs in two passes.
Pass one builds each payer’s food subtotal. The shared plate divides as 900 / 3 = 300 each with nothing left over, so payer 0 ate 3200 + 300 = 3500, payer 1 ate 1200 + 300 = 1500, and payer 2 ate 300. In dollars: $35.00, $15.00 and $3.00.
Pass two calls allocate a second time with those subtotals as the weights, spreading the 470 + 1060 = 1530 cents of tax and tip in proportion to what each person ate:
payer 0 1530 * 3500 / 5300 = 1010.377 -> floor 1010
payer 1 1530 * 1500 / 5300 = 433.019 -> floor 433
payer 2 1530 * 300 / 5300 = 86.604 -> floor 86
floors sum to 1529, leftover 1
Payer 2 had the largest discarded fraction, .604, so the one leftover cent goes there: 87. Add the subtotals back and the shares are 3500 + 1010 = 4510, 1500 + 433 = 1933, and 300 + 87 = 387 — the [45_10, 19_33, 3_87] the assertion checks, summing to exactly 68_30.
Nothing about that depended on chance. Had two payers discarded the same fraction, the tie-break by index would have decided it, and it would decide it the same way on every run and every machine. A split rule that is not deterministic produces a different bill on a refresh, and a customer who reloads the pay-at-table page and sees a different number will call someone over.
The cost of the policy object
The pattern here is Strategy: pull the varying rule out into its own object so it can be swapped without editing the caller. SplitPolicy on the diagram is that object, and split_equal, split_by_share and split_by_item are its three implementations, all sharing allocate.
Its cost is small but real: three policies mean the amount a customer owes depends on a choice made at the till, so “I was charged $19.33” is unreproducible without knowing which policy ran. Record the policy on the bill.
Extension scenarios
Each of the three requirement changes below is the kind an interviewer springs at minute thirty-five. What is being measured is not whether you can code it, but how many files the change touches — and the third one is a case where the honest answer is “the diagram was wrong”.
“Now support course timing so mains fire after starters clear”
What breaks: the design has no object to hang the trigger on. OrderItem knows its own state and Order knows the minimum over all of them, but “all the starters are cleared” is a property of a subset of the items, and nothing owns a subset.
So Course becomes a first-class object sitting between them — first-class meaning it gets its own identity and its own behaviour rather than being a label on the items. It has its own small lifecycle — held, then fired, then cleared — and its own rule for when to release the next one.
The item state machine does not change at all. That is the payoff for having put state on the item in the first place: a course is a grouping with a trigger, not a new kind of state.
Course answers one question, “is this course finished?”. CourseTimer is an observer that asks that question after every event and fires the next course when the answer flips to yes.
@dataclass
class Course:
seq: int # 1 = starters, 2 = mains
item_ids: list[str]
fired: bool = False
def cleared(self, order: Order) -> bool:
return all(i.state in (ItemState.SERVED, ItemState.VOIDED)
for i in order.items if i.item_id in self.item_ids)
class CourseTimer:
"""An observer that fires the next course when the previous one clears."""
def __init__(self, order: Order, courses: list[Course]):
self.order, self.courses = order, sorted(courses, key=lambda c: c.seq)
def notify(self, ev: OrderEvent) -> None:
for prev, nxt in zip(self.courses, self.courses[1:]):
if prev.cleared(self.order) and not nxt.fired:
nxt.fired = True
for item_id in nxt.item_ids:
self.order.advance(item_id, ItemState.FIRED)
Two idioms to note. all(...) over a generator is true when every item in the course has reached SERVED or been VOIDED, and true trivially when the course has no items — the standard behaviour of all on an empty sequence, and the right answer here. zip(self.courses, self.courses[1:]) walks a list in consecutive pairs, pairing each course with the one after it, so the loop always has a “previous” and a “next” to compare.
Two courses, soup then steak. The middle assertion is the one that matters, so watch for it.
o = Order("o2", [OrderItem("s1", "soup", 9_00, seat=1),
OrderItem("m1", "steak", 32_00, seat=1)])
hot = KitchenDisplay("hot")
o.subscribe(hot)
courses = [Course(1, ["s1"], fired=True), Course(2, ["m1"])]
o.subscribe(CourseTimer(o, courses))
for st in (ItemState.FIRED, ItemState.COOKING, ItemState.READY):
o.advance("s1", st)
assert courses[1].fired is False # plated, not cleared: main holds
o.advance("s1", ItemState.SERVED) # starter cleared
assert courses[1].fired and hot.queue == ["m1"] # the main fired itself
The soup has been plated and is sitting under the heat lamp at READY, and the main is still held — because “ready” is a fact about the kitchen and “cleared” is a fact about the table.
Only when the soup reaches SERVED does the timer release the steak, which then appears on the hot screen without anyone pressing anything. The hot queue holds only m1 at the end because the soup was removed from it when it went READY.
Be honest about the two costs.
CourseTimer is an observer that does mutate the domain, which the previous section warned against. So it is not a display, it is a policy, and it must be named and registered as one so that nobody deletes it thinking it draws a screen.
And an observer that publishes while the publisher is still walking its subscriber list is a re-entrancy hazard, meaning a function is entered again before its first invocation has finished. Here CourseTimer.notify calls order.advance, which calls _publish again while the outer _publish is still mid-loop. This version survives it for two reasons: the nested call does not add or remove subscribers, so the outer loop’s list is unchanged under it; and nxt.fired is set to True before the nested advance, which is what stops the timer firing the same course twice and recursing forever. Both are easy to break. In a real build, queue the follow-on event and drain the queue after the current publish completes.
“Now split a bill across payment methods”
What does not change: allocate and every split policy. The amounts owed are already exact integers that sum to the total, and nothing about that depends on how the money arrives.
What changes is that Bill gains a state it did not have. A single payment means the bill goes from open to paid. Several tenders mean it can sit in between, so the lifecycle becomes open, then partially paid, then paid, with an invariant checked on every write.
apply_tender is that check. It returns the bill’s new state and refuses anything that would take more money than is owed.
@dataclass
class Tender:
amount: int
method: str
auth_id: str
def apply_tender(total: int, tenders: list[Tender], t: Tender) -> str:
taken = sum(x.amount for x in tenders) + t.amount
if taken > total:
raise ValueError("tenders exceed the bill")
tenders.append(t)
return "PAID" if taken == total else "PARTIALLY_PAID"
auth_id is the authorization identifier the card network hands back when it approves a charge — the receipt that lets you refer to that specific payment later, to capture it or reverse it.
The check runs before the append, so a tender that would overshoot the bill is rejected rather than recorded and then compensated.
Paying the $68.30 check from Decision 3 with a card and then cash:
ts: list[Tender] = []
assert apply_tender(68_30, ts, Tender(40_00, "visa", "a1")) == "PARTIALLY_PAID"
assert apply_tender(68_30, ts, Tender(28_30, "cash", "a2")) == "PAID"
assert sum(t.amount for t in ts) == 68_30
Forty dollars on a card leaves the bill partially paid. The remaining $28.30 in cash closes it, and the recorded tenders sum to exactly the total computed back in Decision 3.
The hard part is not the arithmetic. It is the second card declining after the first one has already been captured. You now hold real money against a bill that is not settled.
That is the same non-atomic pair of steps as a cash machine’s dispense — an irreversible external effect bracketed by a state you have to be able to recover from — and the resolution has the same shape. Either:
- authorize every tender first, meaning ask each card network to reserve the funds, and only capture — actually take the money — once the full total is authorized; or
- capture sequentially against a durable record, written before the network call, so that a partially paid bill can be resumed or refunded rather than guessed at.
The protocol worked out step by step is in ch 13, and its general form, for any effect you cannot roll back, is in ch 27, deep dive 3.
“Now add takeaway orders that have no table”
This is the extension that shows the diagram was wrong. Order --> "1" Table was true right up until someone ordered a coffee to go.
The cheap fix is the wrong one. Making table nullable — allowed to hold “nothing” — spreads if order.table is not None through the server view, the floor plan, the covers report and the receipt. Every one of those branches is a place where a future reader has to work out which case they are in.
The right fix is polymorphism on fulfilment — one abstract type with a subtype per way of getting food to a customer, each supplying its own behaviour behind a shared interface. The differences are behavioural rather than a single missing field, and a None cannot carry behaviour.
The differences are four separate behaviours, any one of which would already be too much for a nullable field:
| Dine-in | Takeaway | |
|---|---|---|
| Location | a table, held for the visit | a pickup shelf, held until collected |
| Terminal step | SERVED by a runner | COLLECTED by the customer |
| Timing | courses paced against the table | everything fires at once and goes out together |
| Occupies a cover | yes | no, and counting it as one corrupts the turn-time numbers computed earlier |
So Fulfillment is an abstract type with DineIn(table, server) and Takeaway(pickup_name) as its two subtypes — and later Delivery(address, courier) at no additional structural cost, which is the tell that the split was made along the right seam. The Order keeps one lifecycle, and the fulfilment supplies the terminal transition and the timing policy.
What it costs: one more indirection, a factory — a function whose job is to build the right subtype from the incoming request — and a discriminator column on the orders table, a stored field naming which subtype a row is, so that reporting queries can still group by type.
That is three things. The nullable field costs one conditional in every consumer forever, which is more. And the part that actually decides it: the nullable version cannot express that takeaway fires all at once, because that is behaviour and a None has no behaviour.
What interviewers probe
These are the questions this design exists to answer, with the answer that lands.
| Probe | The answer that lands |
|---|---|
| “Where does order status live?” | on the item. The order’s state is the minimum over live items, computed and never stored |
| “The drinks arrived but the food has not” | exactly the case a single Order.status cannot represent, which is why it is not there |
| “Split $10.00 three ways” | 334 + 333 + 333. Largest remainder, deterministic tie-break, asserted to sum for every total and party size |
| “Who pays the tax on a shared plate?” | allocate the plate, then allocate tax and tip by each payer’s subtotal. Splitting tax evenly subsidises the big eater |
| “Why Observer and not a call into the kitchen?” | adding the runner app and the bar printer must not edit Order. Cost: the flow is no longer readable in one place |
| “What if a display is offline?” | in the same process this is a method call and cannot happen; over a socket it is a queue with a snapshot on reconnect. Do not conflate them |
| “Is the kitchen a Singleton?” | no. A Singleton is a class rigged so only one instance can exist; two stations means two displays, and a test wants three — see ch 03 |
| “Void a cooking item?” | not a transition. Once it is on the flat-top the money question is a comp on the bill, not a cancel on the order |
Cheat sheet
| State lives on | OrderItem. Order.state is min(live item states), computed |
| Not a state of the order | PAID. Payment belongs to Bill; the order ends at SERVED |
| Order lifecycle | PLACED -> FIRED -> COOKING -> READY -> SERVED, with VOIDED reachable only before the food exists |
| Table lifecycle | FREE -> RESERVED -> SEATED -> DIRTY -> FREE; skipping DIRTY costs 7.1 covers a night |
| Observer | kitchen displays, runner app, printer, analytics. Commit the transition, then publish; collect delivery failures, never propagate them |
| Observer rule | an observer must not mutate the domain. If it does, it is a policy and must be named as one |
| Splitting | allocate(total, weights) by largest remainder, ties by index. Every policy calls it |
| The invariant | sum(parts) == total for every total and every party size, asserted exhaustively |
| Tax and tip | allocated by each payer’s subtotal, not split evenly |
| Money | integer minor units — ch 09, money |
| The arrow that was a lie | Order --> "1" Table. Takeaway makes it Fulfillment, not a nullable field |
| Say out loud | “A bill that can be half paid is a different object from one that is paid or not” |
The OOD track, in fourteen patterns
This chapter closes the object-oriented design track, so it ends with the map. The useful summary is not the list of problems — it is which decision each one turned on, because that is what transfers to the problem you actually get asked.
| Ch | Problem | What carried it | Why |
|---|---|---|---|
| 01 | What is an OOD interview | the frame | the artifact you defend is running code, not a diagram |
| 02 | A framework for the OOD interview | the method | six steps against a clock, so 45 minutes have a shape |
| 03 | OOP fundamentals | Singleton, refused | substitutability is the pillar that breaks in running code; Singleton is a global wearing a class |
| 04 | Parking lot | Strategy, twice | fit and fee change on different schedules, so they are two strategies and not one |
| 05 | Movie ticket booking | lock / conditional update + injected clock | the hold exists so that two users lose the race deterministically |
| 06 | Unix file search | Composite | a parameter list is an implicit AND; making And/Or/Not filters too buys every other predicate |
| 07 | Vending machine | State | at three states, one class per state beats a chain of ifs — and the crossover is the answer, not the pattern |
| 08 | Elevator | Strategy + Command | dispatch policy is swappable; a hall call is an object because it carries a direction |
| 09 | Grocery store | Strategy with an explicit stage | promotions do not commute, so the ordering has to be declared rather than accidental |
| 10 | Tic-tac-toe | patterns declined | three objects and constant-time counters; Command only if redo is actually asked for |
| 11 | Blackjack | Strategy | the dealer’s rule set is the swappable part, and the hand — not the player — is the betting unit |
| 12 | Shipping locker | Strategy + injected clock | allocation is policy; expiry is a transition that a clock drives, so the clock is a dependency |
| 13 | ATM | State + a durable journal | the uncancellable region needs an intent record written before the motor turns |
| 14 | Restaurant | Observer + a computed state machine | the kitchen subscribes instead of being called, and every item has its own lifecycle |
Read down the last column and the same three moves account for almost all of it: put the varying rule behind an interface, put the lifecycle in a machine with explicit guards, and put anything you cannot undo behind a durable record. Two chapters earn their place by refusing a pattern, which is the move most candidates never make and interviewers always notice.
Where to go next:
- The distributed versions of these same problems — holds and races become consensus, journals become write-ahead logs, and the observer becomes a message queue: system design ch 16.
- The interview itself, as a performance — pacing, what to say out loud at each step, and the phrases that signal seniority: ch 10 — Design Interview Playbook.
- The framework, once more, from memory: ch 02. If you can run its six steps on a problem that has no chapter here, the track has done its job.