Guide · System design
LLD: Design a Restaurant Ordering System
22 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- A restaurant management LLD (low-level design: the classes and rules inside one service) turns tables, menus, orders and kitchen tickets, the cook's copy of each dish, into about eight classes. Each class owns its facts.
- Track each table and each ordered dish with a state machine: a fixed list of states and the only moves allowed between them. In Java you write the states as an enum, a type with a fixed set of named values.
- Give every cooking station its own queue, sorted by priority and then by an arrival number. Java's PriorityBlockingQueue promises nothing about the order of equal-priority tickets, so the arrival number keeps it first come, first served.
- Stop double taps with a request id that the tablet reuses on every retry. Stop lost updates, where one save silently wipes out another, with a version number checked on every save. Add money with BigDecimal, Java's exact decimal type, never double, which only approximates decimals.
- Beginners can stop after the bill section. Engineers then get a tested run where the first ticket is cooked last, a shared ticket table, and a paisa (one hundredth of a rupee) lost to double.
What goes wrong when a restaurant ordering app has no real design?
Orders go missing from bills, double taps cook a dish twice, and tables get their food out of turn. None of this needs heavy traffic. It comes from unclear states, unclear owners and no rules about what may happen next, which is exactly what a restaurant management LLD settles.
Picture a common setup. A family restaurant has 30 tables and three cooking stations: the tandoor, the curry counter and the drinks bar. Guests order from a tablet on each table, about 140 orders an hour on a Saturday night. The setup is made up, but the bugs below are the usual ones.
The first app was a quick prototype. Every order went into one long list, and every station's screen showed the whole list. One busy weekend produced four complaints.
- Table 12 added two butter naans after the main order. The naans reached the tandoor screen, but the bill showed only the first order.
- A guest tapped Place order twice on a slow network. The kitchen cooked two plates of paneer tikka, and only one was paid for.
- The tandoor cook picked kebab orders in whatever order the screen showed. A table that ordered first sometimes waited 15 minutes longer than the next one.
- A waiter seated a new family at table 5 while the old guests were still paying. The tablet mixed both groups' orders into one bill.
Each complaint maps to a missing rule. The bill had no single owner, the order had no guard against repeats, the kitchen had no fair line and the table had no states. The rest of this guide adds those rules one class at a time.
What is a restaurant management LLD and what should it cover?
A restaurant management LLD is the low-level design of the ordering software: its classes, their fields, their methods and the rules for how they change. It covers tables, the menu, orders, the kitchen queue and the bill.
High-level design picks the big parts, such as servers, databases and how they connect. Low-level design zooms into one service and decides what each class knows and does. A list of nouns is not enough. You must show how an order moves, and who may move it.
Start by writing the requirements, the things the system must do, in plain sentences.
- Seat guests at a free table, or hold a table for a booking.
- Take an order from the table's tablet, and let guests add more items later.
- Send each item to the station that cooks it, in a fair order.
- Let cooks mark items as cooking and ready, and let waiters mark them served.
- Produce one correct bill per table visit, with cancelled items left out.
Then write the quality rules. A tapped button must never create two orders. Two staff changing one order at once must not lose either change. Money must add up to the last paisa.
Finally, say what is out of scope, meaning left out on purpose: payments inside the app, stock buying, staff shifts and delivery orders. Saying this early keeps an interview answer focused.
Which classes do you need for a restaurant ordering system?
You need about eight classes: Restaurant, Table, MenuItem, Order, OrderItem, KitchenTicket, Station and Bill. Each class owns one kind of fact, so every change has one obvious home.
A class is a blueprint for objects in code. The Table class describes every table, and table 12 is one object made from it. Follow the arrows below from the restaurant down to the bill.
The restaurant's main classes
Drag to exploreEach box is one class and each arrow says how one class refers to another. Notice that OrderItem and KitchenTicket are separate: one is the guest's view, the other the cook's.
Diagram connections:
- Restaurant to Table (Has 30)
- Restaurant to MenuItem (Offers)
- Table to Order (Has one open)
- Order to OrderItem (Has many)
- OrderItem to MenuItem (Points to)
- OrderItem to KitchenTicket (Becomes)
- KitchenTicket to Station (Waits at)
- Order to Bill (Closes with)
- Restaurant: One branch, its tables and its menu
- Table: Number, seats, status, current order
- MenuItem: Name, price, station, sold-out flag
- Order: One table visit, in rounds
- OrderItem: Quantity, copied price, item status
- KitchenTicket: Dish, priority, arrival number
- Station: Tandoor, curry or drinks, own queue
- Bill: Lines, tax, total, split rules
Three choices in this map matter most. First, the table points only to its current order. Old orders are history, kept in the database, not inside the table object.
Second, OrderItem points to a MenuItem but copies its price at the moment of ordering. If the manager raises the paneer tikka price during dinner, guests who already ordered still pay the old price.
Third, OrderItem and KitchenTicket are separate on purpose. The OrderItem is the guest's view: what was ordered and what it costs. The KitchenTicket is the cook's view: what to cook, where, and when it arrived.
| Class | What it holds | What it may do |
|---|---|---|
| Table | Number, seats, status, current order | Seat guests, ask for the bill, become free again |
| MenuItem | Name, price, station, sold-out flag | Be marked sold out or back in stock |
| Order | Table, rounds of items, version, request ids already handled | Add a round, cancel an item, close |
| OrderItem | Menu item, quantity, copied price, status | Move to cooking, ready, served or cancelled |
| KitchenTicket | Dish, station, priority, arrival number | Wait in a queue until a cook takes it |
| Station | Name and its own ticket queue | Hand the next ticket to a cook |
| Bill | Lines from served items, tax, total | Split by guest or item, be marked paid |
How do you track a table from free to paid?
You track a table with a state machine: a fixed list of states and the only moves allowed between them. In Java, you write the states as an enum.
An enum is a type with a fixed set of named values, such as FREE or OCCUPIED. A misspelt value does not compile. Write TableStatus.OCUPIED and javac stops with cannot find symbol, so the typo never reaches a running restaurant.
Here are the table states. Look at the Cleaning box. It is the step the prototype skipped, which let a waiter seat the new family at table 5 too early.
The life of one table
Drag to exploreA table can only move along these arrows; any other move fails with a clear message. Notice there is no way from Billing back to Occupied, so a paying table cannot take a new family.
Diagram connections:
- Free to Occupied (Walk-in seated)
- Free to Reserved (Booking held)
- Reserved to Occupied (Guests arrive)
- Reserved to Free (No-show after 15 min)
- Occupied to Billing (Bill asked)
- Billing to Cleaning (Paid)
- Cleaning to Free (Staff taps Table ready)
- Free: Clean and ready for guests
- Reserved: Held for a booking
- Occupied: Guests seated, order open
- Billing: Bill asked for, no new rounds
- Cleaning: Paid, staff clearing the table
The table moves to Cleaning only when the bill is paid. It moves to Free only when a staff member taps Table ready. Trying to seat a family during Billing fails before anything is saved.
A booking that nobody claims moves from Reserved back to Free after a time limit, here 15 minutes (made up). Run it as a timed job, so no waiter has to remember.
How should an order move from the tablet to the kitchen?
An order should be saved first, then split into one ticket per item, and each ticket sent to the station that cooks it. Items then change state as cooks and waiters tap their screens.
Treat an Order and its items as one unit, called an aggregate. The term comes from domain-driven design, a way of shaping code around the business's own words. Outside code talks only to the Order, and the Order changes its own items.
Martin Fowler describes aggregates as the unit you load and save whole, and says transactions should not cross aggregate boundaries. A transaction is a group of database changes that succeed or fail together. So adding a round saves the whole Order, or nothing.
Here is what happens when table 12 taps Place order. Each box is one step, and its second line says which part talks to which.
From the tablet to the station screens
Drag to exploreThe round is saved before any station sees it. Notice that the push comes after the save, so a cook never sees a dish the database does not know about.
Diagram connections:
- Guest taps Place order to All items in stock?
- All items in stock? to Show sold-out message (No)
- All items in stock? to Save round and tickets (Yes)
- Save round and tickets to Push tickets to stations
- Push tickets to stations to Tandoor queue (Naan, kebab)
- Push tickets to stations to Curry queue (Curry, rice)
- Push tickets to stations to Drinks queue (Lassi, juice)
- Tandoor queue to Item marked ready
- Curry queue to Item marked ready
- Drinks queue to Item marked ready
- Guest taps Place order: Tablet → Order service
- All items in stock?: Order service → Menu
- Show sold-out message: Order service → Tablet
- Save round and tickets: Order service → Database
- Push tickets to stations: Order service → Station queues
- Tandoor queue: Station queue → Tandoor screen
- Curry queue: Station queue → Curry screen
- Drinks queue: Station queue → Bar screen
- Item marked ready: Cook screen → Waiter tablet
Adding items later was the bug that broke table 12's bill. The fix is the round. Each tap of Place order adds a new round to the same open Order, so round 2 is just two butter naans, and the bill reads every round.
Each OrderItem has its own status: placed, cooking, ready, served or cancelled. The code below keeps the allowed moves inside the enum itself, so no screen can skip a step.
public class ItemLife {
public static void main(String[] args) {
OrderItem naan = new OrderItem("Butter naan");
naan.moveTo(ItemStatus.COOKING);
naan.moveTo(ItemStatus.READY);
naan.moveTo(ItemStatus.SERVED);
System.out.println(naan.name() + " is " + naan.status());
try {
naan.moveTo(ItemStatus.CANCELLED);
} catch (IllegalStateException e) {
System.out.println("Refused: " + e.getMessage());
}
}
}
enum ItemStatus {
PLACED, COOKING, READY, SERVED, CANCELLED;
boolean canMoveTo(ItemStatus next) {
return switch (this) {
case PLACED -> next == COOKING || next == CANCELLED;
case COOKING -> next == READY || next == CANCELLED;
case READY -> next == SERVED || next == CANCELLED;
case SERVED, CANCELLED -> false;
};
}
}
class OrderItem {
private final String name;
private ItemStatus status = ItemStatus.PLACED;
OrderItem(String name) {
this.name = name;
}
String name() {
return name;
}
ItemStatus status() {
return status;
}
// The Order calls this inside its own lock; main calls it only for the demo
void moveTo(ItemStatus next) {
if (!status.canMoveTo(next)) {
throw new IllegalStateException(status + " cannot become " + next);
}
status = next;
}
}Save it as ItemLife.java and run java ItemLife.java. The samples in this guide need Java 16 or later, because switch expressions arrived in Java 14 and records in Java 16. We ran them on Java 21. You should see this:
Butter naan is SERVED
Refused: SERVED cannot become CANCELLEDWhat just happened? The naan moved placed, cooking, ready, served, one legal step at a time. The cancel was refused because the switch says a served item can never change. That switch is the rule book for every move.
The compiler guards the rule book too. A switch expression must cover every value. Delete the READY line and javac refuses with: the switch expression does not cover all possible input values.
OrderItem has no lock of its own. Only the Order calls moveTo, inside the Order's own lock: a rule that lets one change run at a time, which the duplicate-orders section introduces.
How do you design a fair kitchen queue?
A fair kitchen queue gives each station its own queue, sorted first by priority and then by arrival. Cooks always take the ticket at the front, so nobody picks favourites.
A queue is a line of waiting work: you add at the back and take from the front. The restaurant needs three, one per station, because the tandoor and the bar work at very different speeds.
Think of the token counter at a hospital outpatient desk. Each patient takes a numbered token. An emergency goes ahead of everyone, and among the rest, the lower token number is called first.
- Token number: the ticket's arrival number.
- Emergency: priority 0, a rushed dish such as a remade plate after a complaint.
- One desk per department: one queue per station.
Where the picture breaks: paper tokens survive a power cut, but a queue held in memory does not. The restart section below deals with that.
Java's PriorityBlockingQueue sorts items by any rule you give it, and many threads, separate lines of work running at the same time, can use it safely. Its documentation says it makes no guarantees about the ordering of elements with equal priority. So every ticket gets an arrival number as a tie-breaker.
import java.util.Comparator;
import java.util.concurrent.PriorityBlockingQueue;
import java.util.concurrent.atomic.AtomicLong;
public class KitchenQueue {
public static void main(String[] args) throws InterruptedException {
StationQueue tandoor = new StationQueue();
tandoor.add(StationQueue.NORMAL, "T12", "Paneer tikka");
tandoor.add(StationQueue.NORMAL, "T7", "Seekh kebab");
tandoor.add(StationQueue.NORMAL, "T3", "Tandoori roti");
tandoor.add(StationQueue.RUSH, "T9", "Remade naan");
for (int i = 0; i < 4; i++) {
KitchenTicket t = tandoor.nextForCook();
System.out.println(t.seq() + " " + t.orderId() + " " + t.dish());
}
}
}
record KitchenTicket(long seq, int priority, String orderId, String dish) {}
class StationQueue {
static final int RUSH = 0;
static final int NORMAL = 1;
private final AtomicLong nextSeq = new AtomicLong();
private final PriorityBlockingQueue<KitchenTicket> tickets =
new PriorityBlockingQueue<>(16, Comparator
.comparingInt(KitchenTicket::priority)
.thenComparingLong(KitchenTicket::seq));
void add(int priority, String orderId, String dish) {
tickets.put(new KitchenTicket(nextSeq.getAndIncrement(), priority, orderId, dish));
}
KitchenTicket nextForCook() throws InterruptedException {
return tickets.take(); // waits while the queue is empty
}
}A record is a short Java class that only holds fields, and a Comparator tells Java which of two items comes first. This one sorts by priority, then by arrival number. AtomicLong hands out arrival numbers safely, even when two tablets order in the same instant. The documentation's own FIFOEntry example uses the same trick.
Run java KitchenQueue.java and you should see the rushed naan first, then the three normal tickets in arrival order:
3 T9 Remade naan
0 T12 Paneer tikka
1 T7 Seekh kebab
2 T3 Tandoori rotiPause and predict: change KitchenTicket::seq in the comparator to t -> 0L, so every normal ticket ties. Which table waits longest?
3 T9 Remade naan
1 T7 Seekh kebab
2 T3 Tandoori roti
0 T12 Paneer tikkaTable 12 ordered first and is cooked last. We got this same order in 30 of 30 runs on Java 21.0.12. The queue is a heap, a tree that only keeps the smallest item on top. The PriorityQueue documentation says ties for the top are broken arbitrarily.
Arbitrary does not mean random. With three equal tickets and no tie-breaker, our run returned them as 1, 3, 2. The documentation promises nothing here, so never build on either order.
The take method waits while the queue is empty, so a cook's screen sits idle until a ticket arrives. The BlockingQueue documentation shows this as a producer-consumer example. Here, tablets produce tickets and cooks consume them.
This design uses two priority levels: 1 for normal tickets and 0 for a dish a manager rushes. More levels give staff more things to argue about.
What happens to the queue after a restart
A queue held in memory disappears when the app restarts, and AtomicLong starts counting from 0 again. So save every ticket, with its arrival number, in the same database save as its round.
On start-up, rebuild each station's queue from tickets that are still waiting, in their saved order. Then start the counter one past the largest saved number, so new tickets line up behind them. A tablet retry cannot help here, because the round was already saved and acknowledged.
Saving tickets with the round bends the one-aggregate-per-transaction advice from earlier. For one restaurant on one database, that is a fair trade: a crash can never keep a round without its tickets.
How do you stop duplicate orders and lost updates?
You stop duplicate orders with a request id that the tablet creates once per tap. You stop lost updates by letting only one change touch an order at a time. Both are small rules with large effects.
The double paneer tikka came from a retry. The tablet sent Place order, the network was slow, and the tablet sent it again. The server saw two requests and created two rounds.
Now the tablet creates a request id, a random string, when the guest taps. Every retry reuses the same id. The Order remembers ids it has handled, and a repeat gets the first answer back instead of a new round.
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class OrderDemo {
public static void main(String[] args) {
Order order = new Order(12);
int first = order.placeRound("req-7f3a", List.of("Paneer tikka", "Lassi"));
int retry = order.placeRound("req-7f3a", List.of("Paneer tikka", "Lassi"));
int second = order.placeRound("req-91c2", List.of("Butter naan", "Butter naan"));
System.out.println("rounds " + first + " " + retry + " " + second
+ ", items " + order.itemCount());
}
}
class Order {
private final int tableNo;
private final List<List<String>> rounds = new ArrayList<>();
private final Map<String, Integer> roundByRequest = new HashMap<>();
Order(int tableNo) {
this.tableNo = tableNo;
}
synchronized int placeRound(String requestId, List<String> dishes) {
Integer seen = roundByRequest.get(requestId);
if (seen != null) {
return seen; // a retry: hand back the first answer
}
rounds.add(List.copyOf(dishes));
int roundNo = rounds.size();
roundByRequest.put(requestId, roundNo);
return roundNo;
}
synchronized int itemCount() {
return rounds.stream().mapToInt(List::size).sum();
}
}Run java OrderDemo.java. The retry returns round 1 again, and only the second, new request makes round 2:
rounds 1 1 2, items 4The synchronized keyword lets only one thread at a time run placeRound on the same Order object. Here a thread handles one staff tap. Two different orders do not wait for each other.
We checked the lock. Eight threads sent the same request id 1,000 times each, 200 times over. With synchronized, every run made exactly one round. Without it, some runs made more than one: between 2 and 64 of 200 across three tries on our 2-core machine.
Lost updates across two servers
A lost update happens when two changes read the same old data and the second save wipes out the first. Picture a guest adding a lassi while a waiter cancels a raita on the same order.
Inside one app server that keeps each open Order as one shared object in memory, synchronized methods keep these changes in line. If each request loads its own copy from the database, the version check is what protects you, even on one server. Say the restaurant runs two app servers, so orders keep flowing if one fails. A Java lock cannot reach across servers either. So each saved order carries a version number.
Each save says: update this order only if its version is still 7. If another change got there first, the version is now 8, the save changes nothing, and the app reloads and tries again. This is called optimistic locking.
CREATE TABLE orders (
id bigint PRIMARY KEY,
items int NOT NULL,
version bigint NOT NULL
);
INSERT INTO orders VALUES (42, 4, 7);
-- Both servers have read: items 4, version 7
-- Server A adds a lassi and saves first
UPDATE orders SET items = 5, version = 8 WHERE id = 42 AND version = 7;
-- Server B cancels a raita, working from its old copy
UPDATE orders SET items = 3, version = 8 WHERE id = 42 AND version = 7;
SELECT items, version FROM orders WHERE id = 42;Run it in psql, the PostgreSQL command-line client; we used PostgreSQL 16.15. The second UPDATE reports UPDATE 0, which means zero rows changed:
CREATE TABLE
INSERT 0 1
UPDATE 1
UPDATE 0
items | version
-------+---------
5 | 8
(1 row)The lassi survived. Server B sees zero rows changed, reloads items 5 at version 8, cancels the raita and saves version 9. Java persistence libraries automate this with a version field, such as the Jakarta Persistence Version annotation.
The request ids work across servers for the same reason. They are saved with the Order in the same versioned save, so a retry on the other server either sees the id or loses the version check and reloads.
- Request id per tap: a retry never creates a second round.
- Synchronized methods on Order: two taps on one server run one at a time.
- Version number in the database: two servers cannot overwrite each other's changes.
- State checks in every method: a served item cannot be cancelled, whoever asks.
How do you calculate a restaurant bill without rounding errors?
You calculate the bill with BigDecimal, never with double. Add the copied prices of served items, apply tax, and round once at the end with a named rounding mode.
A double stores numbers in binary, so a value like 0.1 is only close, never exact. In Java, 0.1 + 0.2 prints 0.30000000000000004. BigDecimal stores an exact whole number plus a scale, the count of digits after the point, so Rs 829.50 is 82950 with a scale of 2.
Here is table 12's bill, worked through step by step. The 5 percent tax rate is picked for the example only; real tax rules are an accounting decision.
Add round 1
Paneer tikka at Rs 260, dal makhani at Rs 240 and two lassis at Rs 90 each. The subtotal so far is Rs 680.
Add round 2
Two butter naans at Rs 55 each come to Rs 110. The subtotal is now Rs 790.
Leave out cancelled items
The raita, Rs 70, was cancelled while still placed, so it never enters the bill. The subtotal stays Rs 790.
Add tax
At the 5 percent example rate, tax on Rs 790 is Rs 39.50. The total is Rs 829.50.
Round once, at the end
Round the total to two decimal places with RoundingMode.HALF_UP, the school rule: a half paisa or more rounds up.
The bill is built only once every item is served or cancelled, so requestBill checks that first. Then every line on the bill is a served dish.
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
public class BillDemo {
record Line(String dish, int qty, BigDecimal price) {
BigDecimal amount() {
return price.multiply(BigDecimal.valueOf(qty));
}
}
public static void main(String[] args) {
BigDecimal taxRate = new BigDecimal("0.05"); // example rate only
List<Line> served = List.of(
new Line("Paneer tikka", 1, new BigDecimal("260")),
new Line("Dal makhani", 1, new BigDecimal("240")),
new Line("Lassi", 2, new BigDecimal("90")),
new Line("Butter naan", 2, new BigDecimal("55")));
BigDecimal subtotal = served.stream()
.map(Line::amount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal total = subtotal.add(subtotal.multiply(taxRate))
.setScale(2, RoundingMode.HALF_UP);
System.out.println("subtotal " + subtotal + ", total " + total);
// Rs 209 with 10% off is 188.10; 5% tax on that is exactly 9.405
double asDouble = 209 * 0.90 * 0.05;
BigDecimal exact = new BigDecimal("209")
.multiply(new BigDecimal("0.90"))
.multiply(taxRate);
System.out.println("double " + Math.round(asDouble * 100) / 100.0
+ ", BigDecimal " + exact.setScale(2, RoundingMode.HALF_UP));
}
}Run java BillDemo.java. The first line matches the steps. The second line is the trap:
subtotal 790, total 829.50
double 9.4, BigDecimal 9.41What just happened? Rs 209 with 10 percent off is Rs 188.10, and 5 percent tax on that is exactly Rs 9.405. Half a paisa should round up to 9.41. In double, the value is stored as 9.404999..., a hair below, so Math.round sends it down to 9.40. Java prints that as 9.4.
We tried every whole-rupee price from Rs 1 to Rs 2,000 with that discount and tax. Double rounding was a paisa short on 11 of them.
Three BigDecimal traps worth knowing
- Build amounts from strings, such as new BigDecimal("0.05"). The documentation shows new BigDecimal(0.1) holds 0.1000000000000000055511151231257827021181583404541015625.
- The values 2.0 and 2.00 are not equal under equals, because their scales differ. Use compareTo when you check whether two amounts match; it returns 0 for them.
- Rounding each line can change the total. Three discounted drinks at Rs 49.50 give Rs 7.44 tax rounded per line, but Rs 7.43 rounded once.
With whole-rupee prices and a whole-number tax rate, line tax never needs rounding at all. Rounding only matters once discounts or fractional rates appear. Pick one rule, per line or once per bill, and use it everywhere.
Split bills are just a view over the same lines. The Bill can divide by guest or by item, but the Order stays one record. That way the kitchen and the bill never disagree.
Which design patterns fit a restaurant ordering system?
Three patterns fit: State for lifecycles that change behaviour, Observer for telling waiters when food is ready, and Strategy for different ways to split a bill. Use them only where they remove real code.
A design pattern is a named, well-known shape for solving a common code problem. Naming one in an interview helps only if you can point to the class where it lives.
- State: the classic pattern gives each state its own class with its own behaviour. The ItemStatus enum is a lighter state machine, enough while states differ only in which moves they allow.
- Observer: when an item becomes ready, the Order tells every listener, such as the waiter's tablet and the counter screen where plates are collected.
- Strategy: equal split, split by item and one payer are three small classes behind one BillSplitter interface.
Switch from the enum to full State classes when states start doing different work. For example, a Billing table that must print, lock the tablet and call a waiter has earned its own class.
Skip a factory for menu items. It adds a class and saves nothing, because every menu item is built the same way.
How do two app servers share one kitchen queue?
They share it by making a database table the queue. An in-memory queue lives inside one server, so with two servers each tandoor screen would see only the tickets its own server received.
Each ticket becomes a row with a station, a priority, a dish and a status. A cook's screen claims the next waiting row in one statement. Two cooks at the same station must never claim the same row, and neither should wait for the other.
CREATE TABLE kitchen_ticket (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
station text NOT NULL,
priority int NOT NULL DEFAULT 1,
dish text NOT NULL,
status text NOT NULL DEFAULT 'WAITING'
);
INSERT INTO kitchen_ticket (station, dish) VALUES
('tandoor', 'Paneer tikka'), ('tandoor', 'Seekh kebab'), ('tandoor', 'Tandoori roti');
INSERT INTO kitchen_ticket (station, priority, dish) VALUES ('tandoor', 0, 'Remade naan');
-- A cook's screen claims the next ticket
UPDATE kitchen_ticket SET status = 'COOKING'
WHERE id = (
SELECT id FROM kitchen_ticket
WHERE station = 'tandoor' AND status = 'WAITING'
ORDER BY priority, id
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING id, dish;FOR UPDATE locks the chosen row, and SKIP LOCKED tells PostgreSQL to step over rows another cook has already locked. The PostgreSQL manual says skipping gives an inconsistent view, fine for a queue-like table but not for general work.
Run it on PostgreSQL 16 and the rushed naan comes back first, just like the Java queue:
id | dish
----+-------------
4 | Remade naan
(1 row)
UPDATE 1We tested two cooks at once. Cook A held a claim open for 3 seconds. Cook B, with SKIP LOCKED, got the next ticket in 0.06 seconds. Without SKIP LOCKED, cook B sat waiting until A's transaction ended, about 2 seconds in our run: the rest of A's 3-second hold.
The arrival number in the database
Here the identity column id is the arrival number. The database hands it out at insert time, so after a restart the order is already saved and nothing needs rebuilding.
One limit: id order is the order the numbers were handed out, not the order rows became visible. In our test, a tablet that inserted first but committed 2 seconds later got id 1 while id 2 was already on screen.
This small reorder only happens when two saves overlap, which a kitchen can usually live with. Know it is there before you promise strict first come, first served.
You can also enforce request ids with the database. Put a UNIQUE constraint on order id plus request id in a rounds table. A repeat insert then fails with a duplicate key error, or quietly does nothing with ON CONFLICT DO NOTHING.
How do you explain restaurant management LLD in a system design interview?
Explain it as the story of one table visit, adding a class each time the story needs one. Then show the states, the queue and the guards.
Ask the scope questions
Who places orders, which stations exist, can guests add items later, and can bills be split? Write the answers down.
List the classes
Name Table, MenuItem, Order, OrderItem, KitchenTicket, Station and Bill, with one line on what each owns.
Draw two state machines
One for the table and one for each item. Show which moves are allowed and which method makes each move.
Design the kitchen queue
One queue per station, sorted by priority and then arrival number. Say how cooks take the next ticket and what happens on a restart.
Add the guards
Request ids against double taps, locks and version numbers against lost updates, and BigDecimal for money.
Scale to two servers
Move the queue into a ticket table claimed with FOR UPDATE SKIP LOCKED, and keep the version check on every order save.
Name the patterns last
Point to State, Observer and Strategy in your own classes. Skip any pattern you cannot point to.
Questions people ask
- What classes are needed in a restaurant management system?
- A typical answer uses Table, MenuItem, Order, OrderItem, KitchenTicket, Station and Bill, plus a Restaurant that holds them. Add Reservation if bookings matter, and Payment if the app takes money. Keep each fact in one class, such as the table's status in Table only.
- Should each kitchen station have its own queue?
- Yes, when stations cook different dishes at different speeds. One shared queue lets a slow tandoor ticket sit in front of a fast drinks ticket. Give each station its own queue sorted by priority and arrival number, and let the Order track when all its items are ready.
- How do you handle a dish that runs out during service?
- Mark the MenuItem as sold out, and check that flag when a round is placed. New orders then show a clear message on the tablet. Items already placed but not cooked are cancelled by staff, who tell the table in person and offer a swap.
- Why should you not use double for prices in Java?
- A double stores numbers in binary, so many decimal amounts are only approximate. In our test, a Rs 9.405 tax rounded to Rs 9.40 in double instead of Rs 9.41. BigDecimal stores exact decimal digits and lets you name the rounding mode.
- What is the difference between a restaurant LLD and a food delivery system design?
- A restaurant LLD designs the classes inside one service: tables, orders, queues and bills. A food delivery design is high-level: many restaurants, riders, maps, payments and scale across a city. The first is mostly an object-design question; the second is mostly a distributed-systems question.
- Do you need a database in a low-level design answer?
- You need to say where data is saved, but not design every table in detail. Mention a repository, a class that loads and saves objects, for Order and Table. Say that each saved order carries a version number, and where waiting tickets live after a restart.
- How do you stop a guest from placing the same order twice?
- Give each tap a request id created on the tablet, and reuse it on every retry. The Order keeps the ids it has already handled. When a repeat arrives, it returns the first result instead of adding a second round.
Sources and further reading
- Java SE 21 API: PriorityBlockingQueue (equal-priority ordering, FIFOEntry example)
- Java SE 21 API: BlockingQueue (take, producer-consumer example)
- Java SE 21 API: BigDecimal (unscaled value and scale, equals vs compareTo, double constructor)
- Java SE 21 API: PriorityQueue (priority heap, ties broken arbitrarily)
- Java SE 21 API: RoundingMode (HALF_UP)
- The Java Tutorials: Enum Types
- The Java Tutorials: Synchronized Methods
- JEP 361: Switch Expressions (standard in JDK 14, exhaustive cases)
- JEP 395: Records (final in JDK 16)
- Martin Fowler: DDD Aggregate
- Jakarta Persistence 3.2 API: Version annotation (optimistic lock failures)
- Wikipedia: State pattern (one class per state)
- PostgreSQL 16 manual: SELECT, the locking clause (FOR UPDATE, SKIP LOCKED)