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.
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.
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.
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.

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.
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.

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.
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.
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