Work/Full-Stack · System Design

Reservation Platform — Zero Double-Bookings

A US restaurant group ran event-hall, catering, and table bookings over the phone — three intake channels, one paper-adjacent process, and regular double-bookings. I replaced it with a 4-app platform where double-booking is structurally impossible.

Role
Full-Stack Engineer (freelance)
Timeline
7 months · Sep 2025–Mar 2026
Team
Solo, end-to-end
Year
2025–2026
Premium plan, Saturday the 17th. Evening is taken, afternoon is open. Eighty guests, the Live Station buffet, then the phone number and the six-digit code.
0
double-bookings under load
21
customer/admin screens
77
shared Zod schemas
72
automated tests

01 — Problem

Three booking channels, one shared calendar

Event halls, catering, and tables compete for the same physical spaces and staff. When intake happens over the phone across three channels, conflicts are found by accident — usually by the customer, on the day. The client needed self-service booking, but self-service makes the concurrency problem worse: now two customers can race for the same slot at 2 a.m.

02 — Core design

An 8-state lifecycle with transactional locking

A reservation isn't a row, it's a state machine: 8 states from draft through confirmation, fulfillment, and cancellation, with every legal transition enumerated and everything else rejected at the API boundary. Invalid transitions aren't bugs to catch in review — they're unrepresentable.

Slot allocation runs inside a database transaction that locks the slot row before checking availability. Two concurrent requests for the last slot serialize at the database; the second one sees it taken. No advisory flags, no check-then-write races — the invariant lives where the data lives.

State diagram of the 8-state reservation lifecycle with locking at the transition boundary
Draft and awaiting orders belong to guest-order tables. Halls, catering, and hosted tables enter at pending. The slot row is locked before the insert.

03 — The floor

The month, then the decision

The admin calendar marks every day that already holds a hall booking. The table under it is the queue: pending review, confirmed, rejected, with the session and the guest count on the row.

Accepting one opens the reservation, then a second dialog. The copy on that dialog is the invariant: confirm it, and the selected time slot will be locked. Decline and cancel ask for a reason. Completed is a dead end.

Dots are days with a booking. The recording opens the Sunday pending review and stops on the lock.

04 — Catering

The menu is quoted after review

Catering is the second channel on the same lifecycle. The guest builds a tray — koobideh, joojeh, fesenjan — and the card does not show a price. The number is written later, on the admin copy of that same order.

Catering menu with Chelo Kebab Koobideh and Joojeh Kebab added, prices withheld until review
One koobideh, one joojeh. Pricing provided after review, on purpose.

05 — Quote

Tax and delivery are typed on the order

The catalog rate is only a starting point. Three servings of koobideh at $24.99 and two of joojeh at $22.99 are already on the line. Tax and the delivery fee are what the admin adds. Send and confirm writes the total and moves the reservation to confirmed — a catering order does not confirm itself.

8% tax, $45 to Mercer Street. The total lands at $175.63, and that button is what confirms it.

06 — Tables

A table is a time, then a mode

Table booking starts on the same kind of calendar, then a time. 19:00 is the first sitting. After that the host either orders for the group, or invites guests to order on their own link — draft, then awaiting orders, then the same pending review as the hall.

Table reservation calendar with Saturday the 17th selected and 19:00 chosen
Saturday the 17th, 19:00. The next step is who orders.

07 — Phone

Catering, with a fingertip

The phone walk is the other channel. A delivery date, then dinner, then the dishes. Each tap leaves a ring on the control it hits. The cards still withhold the price.

Dinner, two koobideh and a joojeh, then the same six-digit code. The ring is the tap.

08 — Contracts

One schema, both sides of the wire

The monorepo shares 77 Zod schemas between the Fastify API and all frontend apps — every request validated at the edge, every response typed at the client, one definition each. 72 automated tests cover auth, booking, and audit paths, including concurrency tests that hammer the same slot from parallel connections.

09 — Operations

Slow paths off the request, eyes on production

Confirmations and reminders (Twilio SMS, Resend email) run through BullMQ workers, off the HTTP path — a booking never waits on a text message, and a Twilio outage degrades notifications, not bookings.

The platform ships with a 9-service observability stack — OpenTelemetry traces, Prometheus metrics, Loki logs, Grafana on top — targeting p95 under 500ms. For a solo-built system, dashboards are the second engineer.

Seven routes, p95 under 10ms, and the trace for GET /v1/catalog/sessions. The database span is 16ms of the 17.

10 — Outcome

Phone intake, retired

21 customer and admin screens now handle all three booking channels self-service. Under load tests hammering identical slots, double-bookings: zero. The interesting part isn't the zero — it's that the design makes any other number impossible.

Stack

TypeScriptNext.jsReactFastifyPostgreSQLRedisBullMQTwilioDockerOpenTelemetry

Next case

Propex — The Challenge, Then the Desk→