Skip to content

9.7.23 — Restaurant Management

"Design a restaurant management system: reservations, seating, ordering, the kitchen, and the bill."

A cinema seat is free at 6pm and free again at 9pm, and you know that because the film has a running time. A restaurant table has no running time. Two people ordering coffee leave in forty minutes; four people with a bottle of wine stay for three hours, and nobody can tell which is which when the booking is made.

That single missing number — when does this table free up — is what makes the restaurant a different problem from every other booking problem in this chapter, and it is where the interesting design lives. Everything else on the page (orders, the kitchen, the bill) is a second problem bolted to the first, and the two barely talk to each other.

1. A table plays three different roles

The same physical table is three different things depending on when you look at it, and trying to model it with one entity is what makes candidates tangle.

① beforea slot that can be bookedReservation② duringoccupied, end time unknownSeating③ throughoutwhat is owedBillthe three do not have the same lifetime, which is why they are three entitiesA reservation exists for weeks and may never become a seating. A seating starts when people sit down and ends when they leave,and nothing knows in advance when that is. A bill can outlive the seating — the table is cleared and re-seated while theprevious party is still at the card machine. Merge any two of these and one of those three sentences becomes impossible to model.
Figure 1 — One table, three entities. The reservation is a claim on the future, the seating is the present, and the bill is the money. They start and end at different moments, so each gets its own row.
typescript
interface Table {                   // (1)
  id: TableId;
  number: string;                   // "12", what the staff say out loud
  capacity: number;                 // (2)
  minCovers: number;                // (3)
  sectionId: SectionId;             // (4)
  combinesWith: TableId[];          // (5)
}

(1) The table itself is almost pure description. It has no isOccupied field, and that absence is the design.

(2) How many people fit. A "cover" is one diner, and it is the unit the whole restaurant counts in.

(3) The minimum it is worth seating. Putting two people on a table for eight loses the restaurant a whole booking, so the seating logic needs to know the floor as well as the ceiling.

(4) Which part of the floor it is in, because a waiter is assigned to a section rather than to individual tables, and the kitchen sends food by section.

(5) Two tables of four pushed together seat eight. This list is what makes a party of eight bookable at a restaurant whose largest table is a four, and it is the single most commonly forgotten field in this problem.

2. Booking a thing whose end time nobody knows

The naive model fails immediately. A reservation cannot be "table 12 from 19:00 until people leave", because the system would never be able to say whether 21:00 is free.

What real restaurants do is assume a turn time and book against slots.

typescript
interface Reservation {
  id: ReservationId;
  partySize: number;
  startAt: Instant;
  assumedTurn: Duration;            // (1)
  tableIds: TableId[] | null;       // (2)
  state: ReservationState;
  guestId: GuestId;
  notes: string;                    // (3)
}

(1) The turn time is an assumption, not a fact, and it is a function of party size and time of day. Two people at 18:00 on a Tuesday might be given 90 minutes; six people at 20:00 on a Saturday might be given 150. Storing it on the reservation rather than deriving it later means the booking that was taken under one policy keeps its own answer, the same reason a library loan stores its due date (9.7.22).

(2) A reservation often has no table assigned at all until the day. This surprises people. The restaurant sells capacity for a time, and which table you get is decided by the person at the door, who can see that table 9 is running late and table 14 has just paid. Locking a specific table weeks in advance throws that flexibility away and makes the floor harder to run, not easier.

(3) Free text that matters more than any other field: a wheelchair, a birthday, an allergy, "regular, always table 4". A design that has no place for this is one the staff will work around with a paper notebook.

So availability is a capacity question, not a table question:

typescript
function canAccept(
  day: ServiceDay, at: Instant, party: number, turn: Duration,
): boolean {
  const overlapping = day.reservations.filter(r =>            // (1)
    r.startAt < at.plus(turn) && at < r.startAt.plus(r.assumedTurn),
  );
  const coversTaken = sum(overlapping.map(r => r.partySize)); // (2)
  const tablesTaken = countTablesNeeded(overlapping);         // (3)
  return coversTaken + party <= day.coverLimit
      && tablesTaken + tablesFor(party) <= day.tableCount;    // (4)
}

(1) Find the reservations whose time window overlaps this one. This is the same two-range overlap test the timetable clash used in 9.7.22, and it appears in almost every scheduling problem in this book.

(2) Count the people, because the kitchen's limit is people. A kitchen that can plate 60 covers an hour does not care how they are grouped.

(3) Count the tables separately, because the floor's limit is tables. Sixty covers arriving as thirty couples needs thirty tables; the same sixty as ten parties of six needs ten.

(4) Both limits must hold. This is the part candidates miss: a restaurant has two independent capacities and a booking can be refused by either one. A design with a single "seats" number cannot express "we have room for the people but not for another table."

Then the honest part: this is a forecast and it will be wrong. People stay longer than the assumed turn. Real restaurants deal with it in three ways, and naming all three is a strong answer:

Pacing. Limit how many bookings may start in any fifteen-minute window, so the kitchen and the door are never hit by twelve arrivals at once. This is a rate limit on a schedule, and it is the single most effective control the restaurant has.

A buffer between bookings. Fifteen minutes of slack on each turn absorbs the ordinary overrun without a policy discussion.

Deliberate overbooking. Some restaurants book above capacity because a known percentage never arrive. That is a business decision with a real cost — a table of four standing in the doorway with nowhere to go — and it must be a stated, tunable number rather than an accident.

3. The door: waiting lists and the quoted wait

Walk-ins are not a lesser case; in most restaurants they are the majority. The party is added to a waiting list with a quoted wait, and the quote is the whole problem.

typescript
interface WaitEntry {
  id: WaitId;
  partySize: number;
  joinedAt: Instant;
  quotedWait: Duration;             // (1)
  state: "waiting" | "notified" | "seated" | "abandoned";  // (2)
  contact: PhoneNumber | null;      // (3)
}

(1) The quote is what the guest hears at the door, and it is stored because the restaurant needs to compare it with what actually happened. A restaurant that consistently quotes twenty minutes and delivers forty is losing guests who would have waited if they had been told the truth.

(2) notified is a real state, not a detail. A party told their table is ready has a window in which to appear, exactly like the library's hold, and the table cannot be given away during it or the whole notification means nothing.

(3) Without a phone number the party must stand at the door. With one they can walk around the corner, which is the difference between a forty-minute wait they tolerate and one they abandon.

How the quote is computed, in the order of increasing honesty:

The bad version is (parties ahead of you) × (average turn), which ignores that a party of two and a party of eight are waiting for completely different tables. A couple can often be seated immediately while a party of eight waits an hour, and quoting them the same number is simply wrong.

The workable version is per party size: look at the tables that could seat this party, take the one that has been occupied longest, and estimate its remaining time from the assumed turn — then add the wait of any party ahead of them in their own size class. Two queues that barely interact.

The version that is actually right uses what the system already knows: the party's order state. A table that has not ordered dessert is not about to leave. A table whose bill has been printed is leaving in five minutes. Section 5's order data turns the estimate from a guess into an observation, and connecting those two halves of the system unprompted is a good moment in this interview.

And a rule the model must enforce: a party being notified does not lose their place if they are slow. Their entry moves to notified with an expiry; when it expires they go back to waiting at the front, not the back, and only a second miss drops them. A design that sends the party to the back of the queue for being ninety seconds late is a design that has never watched a host stand at a door.

4. The order is a list of lines, and each line has its own life

The single biggest modelling mistake here is an order with a status. An order does not have a status; each line does, because the drinks arrive while the main courses are being cooked and one steak comes back to be redone while the rest of the table eats.

typescript
interface Order {
  id: OrderId;
  seatingId: SeatingId;             // (1)
  openedAt: Instant;
  serverId: StaffId;
  lines: OrderLine[];
}

interface OrderLine {
  id: LineId;
  menuItemId: MenuItemId;
  quantity: number;
  seatNumber: number | null;        // (2)
  course: "starter" | "main" | "dessert";   // (3)
  modifiers: Modifier[];            // (4)
  unitPrice: Money;                 // (5)
  state: LineState;                 // (6)
  voidedBy: StaffId | null;         // (7)
}

type LineState =
  | { kind: "placed" }
  | { kind: "sentToKitchen"; ticketId: TicketId; at: Instant }
  | { kind: "ready"; at: Instant }
  | { kind: "served"; at: Instant }
  | { kind: "voided"; reason: VoidReason; at: Instant };

(1) The order hangs off the seating, not the table. When a table is cleared and re-seated, the new party gets a new seating and a new order, and last night's order still points at the right event.

(2) Which chair ordered it. This looks like a nicety until the food arrives and the runner has to place it without asking "who had the fish?", and until the bill is split by person in section 7.

(3) The course is what tells the kitchen when to cook it, not what it is. A table's starters go now and their mains go when the starters are cleared, and that timing decision is section 6's job.

(4) "No onions", "well done", "extra shot". Modifiers can change the price, they must reach the kitchen, and they are the most common source of a dish coming back.

(5) The price is copied onto the line at the moment of ordering. When the menu price changes at 18:00 for the evening service, a table that ordered at 17:55 pays what they were shown. Pointing a bill at a live menu price is the same class of bug as computing a library due date on read.

(6) Each line moves through its own states, which is the point made above.

(7) A voided line is never deleted. Voids are where restaurant theft happens — ring in a dish, take the cash, void the line — so a void records who did it and why, and the report of voids per server is one a manager genuinely reads. This is the same reasoning that made a retracted bid an append rather than a delete in 9.7.20.

Adding to an order is an append, not an edit. A table orders drinks, then food, then more drinks, and each round is new lines on the same order. There is no moment at which the order is "complete", which is why the bill in section 7 is computed rather than stored.

5. The kitchen: one order becomes several tickets

The kitchen does not read orders. The kitchen is a set of stations — grill, fryer, cold, pass — and each station needs only its own lines. So the order fans out.

typescript
function routeToKitchen(order: Order, menu: Menu): Ticket[] {
  const sendNow = order.lines.filter(
    l => l.state.kind === "placed" && l.course === currentCourse(order),  // (1)
  );
  const byStation = groupBy(sendNow, l => menu.stationFor(l.menuItemId));  // (2)

  return [...byStation].map(([stationId, lines]) => ({                     // (3)
    id: newTicketId(),
    stationId,
    seatingId: order.seatingId,
    tableNumber: order.tableNumber,
    lines: lines.map(toKitchenLine),
    firedAt: now(),
    dueBy: now().plus(menu.longestPrepTime(lines)),                        // (4)
  }));
}

(1) Only the lines for the course being cooked right now are sent. The desserts sit in placed until someone fires them, which is a human decision made by the server watching the table.

(2) Group by station, because a ticket is a station's work list. groupBy here is the ordinary "collect items into buckets by a key" operation.

(3) One ticket per station. A table ordering a steak, a salad and chips produces three tickets in three places, and none of the three cooks needs to read the other two.

(4) The due time is set by the longest item on the ticket, not the sum, because the station cooks in parallel. This is the number the kitchen display counts down, and it turning red is what makes someone look up.

Then the coordination problem, which is the actual difficulty of a kitchen and the thing candidates never mention. The steak takes twelve minutes, the salad takes two, and they must reach the table together. So the stations do not start at the same time: each station's start is serveAt − itsOwnPrepTime, and the whole ticket is scheduled backwards from when the food should land.

typescript
const serveAt   = firedAt.plus(longestPrep);          // (1)
const startAt   = serveAt.minus(thisStation.prepTime); // (2)

(1) The table is served when the slowest thing is ready. Nothing can be faster than that.

(2) Every other station starts late on purpose, so the salad is not wilting on the pass for ten minutes. This is scheduling backwards from a deadline, and it is exactly what an expediter at the pass does in their head.

A dish that fails changes the whole ticket. The steak is dropped on the floor at minute eleven. The correct behaviour is not to re-fire the steak alone — it is to hold the rest, tell the table, and re-time the ticket, because four people watching one person wait is worse than everyone waiting together. Modelling this means the ticket, not the line, carries the serve time, and that a re-fire moves it.

6. When the kitchen runs out

An item runs out mid-service. Every restaurant has a word for this and every system has the same race: the item is unavailable, and a server is on the floor taking an order for it right now.

Availability lives on the menu item and it is a state with a reason:

typescript
type Availability =
  | { kind: "available" }
  | { kind: "outOfStock"; since: Instant; by: StaffId }      // (1)
  | { kind: "limited"; remaining: number }                    // (2)
  | { kind: "scheduled"; times: TimeRange[] };                // (3)

(1) Marked unavailable by a person, with a time. It comes back the same way, and both events are worth recording because "we ran out of the special at 19:10 on a Friday" is information the kitchen wants next Friday.

(2) A count, for the fourteen portions of the special. This one needs the same conditional decrement as every other limited resource in this chapter, or fifteen tables are promised fourteen dishes.

(3) Some items only exist at lunch. Modelling this as data rather than as a rule in code means the kitchen changes it without a deployment.

The race is settled at the kitchen, not at the tablet. The server's screen shows the item as available because it loaded ninety seconds ago; the claim happens when the line is sent:

sql
UPDATE menu_items
   SET remaining = remaining - :qty
 WHERE id = :itemId
   AND remaining >= :qty;          -- (1)

(1) Zero rows affected means the last portion has gone, and the line is rejected before a ticket is printed. The server is told at the moment they send, which is bad, and the alternative is the guest being told twenty minutes later that their food is not coming, which is much worse. Failing at the earliest point where the truth is known is the general rule, and it is the same conditional claim used for the seat, the seat count and the wallet balance elsewhere in this chapter.

And the screens must be told. Every tablet on the floor holds a stale menu, so the availability change is pushed to them rather than polled for. This is the one place a restaurant genuinely needs a live connection, and it is worth saying which part of the system needs one rather than assuming everything does.

7. The bill is derived, and splitting is where it gets interesting

The bill is not a stored total. It is a computation over the order's non-voided lines, plus service, plus tax, and it can be produced at any moment because the lines are all there.

Splitting is the part with real design in it, and there are three genuinely different requests.

Split evenly by N. The total divided by the number of people. The only difficulty is that money does not divide evenly, and £100 across three people is not three lots of £33.33. The remainder must go somewhere explicitly — the Money type owning a splitEvenly that returns parts summing exactly to the whole, exactly as in 9.7.10.

Split by what each person had. This is why seatNumber was on the line. Group the lines by seat, and shared items — a bottle of wine, a side to share — are assigned to a group of seats and split among only those.

Split by an arbitrary amount. "Put £40 on this card and the rest on that one." This is not a split of items at all; it is a sequence of partial payments against a total, which is a different model, and conflating it with the other two is what makes bill-splitting code turn into a swamp.

typescript
interface Payment {
  id: PaymentId;
  billId: BillId;
  amount: Money;                    // (1)
  method: PaymentMethod;
  tip: Money;                       // (2)
  state: "authorised" | "captured" | "failed" | "refunded";
  idempotencyKey: string;           // (3)
}

(1) Payments are a list, and the bill is settled when they sum to the total. This single change is what makes all three splitting styles the same mechanism: even splits, per-seat splits and arbitrary amounts are all just several Payment rows.

(2) The tip is separate from the amount because it is distributed differently, taxed differently, and reported separately. Folding it into the total is a mistake that surfaces months later in payroll.

(3) A card terminal times out and the server presses pay again. Without a key sent by the terminal and enforced by a unique index, the table is charged twice, and a double charge in a restaurant is discovered by the guest rather than by the system.

One rule that must be stated: a bill cannot be settled while any line is in sentToKitchen. Food that is being cooked has not been eaten or refused, and closing the bill under it loses the ability to say whether it should be paid for.

8. What the interviewer will push on

"How do you know when a table is free?" You do not, and saying so is the answer. A reservation books an assumed turn time, stored on the booking, varying by party size and time of day. The system then defends itself with pacing (a cap on arrivals per fifteen minutes), a buffer between turns, and — if the business wants it — deliberate overbooking as a stated number with a stated cost. The wrong answer treats a table like a cinema seat with a known end time.

"Why is there no table on the reservation?" Because the restaurant sells capacity for a time, and which table you get is decided at the door by someone who can see the floor. Assigning weeks ahead removes the flexibility that makes a full restaurant workable. Then the follow-up: availability is checked against two independent limits, covers and tables, and a booking can be refused by either.

"A party of eight and your biggest table seats four." combinesWith on the table. This is the field almost everyone forgets, and it changes the availability calculation, because using two fours for a party of eight also removes two separate bookings from the floor.

"Does an order have a status?" No — each line does. Drinks arrive while mains cook, one dish comes back, dessert has not been fired. A single status on the order cannot represent any of that. Then volunteer that the price is copied onto the line at order time, so a menu change mid-service does not reprice a bill.

"The kitchen runs out of the special while a server is taking an order for it." The claim happens when the line is sent, with a conditional decrement, so zero rows affected rejects the line before a ticket exists. Tell the server at send time rather than the guest at serve time. And push the availability change to every tablet, since their menus are all stale.

"A steak and a salad must arrive together." Schedule backwards: the serve time is set by the longest prep on the ticket, and every other station starts at serveAt − itsPrepTime. Most candidates fan the order out to stations and stop there, which produces a wilted salad and a correct-looking design.

"Split the bill three ways, but one of them is paying by items." Three different requests, not one: an even split (a Money type that guarantees the parts sum to the whole), a per-seat split (which is why the line carries a seat number), and arbitrary amounts (which are partial payments, a different model entirely). Payments are a list; the bill is settled when they sum to the total.

The thing to volunteer that nobody asks for: voids are never deletions, and the void report per server is a real control. Ringing in a dish, taking the cash and voiding the line is the standard way restaurants are robbed from the inside, and a system that deletes the row makes it undetectable. Candidates model the happy path of ordering and paying; modelling the way the system will be abused by the people operating it is what shows you have thought past the diagram.

Recall

  • A table has no running time. Reservations book an assumed turn stored on the booking, varying by party size and time of day.
  • Defend the forecast three ways: pacing arrivals per window, a buffer per turn, and overbooking only as a stated, tunable number.
  • Reservations often carry no table. The restaurant sells capacity for a time; the table is chosen at the door.
  • Availability has two independent limits: covers (the kitchen) and tables (the floor). Either can refuse a booking.
  • combinesWith on the table is what makes a party of eight bookable in a restaurant of fours.
  • A notified waiting party has a window; missing it puts them back at the front, not the back.
  • The order has no status — each line does, with its own state through placed, sent, ready, served, voided.
  • Unit price is copied onto the line at order time, so a menu change mid-service does not reprice a bill.
  • Voids are recorded, never deleted, with who and why. The void-per-server report is a genuine theft control.
  • The order fans out into one ticket per station, and the ticket's due time is the longest prep, not the sum.
  • Stations start at serveAt − ownPrepTime, scheduling backwards so everything lands together.
  • Running out is claimed at send time with a conditional decrement, and the change is pushed to stale tablets.
  • The bill is derived from non-voided lines. Payments are a list, which makes even splits, per-seat splits and arbitrary amounts one mechanism.
  • A bill cannot settle while a line is still in the kitchen.

Self-test: What number does a restaurant not have that a cinema does? Why is the table missing from the reservation? Which two limits gate a booking? Why does the salad start late? Where is the out-of-stock race settled? Why are payments a list?

Quiz Bank

FoundationalModel reservations for a restaurant. Explain why the model cannot look like the cinema-seat model from 9.7.9.

The difference in one sentence: a film has a running time and a dinner does not.

A cinema knows that the 18:00 showing ends at 20:10, so seat H12 is a known, bounded thing that can be sold twice tonight with no uncertainty at all. A restaurant table is occupied from when people sit down until they decide to leave, and nobody — not the guest, not the staff — knows that number when the booking is taken three weeks earlier.

So the model has to store an assumption and be honest that it is one.

typescript
interface Reservation {
  id: ReservationId;
  partySize: number;
  startAt: Instant;
  assumedTurn: Duration;      // the assumption, stored
  tableIds: TableId[] | null; // usually null until the day
  state: ReservationState;
  notes: string;
}

assumedTurn is a function of party size and time of day — two people early in the week turn faster than six people on a Saturday night. It is stored on the reservation rather than looked up later, so a booking taken under one policy keeps its own answer when the policy changes. That is the same reason a library loan stores its due date rather than recomputing it.

