Engineering Atlas › Low-Level Design

Code a restaurant ordering system

14 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional:

Non-functional:

Out of scope: inventory deduction per recipe (see Design Inventory Management System), online ordering and delivery dispatch (see Design Food Delivery Service), payroll and loyalty points.

Entities and relationships

Relationships: a check is composed of its lines (a line never exists without a check). A line references a menu item version and, once fired, sits in exactly one station queue. A table has at most one open check. Payments belong to a check.

The modelling decision that pays for itself is line, not check, as the unit of kitchen work. A check with a steak and a beer goes to two stations and finishes at two times. If the kitchen tracked whole checks, the bar would wait for the grill.

Class diagram

classDiagram
    class Restaurant {
      -checks: Map~CheckId, Check~
      -stations: Map~Name, Station~
      -tables: Map~TableId, Table~
      +openCheck(id, table, guests)
      +add(checkId, item, qty) LineId
      +fire(checkId) int
      +start(station) LineId
      +done(lineId)
      +void(lineId)
      +close(checkId, taxBp, ways) Money[]
    }
    class Check {
      +id: CheckId
      +table: TableId
      +guests: int
      +status: CheckStatus
      -lines: OrderLine[]
    }
    class OrderLine {
      +id: LineId
      +item: MenuItem
      +qty: int
      +seat: int
      +status: LineStatus
    }
    class MenuItem {
      +id: string
      +price: Money
      +station: Name
      +version: int
    }
    class Station {
      +name: Name
      -queue: LineId[]
    }
    class KitchenDisplay {
      <<interface>>
      +lineFired(station, line)
    }
    class TaxPolicy {
      <<interface>>
      +tax(subtotal) Money
    }
    Restaurant "1" *-- "many" Check
    Check "1" *-- "many" OrderLine
    OrderLine --> MenuItem
    Restaurant "1" *-- "many" Station
    Station o-- OrderLine
    Restaurant --> KitchenDisplay
    Restaurant --> TaxPolicy

Key flows

Firing is the moment the order becomes the kitchen's problem. Everything before it is free to change; everything after it costs food.

sequenceDiagram
    participant S as ServerTablet
    participant R as Restaurant
    participant G as GrillStation
    participant B as BarStation
    participant K as GrillScreen
    S->>R: add(c1, burger, 2) and add(c1, soda, 1)
    R-->>S: L1, L2 pending
    S->>R: fire(c1)
    R->>R: lock check c1, mark L1 and L2 fired
    R->>G: enqueue L1
    R->>B: enqueue L2
    R-->>S: 2 lines fired
    R->>K: lineFired(grill, L1)
    K->>R: start(grill)
    R-->>K: L1 cooking
    K->>R: done(L1)
    S->>R: close(c1, 825 bp, 3 ways)
    R->>R: all lines done, subtotal 2700, tax 223
    R-->>S: [975, 974, 974]

Design patterns used and why

What not to do: a class per dish. Dishes differ in data (price, station, modifiers), not behaviour.

Concurrency and edge cases

Down the stack

Layer 1The contract

add returns a new line id and the line is pending; it fails on a closed or unknown check, an unknown item or a quantity below one. fire(check) moves every pending line of that check to fired and appends it to its station queue in line order, returning how many moved; calling it again with nothing pending returns zero and changes nothing. start(station) returns the oldest fired line at that station and marks it cooking, or nothing. close(check, taxBp, ways) succeeds only when every non-void line is done, returns ways amounts that differ by at most one cent and sum to subtotal + round_half_up(subtotal * taxBp / 10000), and frees the table.

Layer 2The mechanism

Each station queue is a FIFO of line ids (a deque in Python, a slice in Go). Voiding a fired line removes it from its queue, an O(n) scan that is fine because a station rarely holds more than a few dozen lines; a linked hash set makes it O(1) if you need it. Lines live in a hash map by id so screens can address them directly. Real restaurant systems run a local server in the back office and keep tablets, screens and printers on the restaurant LAN, syncing to the cloud when they can, because a dropped internet connection on a Friday night must not stop the kitchen. Receipt and kitchen printers are commonly driven with the ESC/POS command set, a byte stream of text and control codes (cut paper, select font) sent over USB, serial or a raw TCP socket, often on port 9100.

Layer 3The machine

The data is tiny: a busy restaurant fires perhaps a few hundred lines an hour, and a line is under 200 bytes. A mutex at about 20 ns and a SQLite or Postgres commit at 0.1 to 1 ms are invisible next to the physical latencies: a Wi-Fi round trip on a crowded 2.4 GHz band can take tens of milliseconds and occasionally seconds, and a thermal printer prints a ticket in a second or two. The failure that matters is not speed but loss: a tablet that shows "fired" before the local server has durably stored the lines will, on a dropped packet, leave a table waiting forty minutes for food nobody is cooking. So the tablet treats fire as done only after the server acknowledges a committed write, and retries with the same check and line ids otherwise.

Why it's built this way

  1. Guests change their minds while ordering, so adding and firing are separate steps, and only fired lines cost the kitchen anything.
  2. One check feeds several stations that finish at different times, so the unit of kitchen work is the line, routed by station.
  3. Cooks work in arrival order under pressure, so each station is a FIFO queue, and every change to it is visible on a screen.
  4. Food that has been started cannot be un-cooked, so void is legal only before cooking, and later corrections are a separate, audited comp.
  5. The internet fails during service, so the system of record for the night runs on the local network, and the cloud is a sync target.

Extensibility

Variant: online food delivery order

Here the order is not a check opened at a table. It is one record shared by three parties who never stand in the same room: the customer, the restaurant and a courier. The question changes from "what does the kitchen cook next" to "who is allowed to make which move, and when".

Requirements shift. One order belongs to one restaurant and carries a subtotal and a delivery fee. The restaurant accepts or rejects it, then marks it ready. A dispatcher assigns a courier after acceptance, and the courier picks up and delivers. Status reads vastly outnumber transitions, about six per order.

Data model. The order is the aggregate root, with status placed, accepted, ready, picked up and delivered, plus the endings rejected and cancelled. Customer, restaurant and courier are held by id only, so the record survives profile changes. Role is derived per order (you are the customer if you placed it, the courier if you are assigned) and is never a field on the user, because one person can be a customer on Monday and a courier on Tuesday.

Mechanism: a transition table. Replace the per-line state checks with rows of (state, action, role) -> next state, refund rule. Every rule becomes readable and testable data. A courier app bug cannot mark someone else's order delivered, because the role lookup fails before any state changes. Each transition appends an immutable history entry with a timestamp.

Money follows state. A cancel before acceptance refunds everything. After acceptance the customer gets the delivery fee back but pays for food the kitchen has started. The refund is computed from the state the order was in when it ended, in integer cents, so subtotal + fee - refunded always explains the charge.

Time is part of the model. An order the restaurant never accepts must expire on its own. Use an injected clock and an idempotent sweep that re-checks status under the order's lock, not a sleep inside a request handler.

Races. Cancel against accept, and expiry against a late accept, are settled by one lock or a versioned conditional update on the order: the first wins and the loser sees the new state and fails as invalid. Courier assignment checks and writes the "one active order per courier" map together. Reassigning before pickup releases the old courier in the same step. A retried "deliver" returns invalid, or the original success if an idempotency key is used.

The shape is multi-step consistency across parties, the same as a pull request with an author, reviewer and CI.

Leadership lens

Check yourself

  1. Why separate adding a line from firing it?

    Show answer

    Until a line is fired it costs nothing, so the guest and server can change it freely, and courses can be held back. Firing is the commitment point where the kitchen starts using food and time.

  2. Why is the order line, not the check, the unit of kitchen work?

    Show answer

    A check usually spans several stations that finish at different times. Routing lines lets each station work independently and lets the bar serve drinks without waiting for the grill.

  3. A server voids a line just as the cook taps start. What should happen?

    Show answer

    The two transitions are serialized on the line. If start wins, the void fails and a manager must comp it; if the void wins, the line disappears from the queue and start returns the next line.

  4. Why compute tax once on the subtotal rather than per line?

    Show answer

    Rounding each line separately accumulates error of up to half a cent per line, so the receipt total drifts from the tax rule. One rounding on the subtotal is reproducible and matches the receipt.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →