Backend Design for Restaurant Reservation System
Problem
Design the database schema and RESTful API for a restaurant reservation system. The service must allow customers to search restaurants, view tables, create/update/cancel reservations, and enforce that tables cannot be double-booked for overlapping time ranges.
Solution
1. Requirements Analysis
Functional Requirements:
- Customers can search restaurants and available tables.
- Customers can create, view, update, and cancel reservations.
- The system must prevent double-booking of the same table for overlapping reservation time ranges.
- Restaurant staff can manage tables and view reservations.
Data Requirements:
- Store restaurant metadata (name, address, timezone).
- Store table information (restaurant_id, capacity, section/name).
- Store customer profile (name, email, phone, created_at).
- Store reservations (customer_id, table_id, start_time, end_time, party_size, status, audit timestamps).
Assumptions:
- Reservations are contiguous time ranges; we store
start_timeandend_timeas timezone-aware timestamps. - Business rules (default reservation duration, cancellation windows) are enforced at the application layer.
2. Conceptual Design (Entities & Relationships)
Core Entities:
- Restaurant — venue metadata.
- Table — physical seating that belongs to a restaurant.
- Customer — person making bookings.
- Reservation — a booking linking a customer to a table for a time range.
Relationships:
- Restaurant 1 — N Table
- Table 1 — N Reservation (over time)
- Customer 1 — N Reservation
3. Logical Design (ER Diagram)
erDiagram RESTAURANT { int restaurant_id PK varchar name varchar address varchar timezone } TABLE { int table_id PK int restaurant_id FK varchar name int capacity varchar section } CUSTOMER { int customer_id PK varchar name varchar email varchar phone timestamptz created_at } RESERVATION { int reservation_id PK int customer_id FK int table_id FK timestamptz start_time timestamptz end_time int party_size varchar status timestamptz created_at timestamptz updated_at } RESTAURANT ||--o{ TABLE : has TABLE ||--o{ RESERVATION : "has_reservations" CUSTOMER ||--o{ RESERVATION : makes
Table descriptions
restaurant
restaurant_id(PK): surrogate primary key. Using integer serial keeps joins efficient.name,address,timezone: basic metadata used in searches and for displaying available times in the correct timezone.
restaurant table
table_id(PK): surrogate id for the physical table.restaurant_id(FK): links the table to its restaurant.name: optional human-friendly label (e.g., T1, Table A).capacity: maximum number of guests the table can seat. Used to matchparty_sizewhen auto-assigning tables.section: optional field to group tables by floor/area (useful for staff views and allocation policies).
customer
customer_id(PK): surrogate id for customers.name,email,phone: contact information.email/phoneare useful for confirmations and notifications.created_at: auditing when the profile was created.
reservation (detailed)
reservation_id(PK): surrogate id for the booking record.customer_id(FK): which customer made the booking.table_id(FK): the assigned table. In auto-assign flows this is chosen by the server; in manual flows the client may provide this.start_time,end_time: time range of the reservation stored asTIMESTAMPTZ. We store both endpoints to support variable-length bookings and to computeperiodranges.period(generatedtsrange): a computed range field created fromstart_timeandend_timeto make range overlap checks efficient and support the exclusion constraint. UsingTSRANGEallows use of GIST indexes and the&&operator for overlap checks.party_size: number of guests expected. This drives table assignment (capacity must be >=party_size) and reporting metrics (e.g., occupancy, revenue per cover). It should be an integer > 0.status: small textual state for the reservation lifecycle. Typical values:OPENorPENDING— created but not confirmed (optional confirmation step)CONFIRMED— booked and expected to arriveSEATED— guests have arrived and are using the tableCOMPLETED— guests finished and table clearedCANCELLED— booking cancelled (soft-delete so historical data stays readable)NO_SHOW— guest didn't arrive within a grace window
created_at,updated_at: audit timestamps to track when the reservation was created and last modified.updated_atis maintained by a trigger.
Rationale and behaviors
- We store
party_sizeexplicitly because group size affects table choice, revenue estimates (covers), and validation rules (max capacity). statusenables soft workflow transitions without deleting historical rows. Soft-cancels (CANCELLED) keep analytics intact.- The
periodfield and exclusion constraint provide a strong DB-level invariant: no two reservations for the same table may have overlappingperiodranges. This is preferred over solely application-level checks because it prevents race conditions in high-concurrency scenarios. - Using
TIMESTAMPTZensures correct handling across timezones (important for multi-location chains or for scheduling reminders in local time).
4. Physical Design (SQL Schema)
Below is a PostgreSQL schema. It uses an exclusion constraint on a generated tsrange to prevent overlapping reservations for the same table.
-- Extension for btree_gist (required for some exclusion patterns)
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE restaurant (
restaurant_id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
address TEXT,
timezone TEXT DEFAULT 'UTC'
);
CREATE TABLE rest_table (
table_id SERIAL PRIMARY KEY,
restaurant_id INT NOT NULL REFERENCES restaurant(restaurant_id) ON DELETE CASCADE,
name TEXT,
capacity INT NOT NULL CHECK (capacity > 0),
section TEXT
);
CREATE TABLE customer (
customer_id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT,
phone TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE reservation (
reservation_id SERIAL PRIMARY KEY,
customer_id INT NOT NULL REFERENCES customer(customer_id) ON DELETE CASCADE,
table_id INT NOT NULL REFERENCES rest_table(table_id) ON DELETE CASCADE,
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ NOT NULL,
period TSRANGE GENERATED ALWAYS AS (tsrange(start_time, end_time, '[)')) STORED,
party_size INT NOT NULL CHECK (party_size > 0),
status TEXT NOT NULL DEFAULT 'OPEN',
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
CONSTRAINT reservation_no_overlap EXCLUDE USING GIST (
table_id WITH =,
period WITH &&
)
);
-- Indexes to support common queries
CREATE INDEX idx_rest_table_restaurant_id ON rest_table (restaurant_id);
CREATE INDEX idx_reservation_table_id ON reservation (table_id);
CREATE INDEX idx_reservation_customer_id ON reservation (customer_id);
CREATE INDEX idx_reservation_start_time ON reservation (start_time);
-- Trigger to update updated_at
CREATE OR REPLACE FUNCTION trigger_set_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_reservation_updated_at
BEFORE UPDATE ON reservation
FOR EACH ROW
EXECUTE FUNCTION trigger_set_timestamp();
Notes:
- The exclusion constraint ensures no two reservations for the same table have overlapping
periodranges. - The
btree_gistextension is required in some Postgres versions to support=on integers inside a GIST index.
5. Design Rationale & Indexing
Normalization & Data Integrity:
- The schema is normalized to 3NF. Each entity is modeled independently and referenced via foreign keys. This avoids duplication of restaurant or customer data in reservations.
- Foreign key constraints enforce referential integrity;
ON DELETE CASCADEkeeps dependent rows tidy when a parent is removed (adjust toSET NULLif you need historical preservation).
Preventing double-booking:
- The
EXCLUDE USING GISTconstraint on(table_id, period)guarantees database-level prevention of overlapping reservations for the same table. This is safer than application-only checks which are vulnerable to race conditions.
Indexing Strategy:
- Index foreign keys (
restaurant_id,table_id,customer_id) andstart_timefor common JOIN and time-based queries. - The GIST index backing the exclusion constraint is used for overlap checks; avoid redundant heavy indexes on
period.
Scaling & Operational Notes:
- For very high write concurrency, consider an availability cache or a reservation-service layer that assigns tables and retries on conflicts (HTTP 409). Use idempotency tokens for client retries.
6. API Endpoint Design (RESTful)
Restaurants
POST /restaurants
Request:
{
"name": "Pizzeria Roma",
"address": "100 Main St, City",
"timezone": "Asia/Kolkata"
}
Response (201 Created):
{
"restaurant_id": 42
}
GET /restaurants/{restaurant_id}
Response (200):
{
"restaurant_id": 42,
"name": "Pizzeria Roma",
"address": "100 Main St"
}
GET /restaurants?name=...&min_capacity=...
Response (200):
[
{ "restaurant_id": 42, "name": "Pizzeria Roma" },
{ "restaurant_id": 43, "name": "Trattoria" }
]
Tables
POST /restaurants/{restaurant_id}/tables
Request:
{ "name": "T1", "capacity": 4, "section": "Patio" }
Response (201):
{ "table_id": 101 }
GET /tables/{table_id}
Response (200):
{ "table_id": 101, "restaurant_id": 42, "capacity": 4, "section": "Patio" }
GET /restaurants/{restaurant_id}/tables?capacity=...
Response (200):
[
{ "table_id": 101, "capacity": 4 },
{ "table_id": 102, "capacity": 6 }
]
Customers
POST /customers
Request:
{ "name": "Alice", "email": "[email protected]", "phone": "+1-555-0100" }
Response (201):
{ "customer_id": 900 }
GET /customers/{customer_id}
Response (200):
{ "customer_id": 900, "name": "Alice", "email": "[email protected]" }
Reservations
POST /reservations
Request (auto-assign example):
{
"customer_id": 900,
"restaurant_id": 42,
"start_time": "2025-10-20T19:00:00+05:30",
"end_time": "2025-10-20T21:00:00+05:30",
"party_size": 4
}
Response (201 Created):
{ "reservation_id": 1234, "table_id": 101, "status": "CONFIRMED" }
Errors: 409 Conflict when DB exclusion constraint prevents booking (client should retry or choose another slot).
GET /reservations/{reservation_id}
Response (200):
{
"reservation_id": 1234,
"customer_id": 900,
"table_id": 101,
"start_time": "2025-10-20T19:00:00+05:30",
"end_time": "2025-10-20T21:00:00+05:30",
"party_size": 4,
"status": "CONFIRMED"
}
PUT /reservations/{reservation_id}
Request:
{ "start_time": "2025-10-20T20:00:00+05:30", "end_time": "2025-10-20T22:00:00+05:30" }
Response (200):
{ "reservation_id": 1234, "status": "CONFIRMED" }
DELETE /reservations/{reservation_id}
Response (204 No Content)
Availability
GET /availability?restaurant_id={rid}&start_time=...&end_time=...&party_size=...
Response (200):
[
{ "table_id": 101, "capacity": 4 },
{ "table_id": 102, "capacity": 6 }
]
# Behavioral note (transactional behavior)
POST /reservations must be implemented as a transactional operation. When auto-assigning a table, the server should find a candidate table (using the availability query) and attempt to insert the reservation; if the DB exclusion constraint fails due to a race, return 409 Conflict and allow the client to retry.
7. Core Logic Queries
Below are the key SQL patterns for the most important business logic. They are written for PostgreSQL and assume the schema defined above.
- Find an available table for a restaurant, time range and party size (uses
NOT EXISTSwithtsrange):
SELECT t.table_id
FROM rest_table t
WHERE t.restaurant_id = :restaurant_id
AND t.capacity >= :party_size
AND NOT EXISTS (
SELECT 1
FROM reservation r
WHERE r.table_id = t.table_id
AND r.status <> 'CANCELLED'
AND r.period && tsrange(:start_time, :end_time, '[)')
)
ORDER BY t.capacity ASC
LIMIT 1;
- Transactional create with auto-assign (PL/pgSQL): find a candidate and insert; handle exclusion conflict (SQLSTATE
23P01) gracefully so callers can return 409 and retry.
CREATE OR REPLACE FUNCTION create_reservation_auto(
p_customer_id INT,
p_restaurant_id INT,
p_start_time TIMESTAMPTZ,
p_end_time TIMESTAMPTZ,
p_party_size INT
) RETURNS INT AS $$
DECLARE
v_table_id INT;
v_reservation_id INT;
BEGIN
SELECT t.table_id
INTO v_table_id
FROM rest_table t
WHERE t.restaurant_id = p_restaurant_id
AND t.capacity >= p_party_size
AND NOT EXISTS (
SELECT 1 FROM reservation r
WHERE r.table_id = t.table_id
AND r.status <> 'CANCELLED'
AND r.period && tsrange(p_start_time, p_end_time, '[)')
)
ORDER BY t.capacity ASC
LIMIT 1
FOR UPDATE SKIP LOCKED;
IF v_table_id IS NULL THEN
RAISE EXCEPTION 'no_table_available';
END IF;
BEGIN
INSERT INTO reservation (customer_id, table_id, start_time, end_time, party_size)
VALUES (p_customer_id, v_table_id, p_start_time, p_end_time, p_party_size)
RETURNING reservation_id INTO v_reservation_id;
RETURN v_reservation_id;
EXCEPTION WHEN SQLSTATE '23P01' THEN
-- exclusion constraint violation: concurrent booking happened
RAISE NOTICE 'reservation conflict, caller should retry or return 409';
RETURN NULL;
END;
END;
$$ LANGUAGE plpgsql;
- Simpler approach: attempt insert and rely on exclusion constraint; catch
23P01in application and return HTTP 409.
INSERT INTO reservation (customer_id, table_id, start_time, end_time, party_size)
VALUES (:customer_id, :table_id, :start_time, :end_time, :party_size);
-- On exclusion violation (SQLSTATE 23P01) return 409 Conflict to client
- Cancel a reservation (soft-cancel by status):
UPDATE reservation
SET status = 'CANCELLED', updated_at = now()
WHERE reservation_id = :reservation_id
AND status <> 'CANCELLED';
- Get a customer's upcoming reservations:
SELECT *
FROM reservation
WHERE customer_id = :customer_id
AND end_time > now()
ORDER BY start_time;
- Query to list all reservations for a table in a time window (useful for staff views):
SELECT *
FROM reservation
WHERE table_id = :table_id
AND period && tsrange(:start_time, :end_time, '[)')
ORDER BY start_time;
Notes on behavior & concurrency:
POST /reservationsshould run in a DB transaction. If auto-assigning a table, the server should attempt an insert; if the exclusion constraint triggers, return 409 Conflict and allow the client to retry or choose another slot.POST /reservationsshould run in a DB transaction. If auto-assigning a table, the server should attempt an insert; if the exclusion constraint triggers, return 409 Conflict and allow the client to retry or choose another slot.