The second surprise is tableIds being null. The restaurant is selling capacity at a time, not a specific table. Which table a party gets is decided on the day by the person at the door, who can see that table 9 has just ordered dessert and table 14 is paying. Assigning table 12 three weeks ahead removes exactly the flexibility that makes a busy service workable, and it means a single late table cascades into a booking that cannot be honoured even though the floor has room.

Availability is therefore a capacity question with two separate limits.

typescript
const coversTaken = sum(overlapping.map(r => r.partySize));
const tablesTaken = countTablesNeeded(overlapping);
return coversTaken + party <= day.coverLimit
    && tablesTaken + tablesFor(party) <= day.tableCount;

The covers limit is the kitchen's: it can plate so many dishes an hour regardless of how the diners are grouped. The tables limit is the floor's: sixty people as thirty couples needs thirty tables, and as ten parties of six needs ten. A design with one number cannot express "we have room for the people but not for another table", which is a refusal that happens every Saturday.

And because the whole thing rests on a guess, the design needs defences.

Pacing caps how many bookings may start in any fifteen-minute window, so twelve parties never arrive at once. This is the strongest control the restaurant has, and it protects the kitchen as much as the door.

A buffer of fifteen minutes on each turn absorbs the ordinary overrun without anyone having to make a decision.

Overbooking is legitimate when a known share of bookings never arrive, but it must be an explicit, tunable number with an acknowledged cost — a party of four standing in the doorway with nowhere to sit — rather than something that emerges by accident from an optimistic turn time.

AppliedA table orders two steaks, a salad and a bottle of wine. Walk what the system does from the tablet to the food landing on the table.

First, the order is appended to, not created complete. The wine goes in as one line, the food as three more. There is no moment where the order is finished, which is why the bill is computed later rather than stored.

typescript
interface OrderLine {
  id: LineId; menuItemId: MenuItemId; quantity: number;
  seatNumber: number | null;
  course: "starter" | "main" | "dessert";
  modifiers: Modifier[];
  unitPrice: Money;          // copied now, not looked up later
  state: LineState;
}

unitPrice is copied at this moment. If the menu switches to evening prices at 18:00 and this order was taken at 17:55, the table pays what they were shown. A bill that reads a live price is the same bug as a due date computed on read.

Second, availability is claimed at send, not at display. The tablet's menu was loaded ninety seconds ago, so it can be wrong. When the line is sent:

sql
UPDATE menu_items SET remaining = remaining - :qty
 WHERE id = :itemId AND remaining >= :qty;

Zero rows affected means the last portion has gone. The line is rejected before a ticket is printed, so the server learns at the tablet instead of the guest learning twenty minutes later that their food is not coming. Failing at the earliest point where the truth is known is the general principle.

Third, the order fans out into tickets by station. The kitchen does not read orders; the grill reads grill work.

  • Grill ticket: two steaks.
  • Cold ticket: one salad.
  • The wine does not go to the kitchen at all — it goes to the bar, which is another station with a much shorter prep time and no dependency on the food.

Each ticket carries the table number and the seating identifier, so the runner knows where the food goes and the bill knows what it belongs to.

Fourth — and this is the part most designs miss — the stations do not start together. The steaks take twelve minutes, the salad takes two, and they must land at the same moment.

typescript
const serveAt = firedAt.plus(longestPrep);           // 12 minutes from now
const startAt = serveAt.minus(thisStation.prepTime); // cold starts at minute 10

The ticket's serve time is set by the longest item, not the sum, because the stations cook in parallel. Every other station starts late on purpose. Without this, the salad sits on the pass wilting for ten minutes and the guest gets a warm salad with their steak, which is a design failure that tastes like a kitchen failure.

Fifth, each line moves through its own states. The wine reaches served in three minutes while the steaks are still sentToKitchen. This is exactly why the status is on the line and not on the order — no single status could describe a table that has its drinks, is waiting for mains, and has not ordered dessert.

Now the failure that must be designed for. At minute eleven a steak is dropped. The wrong response is to re-fire that one steak, because then three people eat while one watches. The right response is to hold the ticket, tell the table, and re-time it — which only works if the ticket owns the serve time rather than each line owning its own. That is a modelling consequence of a service decision, and it is worth pointing at as one.

InterviewIt is Saturday, twenty parties are waiting at the door, and the floor is full. What does the system tell each of them, and what does it do when a table finally frees?

The quote is the whole problem, and there is a wrong way that looks reasonable.

The wrong way: parties ahead of you multiplied by the average turn. It treats every waiting party as equivalent, which they are not. A couple can often be seated in ten minutes because small tables turn constantly; a party of eight may wait an hour and a half because it needs two fours to free at the same time. Quoting both of them "forty minutes" is wrong in both directions and the second party will leave angry either way.

The workable way: estimate per party size. Look only at the tables that could seat this party, take the one that has been occupied longest, estimate its remaining time from the assumed turn, and add the wait of parties ahead of them in their own size class. Effectively there are several queues that barely interact, and treating them as one is what makes the quote useless.

The right way uses what the system already knows. Every table has an open order, and the order says exactly where the meal has got to. A table that has not ordered dessert is not about to leave. A table whose bill has been printed is leaving within five minutes. Feeding order state into the wait estimate turns a statistical guess into an observation, and it costs nothing because the data is already there. Connecting the ordering system to the door system is the strongest thing to volunteer here.

Storing the quote matters as much as computing it.

typescript
interface WaitEntry {
  id: WaitId; partySize: number; joinedAt: Instant;
  quotedWait: Duration;
  state: "waiting" | "notified" | "seated" | "abandoned";
  contact: PhoneNumber | null;
}

Keeping quotedWait alongside joinedAt and the eventual seating time lets the restaurant compare what it promised with what it delivered. A place that reliably quotes twenty and delivers forty loses parties who would happily have waited forty if told so at the door. That comparison is the only way to find out.

contact changes guest behaviour more than any algorithm. A party that must stand in a doorway abandons at twenty-five minutes. A party that can walk around the corner and be called waits an hour. This is a one-field design decision with a larger effect than the estimate itself.

Now the table frees. Four things happen in order.

The seating is closed and the table enters a cleaning state. It is not available yet, and a design that skips this will seat a party at a dirty table. Cleaning is a real, short, tracked state.

The best waiting party is chosen — and "best" is not simply "first". The rule is the first party in the queue that this table can actually seat, respecting both the capacity and the table's minimum. Seating two people at a table for eight burns a whole booking, so a party of two is skipped for that table even if they have waited longest. This must be a stated rule, because a host doing it by instinct looks like favouritism when a system does it silently.

The party is notified and their entry moves to notified with an expiry. During that window the table is held for them and cannot be given away, exactly like the library's hold in 9.7.22. Without the hold, the notification promises nothing.

If the window expires, they go back to the front of the queue, not the back. A party ninety seconds late from the bar next door has not forfeited their forty-minute wait. Only a second miss drops them, and even then the entry becomes abandoned with a record rather than vanishing.

The interaction with reservations is the part that must be explicit. A freed table cannot always go to the waiting list, because it may be needed for a booking in twenty minutes and the assumed turn is ninety. So the seating decision checks upcoming reservations first, and a table that is "free" may be deliberately left empty. A system that hands every free table to the waiting list will make the restaurant miss its bookings, and staff will stop using it.

StaffThe restaurant becomes a chain of forty branches with a central booking site and a delivery channel. What in the design survives, what breaks, and what is genuinely new?

Start with what survives, because it is most of it. A table, a seating, an order of lines, tickets by station, a derived bill and a list of payments are all per-branch concepts and none of them change. This is worth saying first: a good single-restaurant model scales to a chain by being repeated, and the work is in what sits around it rather than in reshaping it.

What breaks is anything that assumed one clock, one menu and one floor.

The menu is no longer one thing. Prices differ by city, items differ by kitchen, and availability is per branch. So a menu item becomes a catalogue entry plus a per-branch overlay carrying price and availability — the same catalogue-versus-instance split that separated Book from BookCopy in 9.7.22 and Course from Section. Getting this wrong means a price change in one city changes it everywhere, which is the bug the chain will report first.

