InterviewPrepKit

Home / Learn / Object-Oriented Design

14 — Restaurant Management System

“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:

  1. Why the lifecycle of an order belongs to each individual dish rather than to the order as a whole.
  2. 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.
  3. 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:

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 decisionThe consequenceSection
State lives on the item, and the order’s state is computedone slow item cannot be hidden by an order-level flagDecision 1
The kitchen subscribes to orders rather than being called by thema bar printer and a runner app are registrations, not editsDecision 2
Splitting is an exact integer allocationthree ways to split $10.00 still sums to $10.00Decision 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:

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.

QuestionAnswered yesAnswered no
Do items within one order move independently?state lives on OrderItem and the order’s state is computed from themone 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 statea 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 kitchentiming 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 diagramOrder -> 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.

ActorUse caseWhat it touches
Hosttake a reservation, seat a party, mark a table cleanTable lifecycle
Serveropen an order, add items, fire them, mark servedOrder, OrderItem
Kitchen (line cook)see a ticket, start it, mark it readyreads events, writes item state
Runner / exposee what is ready, deliver itreads events
Cashierclose the bill, split it, take tendersBill, Tender
Managervoid an item, comp a course, read the covers reporteverything, with an audit trail

The five words in that table

All five recur throughout the chapter.

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.

ObjectWhy it is separate
MenuItem vs OrderItemthe 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 Billthe 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 Reservationa 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:

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.

Four line styles appear, and they mean four different things:

Reading the arrows back as sentences

Two arrows are worth defending and one is worth distrusting:

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 boxIn the code below
OrderItem, Order, OrderEvent, OrderObserverimplemented and exercised in Decision 2
KitchenDisplay, RunnerApp, TicketPrinterKitchenDisplay, RunnerApp, and FlakyPrinter standing in for the printer
EqualSplit, ByItem, BySharethe functions split_equal, split_by_item, split_by_share, all calling one shared allocate
Tenderimplemented in extension 2, alongside apply_tender
Restaurant, Table, Reservation, Menu, MenuItem, Billdiagram 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 variesHow the design absorbs itWhat it would cost to have got this wrong
Who wants to know that an item movedThe OrderObserver interface plus a subscriber listEvery new screen, printer or reporting store edits Order
How a bill is dividedThe SplitPolicy interface over one shared allocate functionThree near-copies of the same rounding logic, drifting apart
How far along each dish isState on OrderItem; Order.state is computed from themA single status on the order, which cannot describe a normal table
The menu, its prices and its stationsData in Menu, copied onto the order line at order timeA price change repricing checks that are already open
How many people pay, and howA list of Tenders with a sum invariantA single payment field, and split bills become a manual process

Assumed fixed — and therefore baked into the structure rather than into a parameter:

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.

  1. 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.
  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 READY before 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.

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.

FromEventToWho
PLACEDserver fires the itemFIREDserver
PLACED / FIREDserver voids the itemVOIDEDserver, with a reason
FIREDcook picks up the ticketCOOKINGkitchen
COOKINGcook plates itREADYkitchen
READYrunner delivers itSERVEDrunner

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.

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:

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.

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:

  1. Both items start PLACED, so the order does.
  2. Firing each one puts its id on its own station’s screen and on no other. That is per-station routing, demonstrated.
  3. Moving the steak to COOKING and then READY clears it from the hot screen and puts it in the runner’s list.
  4. At that moment the order’s own state is FIRED, not READY, because the gin has not moved and the least-advanced live item is what the order reports.
  5. There have been four transitions and the printer raised on all four, so undelivered holds exactly four entries while the food carried on regardless.
  6. 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 undelivered stays at four.

What Observer costs, and none of it is theoretical

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.

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:

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:

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-inTakeaway
Locationa table, held for the visita pickup shelf, held until collected
Terminal stepSERVED by a runnerCOLLECTED by the customer
Timingcourses paced against the tableeverything fires at once and goes out together
Occupies a coveryesno, 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.

ProbeThe 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 onOrderItem. Order.state is min(live item states), computed
Not a state of the orderPAID. Payment belongs to Bill; the order ends at SERVED
Order lifecyclePLACED -> FIRED -> COOKING -> READY -> SERVED, with VOIDED reachable only before the food exists
Table lifecycleFREE -> RESERVED -> SEATED -> DIRTY -> FREE; skipping DIRTY costs 7.1 covers a night
Observerkitchen displays, runner app, printer, analytics. Commit the transition, then publish; collect delivery failures, never propagate them
Observer rulean observer must not mutate the domain. If it does, it is a policy and must be named as one
Splittingallocate(total, weights) by largest remainder, ties by index. Every policy calls it
The invariantsum(parts) == total for every total and every party size, asserted exhaustively
Tax and tipallocated by each payer’s subtotal, not split evenly
Moneyinteger minor units — ch 09, money
The arrow that was a lieOrder --> "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.

ChProblemWhat carried itWhy
01What is an OOD interviewthe framethe artifact you defend is running code, not a diagram
02A framework for the OOD interviewthe methodsix steps against a clock, so 45 minutes have a shape
03OOP fundamentalsSingleton, refusedsubstitutability is the pillar that breaks in running code; Singleton is a global wearing a class
04Parking lotStrategy, twicefit and fee change on different schedules, so they are two strategies and not one
05Movie ticket bookinglock / conditional update + injected clockthe hold exists so that two users lose the race deterministically
06Unix file searchCompositea parameter list is an implicit AND; making And/Or/Not filters too buys every other predicate
07Vending machineStateat three states, one class per state beats a chain of ifs — and the crossover is the answer, not the pattern
08ElevatorStrategy + Commanddispatch policy is swappable; a hall call is an object because it carries a direction
09Grocery storeStrategy with an explicit stagepromotions do not commute, so the ordering has to be declared rather than accidental
10Tic-tac-toepatterns declinedthree objects and constant-time counters; Command only if redo is actually asked for
11BlackjackStrategythe dealer’s rule set is the swappable part, and the hand — not the player — is the betting unit
12Shipping lockerStrategy + injected clockallocation is policy; expiry is a transition that a clock drives, so the clock is a dependency
13ATMState + a durable journalthe uncancellable region needs an intent record written before the motor turns
14RestaurantObserver + a computed state machinethe 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: