A multi-tenant restaurant platform: app, back office and three workers
Multi-tenant SaaS · architecture, schema, all the code, three workers, tests, operations
A guest scans the QR on the table, browses the menu as short videos, orders and — in Ukraine — pays the bill without waiting for a waiter. Staff see orders on a live board and get a Telegram message when nobody has picked one up. The owner runs menu, tables, halls, bookings, promotions, recipes and theme from the back office; a platform admin opens a new venue in about six minutes. One Next.js app, three background workers, one Postgres — and not one venue wired into the code.
How it worked before
The menu lived in a PDF forwarded through a chat and reprinted after every price change. Orders went from table to kitchen through a waiter's memory, and paying meant waiting for the waiter to come back with a terminal.
What made it hard
Since the first version the platform has doubled: payments, bookings, push notifications, a delivery queue, three workers. Every new feature is another path into the same order — and multi-tenancy, money and restaurant Wi-Fi were already the hard part. The job was to grow without the class of bug that only shows on the floor at peak: a duplicate order after a retry, a bill with the previous party's drinks, “delivered” when nothing arrived, one venue's data where it does not belong.
Engineering decisions
- 01
Every order goes through one command
Guest and waiter call the same placeOrder; the route only authenticates and maps the result to HTTP. The server prices the order and applies discounts with the same module the guest's cart uses. The client's total is only compared: a mismatch is a 409, never a reason to trust it.
- 02
The order and its escalation commit together
The delivery job is enqueued inside the order's transaction — pg-boss lives in the same Postgres. Either both exist or neither does: in the load test 11,900 orders produced exactly 11,900 jobs. The guest gets an answer only after COMMIT. Load also surfaced a deadlock — the queue producer borrowed a pool connection while the order held the table lock — so the producer now starts before the transaction opens.
- 03
A retry returns the same order
Every order carries an idempotency key checked against a hash of the command's canonical form. Thirty orders a second, each sent three times in parallel — not one duplicate. The same key with a different body is a conflict, not a second order.
- 04
The database holds venue boundaries twice
Row-level security on every table, plus composite foreign keys so a dish can only point at a category of its own venue. RLS limits rows; a plain foreign key never checks whose parent row it is. 40 policies, 23 composite keys.
- 05
Realtime is a signal, not a source
The staff board refetches on a Realtime ping and polls every 10 seconds as a fallback; a reconnect means a full refetch. The guest menu comes from a versioned projection — a trigger bumps the version inside the write — so most requests get a 304 without touching the database. In production that cut menu transactions by 81.6%, and a stop-list change still reaches guests in under ten seconds.
- 06
A browser session is not a visit
The cookie identifies a phone; the visit belongs to the table. Once the table is closed the next guest starts a new visit, and the bill covers only the current one — whoever pays never sees the previous party's drinks.
What else is in there
Pay at the table through Expirenza by mono: a separate worker holds a WebSocket per venue and answers bill and payment requests with signed callbacks. Bookings with a guest CRM, ratings for the waiter and dishes, recipes and ingredient consumption, scheduled promotions and upsell hints, Google Places sync, menu import from photos and PDFs through Claude, video transcoding into three sizes with posters, Web Push and an in-app QR scanner. Five guest languages, three currencies — including the rupiah with its zero exponent.
Proof instead of a feeling
About 1,200 unit and database tests, 53 end-to-end scenarios and a visual gate of nine reference screenshots on an iPhone 13 viewport. CI applies migrations twice — the second run must be a no-op — fails on any table without forced RLS, holds the guest bundle under 180 KB gzip and checks that exactly five e2e tests are skipped, each with a reason. The house rule: a gate that has never gone red is not a gate, so every new check is first proven against the defect it was written for.
Measured, not assumed
k6 against a local production build: 30 orders a second for five minutes, each sent three times in parallel — p95 16 ms, no duplicates; 500 staff screens polling the board — p95 under a second. The same test found that tracing every request in Sentry pushed p95 to 11.9 s; tracing is now sampled at 10%. A restore drill brought a production dump into a clean Postgres with all 51 tables matching — about ten minutes to a working state.
Where it stands
In production on its own domain, with the three workers on Railway. A full onboarding rehearsal took 5 min 53 s from “new venue” to an active one, with a phone order and a Telegram escalation. What remains is the pilot venue and a payment against the real Expirenza server: so far every payment test has run against our own mock.
- A multi-tenant restaurant platform: app, back office and three workers45,000
- A travel agency: its own catalogue, booking in three steps27,000
- Water delivery, end to end22,100
- A clinic: 52 pages, an assistant and a smile simulator14,500
- An outreach automation engine11,000
- A relationship CRM with an AI assistant10,400
- Direct-message sales run by an AI4,800
- Daily quests with an AI game master3,700
- A catalogue that updates itself3,300
- A platform for authored training programmes3,300
- A funnel that qualifies the lead itself2,900