Time zones stop being ignorable. A booking is made in one place for a restaurant somewhere else. Store instants, render in the branch's local time, and be explicit that "Saturday service" is a property of the branch, not of the server running the code.

Availability search now spans branches. "A table for four at 20:00 near me" queries forty branches, and the naive answer runs forty availability calculations per search. The fix is a precomputed per-branch, per-slot availability summary that the booking write updates, so the search reads one small table. It can be slightly stale, because the booking write itself is the authority and will reject an over-capacity booking regardless of what the search showed — the same relationship between a fast approximate read and an authoritative write used throughout this chapter.

What is genuinely new is the delivery channel, and it is a different problem wearing the same words.

A delivery order has no table, no seating and no server. Attaching it to the seating model produces fake tables, which every system that tried this has regretted. The clean move is to notice that Order already only needs a thing to belong to, so introduce a fulfilment target: dine-in points at a seating, delivery points at an address and a courier, collection points at a pickup time. One order model, three fulfilments.

The kitchen is now serving two masters with different urgency rules. A dine-in ticket is timed so a table's food lands together. A delivery ticket is timed so the food is ready when a courier arrives, which is a completely different deadline arriving from outside the building — and the courier's arrival time is a moving estimate, exactly the two-timelines problem worked in 9.7.21. The kitchen display therefore needs one merged queue ordered by when the food must be ready, not two separate screens, or the staff make the prioritisation decision by guessing.

Capacity now has a third limit. The kitchen's covers-per-hour is shared between the dining room and delivery. A branch that accepts unlimited delivery orders while full destroys its dine-in service, and the guests who are physically present are the ones who notice. So delivery acceptance is gated by the same covers limit, and the business has to decide the split — which is a policy number, stored per branch, adjustable during service.

Two chain-wide things that must be designed rather than assumed.

A branch must keep working when the centre is unreachable. A network problem at head office must not stop a full restaurant taking orders and printing tickets. That means the in-branch system is authoritative for the service in progress, and it syncs upward — bookings arrive from the centre, everything else flows out. Designing this the other way round produces a chain that stops trading during a network incident.

Reporting is a different system with different needs. Sales by item, void rates by server, covers by hour and turn times by day are read across all branches and all time, and running those queries against the tables that are also taking orders on a Saturday night is how a restaurant loses a service. Orders, lines, payments and voids flow into a separate store for analysis; the operational database keeps only what the service needs.

What I would monitor across the chain. Quoted wait against actual wait per branch, since a branch that lies at the door loses guests silently; the gap between assumed turn and real turn by party size, because that number is the input every booking decision rests on; ticket time from fire to served against the promised time, per station, which finds the station that is quietly the bottleneck; and voids per server as an absolute rate, which is the one metric here whose purpose is to catch a person rather than a system.

Flashcards

FlashThe number a restaurant does not have

When the table frees. A film has a running time; a dinner does not. So reservations book an assumed turn, stored on the booking and varying by party size and time of day, and the design defends the guess with pacing, buffers and explicit overbooking.

FlashWhy the reservation has no table

The restaurant sells capacity for a time, and the table is chosen at the door by someone who can see the floor. Availability is checked against two independent limits — covers for the kitchen, tables for the floor — and either can refuse.

FlashStatus belongs on the line

Drinks arrive while mains cook and one dish comes back. No single order status can describe that. Each line carries its own state, and its unit price is copied at order time so a mid-service menu change cannot reprice the bill.

FlashScheduling backwards

The ticket's serve time is the longest prep on it, not the sum, because stations cook in parallel. Every other station starts at serveAt − ownPrepTime, so the salad is not wilting while the steak finishes.

FlashWhere the out-of-stock race is settled

At send, with a conditional decrement — zero rows affected rejects the line before a ticket exists. The server hears it at the tablet instead of the guest hearing it twenty minutes later. And the change is pushed to every stale tablet on the floor.

FlashVoids are never deletions

Ring in a dish, take the cash, void the line: that is how restaurants are robbed from the inside. A void records who and why, and voids-per-server is a report a manager actually reads.

Next: 9.7.24 — the shopping cart, where the hard part is that nothing in the cart is yours until you pay.