Design a Restaurant Management system
What we are building
A Restaurant Management System lets staff run a restaurant from one portal: track which tables are free, reserve tables for a date and time, take orders seat-by-seat, and generate a bill the customer pays by card, cheque, or cash. The interesting engineering lives in two places that this page treats as load-bearing: (1) producing a correct, itemised bill from an order, and (2) making sure two receptionists can never confirm the same table for the same slot. Everything else is plumbing around those two mechanisms.
Actors and their jobs
| Actor | Responsibility |
|---|---|
| Receptionist | Add/modify tables and layout; create and cancel reservations. |
| Waiter | Take and modify orders; add meals per seat. |
| Manager | Add employees; modify the menu. |
| Chef | View and prepare an order. |
| Cashier | Generate the check and process payment. |
| System | Send reservation reminders and cancellation notices. |
Domain model
The aggregate roots are Restaurant → Branch. A Branch owns one Menu, one Kitchen, and a set of Tables. A Menu is a list of MenuSections, each holding MenuItems (the priced, titled things a customer can buy — "Ribeye Steak", "Cola"). An Order belongs to a table and groups one Meal per occupied TableSeat; each Meal is a list of MealItems, and every MealItem points at the MenuItem it was ordered from. The Bill is derived from the order: one BillItem per meal item, carrying the item's title, quantity, and the price captured at order time.
The single most important modelling decision: a MealItem references a MenuItem but copies the price onto the BillItem at billing time. If the manager re-prices the steak tomorrow, today's settled bills must not change. The bill is a snapshot, not a live view.
Core classes
For brevity, fields are private with conventional getters/setters; only the accessors that the worked example actually calls are written out. The crucial one is MenuItem.getTitle() — the bill needs the item's name ("Ribeye Steak") to print a human-readable line, so the title must be exposed, not just the price.
public enum PaymentStatus { UNPAID, PENDING, COMPLETED, DECLINED, REFUNDED }
public class MenuItem {
private int menuItemID;
private String title;
private String description;
private double price;
// Accessors the Bill depends on. getTitle() is required so a bill line
// can show "Ribeye Steak" rather than an opaque id.
public String getTitle() { return title; }
public double getPrice() { return price; }
public boolean updatePrice(double price) {
this.price = price;
return true;
}
}
public class MealItem {
private int mealItemID;
private int quantity;
private MenuItem menuItem;
public MenuItem getMenuItem() { return menuItem; }
public int getQuantity() { return quantity; }
}
public class Meal {
private int seatNumber;
private List<MealItem> items = new ArrayList<>();
public List<MealItem> getItems() { return items; }
}Mechanism 1 — building the bill from an order
Order.generateBill() walks every meal item, asks each one for its MenuItem, and reads both the title and the price off it. The title becomes the human-readable line; the price is multiplied by quantity. Because MenuItem now exposes getTitle(), the line new BillItem(it.getMenuItem().getTitle(), ...) compiles — this is the compile bug the reviewer flagged, and it is fixed by the accessor added above.
public class BillItem {
private final String title; // "Ribeye Steak" — copied from the MenuItem
private final int quantity;
private final double unitPrice; // price snapshot taken at billing time
public BillItem(String title, int quantity, double unitPrice) {
this.title = title;
this.quantity = quantity;
this.unitPrice = unitPrice;
}
public double lineTotal() { return quantity * unitPrice; }
public String getTitle() { return title; }
}
public class Bill {
private final List<BillItem> items;
private final double taxRate; // e.g. 0.08
private final double tipRate; // e.g. 0.18
public Bill(List<BillItem> items, double taxRate, double tipRate) {
this.items = items;
this.taxRate = taxRate;
this.tipRate = tipRate;
}
public double subtotal() {
double s = 0;
for (BillItem bi : items) s += bi.lineTotal();
return s;
}
public double tax() { return subtotal() * taxRate; }
public double tip() { return subtotal() * tipRate; }
// Single source of truth: total() REUSES subtotal()/tax()/tip()
// rather than re-deriving the arithmetic, so the two can never drift.
public double total() { return subtotal() + tax() + tip(); }
}
public class Order {
private List<Meal> meals = new ArrayList<>();
public Bill generateBill(double taxRate, double tipRate) {
List<BillItem> lines = new ArrayList<>();
for (Meal meal : meals) {
for (MealItem it : meal.getItems()) {
MenuItem mi = it.getMenuItem();
lines.add(new BillItem(
mi.getTitle(), // compiles: getTitle() now exists
it.getQuantity(),
mi.getPrice())); // price snapshot, not a live reference
}
}
return new Bill(lines, taxRate, tipRate);
}
}Worked example: a two-line bill
One steak at $30.00 and one cola at $4.00, with 8% tax and an 18% tip:
| Bill line | Qty | Unit | Line total |
|---|---|---|---|
| Ribeye Steak | 1 | $30.00 | $30.00 |
| Cola | 1 | $4.00 | $4.00 |
subtotal() = 30.00 + 4.00 = $34.00. tax() = 34.00 × 0.08 = $2.72. tip() = 34.00 × 0.18 = $6.12. total() = 34.00 + 2.72 + 6.12 = $42.84. The titles "Ribeye Steak" and "Cola" come straight from MenuItem.getTitle(); the total is computed by composing the smaller methods, so if the tax rule changes you edit tax() once and total() follows.
Production caveat on the money type: the code above uses double for brevity, but real billing must use BigDecimal with an explicit rounding mode. In IEEE-754, 34 × 0.08 is actually 2.7199999999999998, not 2.72 — the pennies only look right after display rounding, and they drift once you sum many lines. A bill off by a cent is a chargeback and an audit finding, so treat double here as pseudocode: in production, every price, rate, and total is BigDecimal, rounded HALF_UP to two places at the line level.
Mechanism 2 — table-reservation concurrency
This is the part the original page only narrated. Here is the actual problem: two receptionists both run "is table 5 free for 7pm?", both see FREE, and both write a CONFIRMED reservation. A check-then-act in Java cannot prevent this — between the check and the write, the other transaction sneaks in. The fix is not a Java lock (it would not survive multiple app servers); it is a database-level uniqueness guarantee. Make the slot itself unique, and let the database reject the second writer.
Step 1 — the constraint that makes the race impossible
A unique constraint on (table_id, slot) means the database physically cannot hold two confirmed rows for the same table and time. No matter how many app servers or threads race, exactly one INSERT wins.
CREATE TABLE reservation (
reservation_id BIGSERIAL PRIMARY KEY,
table_id BIGINT NOT NULL REFERENCES "table"(table_id),
slot TIMESTAMPTZ NOT NULL, -- the reserved start time
status TEXT NOT NULL,
customer_id BIGINT NOT NULL,
-- The whole mechanism lives here: one confirmed row per (table, slot).
CONSTRAINT uq_table_slot UNIQUE (table_id, slot)
);Step 2 — insert and let the loser fail loudly
The winning transaction inserts; the losing one violates uq_table_slot and the database raises a unique-violation. ON CONFLICT DO NOTHING turns that into a row count we can test: 0 rows means "someone beat you to it." The losing receptionist gets a clean "table just got taken" message instead of a phantom double-booking.
-- Atomic claim. Either this inserts exactly one row, or it inserts zero
-- because the slot is already taken. There is no in-between.
INSERT INTO reservation (table_id, slot, status, customer_id)
VALUES (?, ?, 'CONFIRMED', ?)
ON CONFLICT ON CONSTRAINT uq_table_slot DO NOTHING;public class ReservationService {
private final DataSource ds;
public ReservationService(DataSource ds) { this.ds = ds; }
/** @return true if this caller won the slot; false if someone else holds it. */
public boolean reserve(long tableId, Instant slot, long customerId) {
String sql =
"INSERT INTO reservation (table_id, slot, status, customer_id) " +
"VALUES (?, ?, 'CONFIRMED', ?) " +
"ON CONFLICT ON CONSTRAINT uq_table_slot DO NOTHING";
try (Connection c = ds.getConnection();
PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, tableId);
ps.setObject(2, OffsetDateTime.ofInstant(slot, ZoneOffset.UTC));
ps.setLong(3, customerId);
int rows = ps.executeUpdate();
return rows == 1; // 1 = we won, 0 = slot already taken
} catch (SQLException e) {
throw new ReservationException("reserve failed", e);
}
}
}If you must read-then-write inside one transaction (for example, to also decrement a quota row), take a row lock instead of trusting a bare SELECT: SELECT ... FROM "table" WHERE table_id = ? FOR UPDATE blocks the second transaction until the first commits, so the two serialise rather than collide. The UNIQUE constraint is still your backstop — keep it even when you lock, because a lock only protects readers who agree to take it.
When-not: reservation & billing decision space
UNIQUE on (table_id, slot) is one correct concurrency tool, not the only product design. Interviewers probe whether you can name alternatives and their trade-offs.
| Strategy | How it works | Wins when | Loses when |
|---|---|---|---|
| UNIQUE slot (CONFIRMED only) | At most one confirmed row per (table, slot); loser gets conflict | No double-book; simple ops; multi-server safe | No waitlist; browsing inventory can still race unless holds exist; no soft-hold UX |
| PENDING hold + confirm (TTL) | Insert PENDING with expiry (e.g. 10 min); convert to CONFIRMED on pay; expire job frees slot | Browse-then-pay / multi-step booking; reduces checkout race | More states; clock skew; abandoned holds need a sweeper; UNIQUE must include status design (partial unique index on confirmed, or slot uniqueness only for non-expired holds) |
| Deliberate overbook | Allow N > capacity (or accept more parties than tables) based on no-show model | High no-show rate; maximize covers; revenue science | Walk-outs, reputation damage; needs walk-in buffer and staff override — not "bug-free" uniqueness |
Billing when-not:
- Do not re-read live menu prices at payment time if the guest ordered at table price — snapshot price on
BillItem(this page does). Re-reading causes tip/tax surprises when the kitchen special ends mid-meal. - Do not embed tax rules only in the client UI — server
Bill.tax()is authoritative; UI estimates must match or label themselves as estimates. - Split checks / comps / voids need explicit line operations, not a second ad-hoc total field that drifts from the line sum.
Choose UNIQUE confirmed when the product is "instant reserve or fail." Choose hold+TTL when payment or party-size confirmation is multi-step. Choose overbook only with an explicit business policy and ops playbook — never as an accidental race.
Pulling it together
The two mechanisms share one principle: derive, don't duplicate, and let the lowest reliable layer enforce the invariant. The bill is derived from the order and computed by composing subtotal()/tax()/tip() into total(), so pricing logic lives in exactly one place. The no-double-booking invariant is enforced by the database's UNIQUE constraint, not by hopeful Java checks, so it holds under any amount of concurrency and any number of app servers — when the product policy is hard exclusive booking. Get those two right and the rest of the system — menus, kitchens, notifications — is ordinary CRUD around them.
Source
Adapted and corrected from Grokking the Object Oriented Design Interview (Design Studio / DesignGurus), "Design a Restaurant Management System," plus PostgreSQL documentation on UNIQUE constraints, INSERT ... ON CONFLICT, and SELECT ... FOR UPDATE for the concurrency mechanism. Reservation decision table and billing when-not elevated for this guide.
🤖 Don't fully get this? Learn it with Claude
Stuck on Design a Restaurant Management system? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **Design a Restaurant Management system** (OO & Low-Level Design) and want to truly understand it. Explain Design a Restaurant Management system from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **Design a Restaurant Management system** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **Design a Restaurant Management system** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **Design a Restaurant Management system** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.