Restaurant Management LLD: Four State Machines Holding Hands
A low-level design walkthrough of a restaurant management system: seating by table fit, order tickets through a kitchen queue, and billing that frees the table — coordinated state machines.
"Design a restaurant management system." After the games and the parsers, this is the queue's first business domain — and business domains have a different failure mode. Nobody gets the algorithm wrong (there isn't one); they get the coordination wrong. A table that's never freed after billing. A waiter serving a dish the kitchen never cooked. An order accepted for a table nobody's sitting at.
Every one of those bugs is a state machine ignored. A restaurant is four small lifecycles — party, table, ticket, bill — that must hold hands correctly, and the design's whole job is making the illegal handshakes impossible.
Let's start nowhere near a computer
Stand at the host's podium on a Friday night and watch the information flow. The host scans for a table that fits — a couple doesn't get the eight-seater. The waiter writes a ticket and clips it to the kitchen rail; chefs pull tickets in order, oldest first. A bell rings — that ticket's food is ready, not any other's. And the moment a bill is paid, the host's mental map flips that table back to "free" — that flip is the restaurant's heartbeat.
Notice nobody "manages the restaurant." Five people each run one tiny state machine, and the handoffs — podium to waiter, rail to chef, bell to runner, bill to podium — are where the system lives.
You've already built these pieces
- The fitting table is the parking lot's smallest-fitting-slot search, re-skinned.
- The kitchen rail is a FIFO queue — the producer-consumer shape with chefs as consumers.
- The ticket lifecycle is the vending machine's state discipline: a step skipped is an exception thrown.
- OrderItem — order × menu-item × quantity — is the HLD guide's hidden noun, back again.
Step 1 — Functional requirements (sentences first)
What the system must do, as plain sentences — the functional requirements.
- A party of N is seated at the smallest free table that fits them.
- A seated table can place orders: menu items with quantities.
- The kitchen cooks tickets in the order they arrive: placed → in kitchen → ready → served.
- The bill totals everything the table ordered, and paying it frees the table.
- No orders for unseated tables; no serving uncooked food.
That last sentence is the whole stance in miniature. A restaurant has no clever algorithm to get wrong — what it has is ordering rules, and the design's only real job is to make the out-of-order handshakes impossible: an order before a seat, a serve before a cook, a second party at an occupied table. Refusing the illegal transition is the feature, not an error path bolted on afterward.
Step 2 — Non-functional requirements
At class level the non-functional requirements are different words for the same idea — how well, not just what — and for a restaurant the coordination they demand is the design:
- Correctness. A table seats exactly one party at a time; a bill reconciles to the items actually ordered — no double-seating, no phantom or dropped charges.
- Thread-safety. The host, the waiters, and the chefs all touch shared state at once; concurrent seating or order updates must never race two parties onto one table.
- Performance. Table availability and order lookup are direct work, not full scans of the floor — finding a seat or a ticket shouldn't get slower as the night fills up.
- Extensibility. New menu items, new order states, new pricing and discount rules plug in without rewriting the conductor.
Listing them is the easy half; the design only earns them if it fulfills them. Here's the contract — each requirement and the mechanism that keeps it:
| Requirement | How this design fulfills it |
|---|---|
| Correctness | the table's free-flag and the ticket's guarded transitions make illegal states unrepresentable — Steps 3, 4 |
| Thread-safety | every mutating verb on the conductor is synchronized, so seat/order/bill never interleave — Steps 5, 6 |
| Performance | a table is found by a single fitting search; a ticket is pulled off the head of a FIFO queue — Step 5 |
| Extensibility | the menu is data (a record), the ticket lifecycle is an enum, and pricing lives in one method — Steps 3, 4 |
Every trade-off below is chosen to keep one of these.
Step 3 — Nouns, and where each verb lives
The cast: Table (id, capacity, free?), MenuItem (a record), Order (table, lines, status), and the Restaurant as conductor. Each verb goes to whoever has enough information (the parking lot test): seatParty needs all tables → conductor. nextTicket needs the queue → conductor. Status transitions guard themselves → the Order.
Which structure — and why. Each noun's shape is chosen against an NFR, not picked by habit. The table is just a free boolean beside its capacity — the smallest representation that makes "one party at a time" a single flip, which is correctness carried in one bit. The order is a stateful aggregate — a tiny state machine (PLACED → IN_KITCHEN → READY → SERVED → BILLED) that guards its own transitions, so "serve uncooked food" can't be expressed; that's correctness again, owned by the noun that has the facts. The kitchen is a work queue — the same producer-consumer rail as the thread pool, waiters producing tickets and chefs consuming the oldest first, which buys both FIFO fairness and the performance of a head-of-queue pull instead of a scan. And money is integer minor units (paise, not floats) so totals reconcile exactly — floating-point rupees lose a paisa and break correctness at the till.
Step 4 — The two machines that matter
The table's machine is two states and the flip on payment — miss it and your restaurant fills up forever. The ticket's machine is the kitchen's contract:
void markInKitchen() {
requireStatus(Status.PLACED);
status = Status.IN_KITCHEN;
}
public void markReady() {
requireStatus(Status.IN_KITCHEN);
status = Status.READY;
}
public void markServed() {
requireStatus(Status.READY);
status = Status.SERVED;
}
private void requireStatus(Status expected) {
if (status != expected) {
throw new IllegalStateException("ticket is " + status + ", expected " + expected);
}
}Three tiny methods, and "serve a dish the kitchen never cooked" became a stack trace at the moment of the mistake instead of a refund at the table.
Step 5 — Seating and the kitchen rail
/** Smallest free table that fits — the parking lot's search, re-skinned. */
public synchronized Optional<Integer> seatParty(int partySize) {
return tables.stream()
.filter(t -> t.free && t.capacity >= partySize)
.min(Comparator.comparingInt(t -> t.capacity))
.map(t -> {
t.free = false;
return t.id;
});
}
/** The chef pulls the OLDEST ticket — fairness is FIFO, not vibes. */
public synchronized Optional<Order> nextTicket() {
Order ticket = kitchenQueue.poll();
if (ticket != null) {
ticket.markInKitchen();
}
return Optional.ofNullable(ticket);
}Why smallest fitting table? Seat the couple at the eight-seater and the next party of eight waits an hour. Greedy-by-fit is the one-line answer; mentioning its limits (real hosts hold big tables back on Fridays — a reservation policy, i.e. a Strategy seam) is the senior garnish.
Step 6 — The bill closes the loop
public synchronized int settleBill(int tableId) {
Table table = tableOrThrow(tableId);
if (table.free) {
throw new IllegalStateException("nobody is seated at table " + tableId);
}
int total = orders.stream()
.filter(o -> o.tableId() == tableId)
.mapToInt(Order::total)
.sum();
orders.removeIf(o -> o.tableId() == tableId);
table.free = true; // ← the heartbeat
return total;
}The flip back to free is one boolean — and it's the line this whole question secretly grades. Forget it, and every test passes while the restaurant slowly deadlocks.
Step 7 — Trade-offs (each one keeping an NFR)
The last column is the discipline: every choice keeps one of the promises from Step 2 — that's what designing to the non-functional requirements looks like.
| Decision | The tempting alternative | Why ours wins | Keeps |
|---|---|---|---|
free boolean + smallest-fit search | any free table, first found | a couple doesn't take the eight-seater, and one party owns one table at a time | correctness |
| guarded ticket transitions (enum) | a mutable string status field | "serve before cook" becomes a stack trace at the mistake, not a refund later | correctness |
synchronized on every mutating verb | unguarded shared lists | two hosts can't seat two parties onto the same table in the same instant | thread-safety |
| FIFO kitchen queue, pull the head | scan all orders for the oldest | the chef's next ticket is poll(), not a sweep that slows as orders pile up | performance |
| price snapshotted as integer paise | recompute from the live menu, float | the bill reconciles to what was ordered, exactly, even if the menu changes | correctness |
Growth — when one floor isn't enough. The first knobs are the seating policy (smallest-fit today, hold-big-tables-back on Fridays tomorrow) and the number of chefs draining the rail. The edges you name before you're asked are the ones that fail silently: a table already occupied (the free-flag check refuses the second seating), modifying a sent order (the ticket is past PLACED, so the transition guard throws), a reservation no-show (the block expires and the table flips back to free), and an empty order (zero lines totals to zero — reject it at placement rather than queue a ticket with nothing to cook). None of these need a cleverer algorithm; they need the illegal state to be unrepresentable.
The complete implementation
package dev.fiveyear.restaurant;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.Queue;
public final class Restaurant {
public record MenuItem(String name, int price) {}
public record OrderLine(MenuItem item, int qty) {}
public static final class Table {
final int id;
final int capacity;
boolean free = true;
Table(int id, int capacity) {
this.id = id;
this.capacity = capacity;
}
}
public static final class Order {
public enum Status { PLACED, IN_KITCHEN, READY, SERVED, BILLED }
private final int tableId;
private final List<OrderLine> lines;
private Status status = Status.PLACED;
Order(int tableId, List<OrderLine> lines) {
this.tableId = tableId;
this.lines = List.copyOf(lines);
}
void markInKitchen() { requireStatus(Status.PLACED); status = Status.IN_KITCHEN; }
public void markReady() { requireStatus(Status.IN_KITCHEN); status = Status.READY; }
public void markServed() { requireStatus(Status.READY); status = Status.SERVED; }
private void requireStatus(Status expected) {
if (status != expected) {
throw new IllegalStateException("ticket is " + status + ", expected " + expected);
}
}
public int total() {
return lines.stream().mapToInt(l -> l.item().price() * l.qty()).sum();
}
public int tableId() { return tableId; }
public Status status() { return status; }
}
private final List<Table> tables = new ArrayList<>();
private final List<Order> orders = new ArrayList<>();
private final Queue<Order> kitchenQueue = new ArrayDeque<>();
public void addTable(int id, int capacity) {
tables.add(new Table(id, capacity));
}
public synchronized Optional<Integer> seatParty(int partySize) {
return tables.stream()
.filter(t -> t.free && t.capacity >= partySize)
.min(Comparator.comparingInt(t -> t.capacity))
.map(t -> {
t.free = false;
return t.id;
});
}
public synchronized Order placeOrder(int tableId, List<OrderLine> lines) {
Table table = tableOrThrow(tableId);
if (table.free) {
throw new IllegalStateException("nobody is seated at table " + tableId);
}
Order order = new Order(tableId, lines);
orders.add(order);
kitchenQueue.add(order);
return order;
}
public synchronized Optional<Order> nextTicket() {
Order ticket = kitchenQueue.poll();
if (ticket != null) {
ticket.markInKitchen();
}
return Optional.ofNullable(ticket);
}
public synchronized int settleBill(int tableId) {
Table table = tableOrThrow(tableId);
if (table.free) {
throw new IllegalStateException("nobody is seated at table " + tableId);
}
int total = orders.stream()
.filter(o -> o.tableId() == tableId)
.mapToInt(Order::total)
.sum();
orders.removeIf(o -> o.tableId() == tableId);
table.free = true;
return total;
}
private Table tableOrThrow(int tableId) {
return tables.stream()
.filter(t -> t.id == tableId)
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("no table " + tableId));
}
}Friday night, condensed:
Restaurant r = new Restaurant();
r.addTable(1, 2);
r.addTable(2, 4);
r.addTable(3, 8);
int table = r.seatParty(2).orElseThrow(); // table 1 — smallest that fits
MenuItem dosa = new MenuItem("dosa", 120);
MenuItem chai = new MenuItem("chai", 30);
Order order = r.placeOrder(table, List.of(new OrderLine(dosa, 2), new OrderLine(chai, 2)));
Order ticket = r.nextTicket().orElseThrow(); // chef pulls the OLDEST ticket
ticket.markReady();
ticket.markServed();
int bill = r.settleBill(table); // 300 — and table 1 flips back to free
r.seatParty(2); // the next couple gets table 1The interview corner
Clarify before you code: Walk-ins only, or reservations? One kitchen, or stations (bar, grill, dessert)? Can the menu change mid-service?
The follow-up ladder:
- "Add reservations." Tables blocked for time windows — the hotel's interval formula on hours instead of nights, with the same half-open discipline.
- "Drinks come from the bar." Route order lines by station: a queue per station, and the ticket splits into station tickets — a new hidden noun, found the usual way.
- "Split the bill." The bill becomes its own object holding payment lines against the table's total, closing only at zero balance — money rules from the ATM, table-side.
- "Full house — add a waitlist." A queue of waiting parties consulted whenever a table frees; notify via Observer, seat by the same smallest-fit rule.
- "Prices changed during dinner." They can't — for seated orders: snapshot the price into the OrderLine at placement, exactly the HLD guide's
price_at_order, in miniature.
Mistakes that fail the round: forgetting the table-free flip on payment; accepting orders for unseated tables; recomputing bills from the live menu.
Where to go from here
Pocket version: four lifecycles, verbs on their owners, a FIFO rail between waiter and chef, transitions that throw — and the one boolean flip on payment that keeps the restaurant alive.
- Add reservations — tables blocked for future time windows; you'll meet the hotel's interval problem a day early.
- Add multiple chefs — the rail becomes a real
BlockingQueueand the producer-consumer code drops in unchanged. - Next in the queue: inventory management, where the question stops being "what state is it in?" and becomes "how many can I still promise?"
Friday night, decoded: nobody runs the restaurant. Four little machines do — and the design's only job was to make sure none of them could let go of the others' hands.