How to buildOctober 11, 202617 min read
Software architectureStep 8 of 9

System design by example: a booking service and scaling it

In short

System design is the path from requirements to architecture where every decision is backed by numbers and constraints. For an online booking service the main bottleneck is not load but correctness: two clients must never get the same time, and the most reliable place for that guarantee is the database, via an EXCLUDE constraint. Notifications through an outbox, a schedule cache, replicas and partitioning are added as the service grows and metrics call for them.

TutorialLevel: AdvancedSystem DesignPractice: ~3–6 h

After this you will be able to

  • state functional and non-functional requirements and make a back-of-the-envelope load estimate with explicit assumptions
  • design a booking API and data model that prevents double booking with an EXCLUDE constraint, an Idempotency-Key and a short transaction
  • deliver notifications through a transactional outbox and workers using FOR UPDATE SKIP LOCKED
  • justify when to add a cache, read replicas and partitioning, and what to leave out of the MVP
Contents
  1. 01The problem and the requirements
  2. 02Back-of-the-envelope load estimate
  3. 03API and data model
  4. 04Baseline architecture
  5. 05The bottleneck: double booking
  6. 06Notifications: outbox and queue
  7. 07Scaling: cache, replicas, partitioning
  8. 08Observability, failures, trade-offs and the MVP

The problem and the requirements

The task: design an online booking service for beauty salons and private clinics in Almaty. A client picks a service, a stylist or doctor and a time, then gets a confirmation and a reminder; an administrator sees the schedule and changes it. This is a teaching example: the salons, loads and numbers are hypothetical. We follow the classic system design steps, and every data decision is tested on a PostgreSQL 17 stand.

RequirementsLoad estimateAPI and dataArchitectureBottlenecks
What we buildHow we build it
The steps: agree on the problem first, draw boxes second

Functional requirements describe what the system does:

  • A client sees the salon's services, staff and free times on a chosen date.
  • A client books without signing up: name, phone, service, staff member, time.
  • The client and the salon get a confirmation; the client gets a reminder before the visit.
  • A client cancels via a link; an administrator cancels and reschedules bookings.
  • An administrator sets working hours and breaks for each staff member.

Non-functional requirements describe the system's qualities. They have to be measurable, otherwise nobody can check them. The values below are teaching targets that you would agree with the client:

Correctness

Requirement
No overlapping confirmed bookings for any staff member
Why
A double booking is direct damage: the client arrives and the chair is taken

Booking availability

Requirement
99.9% successful requests per month
Why
About 43 minutes of downtime a month: tolerable for a salon, but no more

Latency

Requirement
p95 under 300 ms for free slots, under 500 ms for creating a booking
Why
The widget is opened on a phone; a slow answer means an abandoned booking

Notifications

Requirement
Delivered within minutes, never duplicated for the client
Why
They can be sent asynchronously, decoupling bookings from the SMS gateway

Data

Requirement
A client's phone is visible only to their salon
Why
It is personal data: tenant isolation and encryption in transit

Deliberately out of scope for the first version: online payment (you will add it in the exercise), loyalty programmes, a salon search catalogue and integrations with accounting systems.

Back-of-the-envelope load estimate

A back-of-the-envelope estimate answers one question: what order of magnitude are we dealing with, tens of requests per second or tens of thousands. The answer decides whether one database server is enough or a cluster is needed. Being off by a factor of two or three is fine; what matters is writing the assumptions down so they can be checked and corrected.

Businesses on the platform

Assumption or formula
assumption
Result
1,000 salons and clinics

Staff members and doctors

Assumption or formula
1,000 × 6
Result
6,000

Bookings per day

Assumption or formula
6,000 × 10 bookings per staff member
Result
60,000

Average booking rate

Assumption or formula
60,000 / 86,400 s
Result
≈ 0.7 per second

Peak booking rate

Assumption or formula
× 10: Monday morning, promotions
Result
≈ 7 per second

Schedule views

Assumption or formula
30 per booking: dates, staff, refreshes
Result
1.8 million per day

Peak reads

Assumption or formula
1.8 million / 86,400 × 10
Result
≈ 210 per second

Notifications

Assumption or formula
3 per booking: confirmation and two reminders
Result
180,000 per day

bookings table growth

Assumption or formula
60,000 × 365 × 0.6 KB
Result
≈ 13 GB per year

We did not guess the row size; we measured it on the stand from the next section: 100,000 synthetic bookings including indexes took about 290 bytes per row. The estimate doubles that to 0.6 KB to allow for real data and fragmentation.

sql/50-size.sql
-- Measurement: space per bookings row including indexes (100,000 bookings)
INSERT INTO masters (salon_id, name)
SELECT 1, 'Stylist ' || g FROM generate_series(3, 1000) g;

INSERT INTO bookings (salon_id, master_id, service_id, client_phone, during, idempotency_key)
SELECT 1, m, 1, '+7700' || lpad((m * 100 + d)::text, 7, '0'),
       tstzrange(timestamptz '2026-11-01 10:00+05' + (d - 1) * interval '1 day',
                 timestamptz '2026-11-01 11:00+05' + (d - 1) * interval '1 day'),
       gen_random_uuid()
FROM generate_series(1, 1000) m, generate_series(1, 100) d;

VACUUM ANALYZE bookings;
SELECT count(*) AS rows,
       pg_size_pretty(pg_table_size('bookings'))          AS table,
       pg_size_pretty(pg_indexes_size('bookings'))        AS indexes,
       pg_total_relation_size('bookings') / count(*)      AS bytes_per_row
FROM bookings;
Measurement: 1,000 staff members with 100 bookings each, then table and index size
terminal
  rows  | table | indexes | bytes_per_row
--------+-------+---------+---------------
 100007 | 14 MB | 15 MB   |           293
(1 row)
Real output: 100,000 measurement rows plus 7 bookings from earlier experiments

Conclusions. There are 30 times more reads than writes, so reading the schedule is what to make cheap: an index first, then a cache. A peak of 7 bookings and about 210 reads per second is, in our experience, within reach of one mid-sized PostgreSQL server; a load test confirms it, an estimate does not. 13 GB a year is no reason to partition in the first year. The real difficulty lies elsewhere: at peak, two clients pick the same popular time.

API and data model

The API describes resources and the actions on them. Naming rules, error formats and pagination are covered in the lesson on API design; here we only make the decisions that matter for bookings.

GET /v1/salons/{id}/services

What it does
Services and their duration
Notes
Read often, changed rarely: a cache candidate

GET /v1/salons/{id}/slots?master_id=&service_id=&date=

What it does
Free start times on a date
Notes
Computed from working hours and bookings

POST /v1/bookings

What it does
Create a booking
Notes
Idempotency-Key header; responses 201, 409, 422

GET /v1/bookings/{id}

What it does
Booking status
Notes
Read from the primary right after creation

POST /v1/bookings/{id}/cancel

What it does
Cancel a booking
Notes
A status transition; history is kept

PUT /v1/masters/{id}/schedule

What it does
Staff working hours
Notes
Invalidates that staff member's slot cache
HTTP
POST /v1/bookings HTTP/1.1
Host: api.example.com
Content-Type: application/json
Idempotency-Key: 6f1c2a8e-0000-4000-8000-000000000001

{"salon_id": 1, "master_id": 1, "service_id": 1,
 "starts_at": "2026-10-20T10:00:00+05:00", "client_phone": "+77000000001"}

# the slot is free
HTTP/1.1 201 Created
Location: /v1/bookings/1

# the slot is already taken
HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{"type": "https://api.example.com/problems/slot-taken",
 "title": "This time is already taken", "status": 409}
The create-booking contract: a sample request and two responses, not runnable code

This contract makes three decisions. The client sends only the start time and the server computes the end from the service duration; otherwise a forged request could book a staff member for 10 minutes instead of an hour. The client generates the idempotency key once per booking attempt: if the response is lost on a mobile network and the request is retried, the server returns the same booking instead of creating a second one. AWS APIs work this way and call it a client request identifier; standardisation of the Idempotency-Key header at the IETF stopped at the draft stage. A taken slot is 409 Conflict: per RFC 9110 the request conflicts with the current state of the resource; the error body uses Problem Details (RFC 9457).

Now the data model. Start the stand: PostgreSQL 17 in Docker, with the lesson's SQL files in a sql/ directory.

bash
# the sql/ directory holds the lesson files; the password test is for a throwaway local container only
docker run -d --rm --name kb-booking-pg -e POSTGRES_PASSWORD=test \
  -v "$PWD/sql:/sql" postgres:17-alpine

docker exec -i -e PGTZ=Asia/Almaty kb-booking-pg psql -U postgres -v ON_ERROR_STOP=1 -f /sql/01-schema.sql
docker exec -i -e PGTZ=Asia/Almaty kb-booking-pg psql -U postgres -v ON_ERROR_STOP=1 -f /sql/02-seed.sql

# interactive sessions for the concurrency experiments, each in its own terminal
docker exec -it -e PGTZ=Asia/Almaty kb-booking-pg psql -U postgres
The stand. In Git Bash on Windows, set MSYS_NO_PATHCONV=1 so container paths are not rewritten
sql/01-schema.sql
-- Data model of the booking service (PostgreSQL 17)
CREATE EXTENSION IF NOT EXISTS btree_gist;  -- "=" on bigint inside a GiST constraint

CREATE TABLE salons (
  id   bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  name text NOT NULL,
  tz   text NOT NULL DEFAULT 'Asia/Almaty'
);

CREATE TABLE masters (
  id       bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  salon_id bigint NOT NULL REFERENCES salons (id),
  name     text NOT NULL
);

CREATE TABLE services (
  id       bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  salon_id bigint NOT NULL REFERENCES salons (id),
  name     text NOT NULL,
  duration interval NOT NULL CHECK (duration > interval '0')
);

CREATE TABLE bookings (
  id              bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  salon_id        bigint NOT NULL REFERENCES salons (id),
  master_id       bigint NOT NULL REFERENCES masters (id),
  service_id      bigint NOT NULL REFERENCES services (id),
  client_phone    text NOT NULL,
  during          tstzrange NOT NULL CHECK (NOT isempty(during)),
  status          text NOT NULL DEFAULT 'confirmed'
                  CHECK (status IN ('confirmed', 'cancelled')),
  idempotency_key uuid NOT NULL UNIQUE,
  created_at      timestamptz NOT NULL DEFAULT now(),
  -- one staff member never has two confirmed bookings that overlap in time
  CONSTRAINT bookings_no_overlap
    EXCLUDE USING gist (master_id WITH =, during WITH &&)
    WHERE (status = 'confirmed')
);

-- Outgoing events: written in the same transaction as the booking
CREATE TABLE outbox (
  id           bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  topic        text NOT NULL,
  payload      jsonb NOT NULL,
  created_at   timestamptz NOT NULL DEFAULT now(),
  processed_at timestamptz
);
CREATE INDEX outbox_pending ON outbox (id) WHERE processed_at IS NULL;
Schema: salons, staff, services, bookings with an exclusion constraint, and the outbox table
sql/02-seed.sql
INSERT INTO salons (name) VALUES ('Example Salon');
INSERT INTO masters (salon_id, name) VALUES (1, 'Stylist 1'), (1, 'Stylist 2');
INSERT INTO services (salon_id, name, duration) VALUES (1, 'Haircut', interval '60 minutes');
Test data: one salon, two staff members, a 60-minute service

during tstzrange instead of two columns

Why
An interval is one type with the overlap operator &&; the half-open [10:00, 11:00) lets the next booking start exactly at 11:00

timestamptz plus the salon's tz

Why
Time is stored as an absolute instant and shown in the salon's zone; the +05 offset is not hard-coded

status instead of deleting

Why
Cancelling is a status transition, so history for analytics and disputes is kept

idempotency_key UNIQUE

Why
The database itself rejects a repeat of the same request

EXCLUDE … WHERE (status = 'confirmed')

Why
Overlaps are forbidden only among active bookings: a cancelled one does not hold the slot

outbox table

Why
The notification event is written atomically with the booking

Baseline architecture

Start with the simplest design that meets the requirements: one deployable API service (a modular monolith), one PostgreSQL database, and a queue with workers for notifications. At seven bookings per second, microservices would add network failures and distributed transactions without solving a single real problem; the trade-offs are compared in the lesson on modular monoliths and microservices.

  1. 01ClientsBooking widget on the salon's site, admin panel, messenger bot. They talk only to the API.
  2. 02API serviceModular monolith: catalogue, schedule, bookings, notifications. Stateless, so instances are added behind a load balancer.
  3. 03PostgreSQLSource of truth: bookings, schedules, outbox. The EXCLUDE constraint is the last line of defence against double booking.
  4. 04Async pipelineA relay reads the outbox and enqueues jobs; workers send SMS and messages with retries.
  5. 05External channelsSMS gateway, messengers, email. Slow and unreliable, so they stay off the request path.
System layers: the booking path is synchronous only up to PostgreSQL

The path of one booking:

  1. The client opens the widget

    GET /slots: the API reads the staff member's working hours and bookings (later, from a cache).

  2. The client presses Book

    POST /v1/bookings with Idempotency-Key. The API validates input and computes the interval end from the service duration.

  3. One PostgreSQL transaction

    Inserts into bookings and outbox. If the interval overlaps another booking, the EXCLUDE constraint rejects it and the API returns 409.

  4. Response to the client

    201 Created right after COMMIT, without waiting for the SMS.

  5. Asynchronously

    The relay moves the event from outbox to the queue; a worker sends the confirmation and schedules reminders.

  1. 01Widget
  2. 02API
  3. 03PostgreSQL: booking + outbox
  4. 04Relay
  5. 05Queue
  6. 06SMS and messengers
The synchronous part ends at COMMIT; notifications run in the background

The bottleneck: double booking

The naive implementation is "check that the slot is free, then insert". Time passes between the check and the insert, and two transactions see the same free slot. Why this happens under PostgreSQL's isolation levels, and the three ways to prevent it, are covered in the lesson on transactions. For the booking service we pick a schema constraint: no handler can forget it, and it holds for any number of API instances.

This is what the API runs for POST /v1/bookings: the booking and the event inserted in one transaction. ON CONFLICT (idempotency_key) DO NOTHING turns a repeat of the same request into a no-op.

sql/03-book.sql
-- Booking: the row and the notification event in one transaction
BEGIN;
WITH b AS (
  INSERT INTO bookings (salon_id, master_id, service_id, client_phone, during, idempotency_key)
  VALUES (1, 1, 1, '+77000000001',
          tstzrange('2026-10-20 10:00+05', '2026-10-20 11:00+05'),
          '6f1c2a8e-0000-4000-8000-000000000001')
  ON CONFLICT (idempotency_key) DO NOTHING
  RETURNING id, master_id, during
)
INSERT INTO outbox (topic, payload)
SELECT 'booking.created',
       jsonb_build_object('booking_id', id, 'master_id', master_id, 'starts_at', lower(during))
FROM b;
COMMIT;
Booking: an idempotent insert plus an outbox event in one transaction
terminal
$ docker exec -i -e PGTZ=Asia/Almaty kb-booking-pg psql -U postgres -f /sql/03-book.sql   # first request
BEGIN
INSERT 0 1
COMMIT
$ docker exec -i -e PGTZ=Asia/Almaty kb-booking-pg psql -U postgres -f /sql/03-book.sql   # retry with the same key
BEGIN
INSERT 0 0
COMMIT
Real output, trimmed to results: the retry created neither a second booking nor a second event

On INSERT 0 0 the API looks up the existing booking by key and returns it with the same 201. If the same key arrives with different parameters, say a different time, that is a client error: AWS returns a validation error in this case, and we respond with 422.

Now the main event: concurrent booking. Two psql sessions play two API instances. Session A books a client for 12:00–13:00 and keeps the transaction open for 4 seconds, like a slow handler. One second later session B tries to book another client with the same staff member for 12:30–13:30. A third session, C, observes.

terminal
-- Session A: booking for 12:00–13:00, the transaction stays open
A=# BEGIN;
BEGIN
A=# WITH b AS (INSERT INTO bookings … '2026-10-20 12:00+05', '2026-10-20 13:00+05' …) INSERT INTO outbox …;
INSERT 0 1
A=# SELECT pg_sleep(4);
-- One second later, session B: same staff member, 12:30–13:30
B=# \timing on
B=# BEGIN;
BEGIN
B=# WITH b AS (INSERT INTO bookings … '2026-10-20 12:30+05', '2026-10-20 13:30+05' …) INSERT INTO outbox …;
-- B waits. Session C checks who it is waiting for:
C=# SELECT pid, wait_event_type, wait_event, pg_blocking_pids(pid) AS blocked_by
C-#   FROM pg_stat_activity WHERE wait_event_type = 'Lock';
 pid | wait_event_type |  wait_event   | blocked_by
-----+-----------------+---------------+------------
 113 | Lock            | transactionid | {106}
(1 row)
-- A's pause is over
A=# COMMIT;
COMMIT
-- B gets its answer:
ERROR:  conflicting key value violates exclusion constraint "bookings_no_overlap"
DETAIL:  Key (master_id, during)=(1, ["2026-10-20 12:30:00+05","2026-10-20 13:30:00+05")) conflicts with existing key (master_id, during)=(1, ["2026-10-20 12:00:00+05","2026-10-20 13:00:00+05")).
Time: 3063.304 ms (00:03.063)
B=# \echo :LAST_ERROR_SQLSTATE
23P01
B=# ROLLBACK;
ROLLBACK
Real output from three sessions; the INSERT is the one from 03-book.sql with another time and key and without ON CONFLICT, long lines shortened

What happened. B did not fail immediately: A's uncommitted row could still roll back, so B waited about 3 seconds for the outcome, and pg_blocking_pids showed exactly whom it waited for. As soon as A committed, B got 23P01, an exclusion constraint violation. The API maps this code to 409 Conflict and offers nearby slots. B's outbox event never appeared either: it was in the same transaction.

terminal
=# SELECT id, client_phone, during FROM bookings WHERE master_id = 1 ORDER BY id;
 id | client_phone |                       during
----+--------------+-----------------------------------------------------
  1 | +77000000001 | ["2026-10-20 10:00:00+05","2026-10-20 11:00:00+05")
  3 | +77000000002 | ["2026-10-20 12:00:00+05","2026-10-20 13:00:00+05")
(2 rows)
=# SELECT id, topic, payload FROM outbox ORDER BY id;
 id |      topic      |                                   payload
----+-----------------+-----------------------------------------------------------------------------
  1 | booking.created | {"master_id": 1, "starts_at": "2026-10-20T10:00:00+05:00", "booking_id": 1}
  2 | booking.created | {"booking_id": 3}
(2 rows)
Real output after the experiment: one booking at 12:00 and one event for it

We tested the reverse too: if A runs ROLLBACK, the waiting B inserts its booking successfully after the same 3 seconds or so. Cancellation works through the partial constraint: after UPDATE … SET status = 'cancelled' the slot is free again and the 12:30 booking goes through.

sql/20-gotcha-on-conflict.sql
-- Trap: ON CONFLICT without a target also swallows the EXCLUDE violation
INSERT INTO bookings (salon_id, master_id, service_id, client_phone, during, idempotency_key)
VALUES (1, 1, 1, '+77000000004', tstzrange('2026-10-20 12:15+05', '2026-10-20 13:15+05'),
        '6f1c2a8e-0000-4000-8000-00000000000c')
ON CONFLICT DO NOTHING;

INSERT INTO bookings (salon_id, master_id, service_id, client_phone, during, idempotency_key)
VALUES (1, 1, 1, '+77000000004', tstzrange('2026-10-20 12:15+05', '2026-10-20 13:15+05'),
        '6f1c2a8e-0000-4000-8000-00000000000c')
ON CONFLICT (idempotency_key) DO NOTHING;
Two inserts for an overlapping 12:15–13:15 slot
terminal
-- without a conflict target: the overlap is silently swallowed
INSERT 0 0
-- with a target (idempotency_key): the EXCLUDE violation surfaces
ERROR:  conflicting key value violates exclusion constraint "bookings_no_overlap"
DETAIL:  Key (master_id, during)=(1, ["2026-10-20 12:15:00+05","2026-10-20 13:15:00+05")) conflicts with existing key (master_id, during)=(1, ["2026-10-20 12:00:00+05","2026-10-20 13:00:00+05")).
Real output: without a target the overlap is swallowed, with a target it is error 23P01

B's wait is not free: while A holds its transaction, every booking for that staff member at an overlapping time queues up. So the booking transaction must be short, with no HTTP calls to the SMS gateway or a payment provider between BEGIN and COMMIT.

Notifications: outbox and queue

The confirmation must go out exactly when the booking is really saved. If the API commits first and then pushes a job to a Redis queue, the process can crash in between: the booking exists, the SMS does not. The other way round, the SMS goes out and the transaction rolls back. This is the dual write problem: two independent systems cannot be updated atomically.

The transactional outbox pattern solves it within a single database: the event is written to the outbox table in the same transaction as the booking, which 03-book.sql already does. A separate relay process reads unprocessed events and publishes them to the queue. Delivery models and the outbox are covered in depth in the lesson on events and queues.

Booking transactionOutbox rowRelayJob queueSMS delivery
Atomic in PostgreSQLAsynchronous, with retries
An event cannot exist without the booking and cannot be lost after it
sql/30-outbox-worker.sql
-- One outbox worker pass: claim a batch, publish to the queue, confirm
BEGIN;
WITH batch AS (
  SELECT id
  FROM outbox
  WHERE processed_at IS NULL
  ORDER BY id
  LIMIT 100
  FOR UPDATE SKIP LOCKED   -- skip rows another worker has claimed
)
UPDATE outbox o
SET processed_at = now()
FROM batch
WHERE o.id = batch.id
RETURNING o.id, o.topic, o.payload;
-- the application publishes the returned events to the queue:
-- success → COMMIT, failure → ROLLBACK (the rows become available again)
COMMIT;
One relay pass: a batch of events is claimed with a lock, rows already taken are skipped

FOR UPDATE SKIP LOCKED lets several relays run in parallel: rows another worker has taken are skipped instead of waited on. The PostgreSQL docs describe this mode as suitable for multiple consumers of a queue-like table and unsuitable for general work, since it deliberately gives an inconsistent view. The test: two workers with batches of 2 events, the first holding its transaction for 3 seconds.

terminal
-- Worker 1: batch of 2, holds its transaction for 3 seconds
 id |      topic
----+-----------------
  1 | booking.created
  2 | booking.created
(2 rows)
-- Worker 2, started a second later: does not wait, takes the next rows
 id |      topic
----+------------------
  3 | booking.reminder
  4 | booking.reminder
(2 rows)
Real output of the demo version with LIMIT 2 and a pause: the second worker did not wait for the first

The guarantee here is at-least-once: if the relay published an event but crashed before COMMIT, it publishes it again after a restart. So the consumer must be idempotent: the sending worker records "confirmation for booking 1 sent" and skips the duplicate. How to do that with BullMQ (jobId, retries with backoff, dead jobs) is shown in the lesson on background jobs.

  • Reminders a day and 2 hours ahead are delayed queue jobs whose run time is computed from lower(during).
  • On cancellation or rescheduling the worker re-checks the booking status before sending, which is simpler than finding and deleting jobs in the queue.
  • A periodic job deletes or archives processed outbox rows, otherwise the table grows forever.

Scaling: cache, replicas, partitioning

By our estimate there are 30 times more schedule reads than bookings. Start with the free-slot query itself: a grid of start times in 30-minute steps minus overlaps with active bookings. In this teaching version working hours are constants; a real system reads them from a shifts table with breaks.

sql/22-free-slots.sql
-- Free start times for a 60-minute service with staff member 1 on 20 October (30-minute step)
SELECT to_char(s, 'HH24:MI') AS starts_at
FROM generate_series(timestamptz '2026-10-20 10:00+05',
                     timestamptz '2026-10-20 18:00+05',
                     interval '30 minutes') AS s
WHERE NOT EXISTS (
  SELECT 1
  FROM bookings b
  WHERE b.master_id = 1
    AND b.status = 'confirmed'
    AND b.during && tstzrange(s, s + interval '60 minutes')
)
ORDER BY s;
Free slots: generate_series plus NOT EXISTS on the overlap operator
terminal
 starts_at
-----------
 11:00
 11:30
 13:30
 14:00
 14:30
 15:00
 15:30
 16:00
 16:30
 17:00
 17:30
 18:00
(12 rows)
Real output: 10:00–11:00 and 12:30–13:30 are taken; the 12:00 booking has been cancelled by now

The EXCLUDE constraint creates a GiST index on (master_id, during), and the same index serves the overlap search: on the stand EXPLAIN showed Index Only Scan using bookings_no_overlap. No separate index for slots is needed.

The schedule cache. The /slots response for a staff member and date fits cache-aside well, with a key like slots:{master_id}:{date}, a short TTL and event-driven invalidation: on booking.created and booking.cancelled the relay deletes the key for that staff member and date. A stale cache is harmless here, and that is the key architectural property: the cache may show a taken slot as free, but the database still checks the booking and the client gets 409 with another time. Invalidation strategies and stampede protection are in the lesson on caching and invalidation.

Read replicas. PostgreSQL streaming replication is asynchronous by default, and the docs call data on a hot standby eventually consistent with the primary. That gives the routing rule:

Free slots, service catalogue

Where
Replica or cache
Why
Sub-second lag is fine: the final check happens on the primary

Create, cancel, reschedule

Where
Primary only
Why
A replica is read-only, and the constraint is enforced on the primary

"My booking" right after creation

Where
Primary
Why
Otherwise the client might not see the booking they just made (read-your-writes)

Reports and exports for the salon

Where
A dedicated replica
Why
Heavy queries do not get in the way of bookings

Partitioning. A table is split into partitions when it gets in the way of maintenance: VACUUM takes too long, old data must be dropped in bulk quickly, indexes no longer fit in memory. At 13 GB a year that is far off, but the partition key deserves thought up front: the obvious "by month" option breaks the double-booking protection.

sql/40-partitioning.sql
-- 1) Partitioning by time: the "no overlap" constraint cannot be declared this way
CREATE TABLE bookings_by_month (
  salon_id  bigint NOT NULL,
  master_id bigint NOT NULL,
  starts_at timestamptz NOT NULL,
  during    tstzrange NOT NULL,
  EXCLUDE USING gist (master_id WITH =, during WITH &&)
) PARTITION BY RANGE (starts_at);

-- 2) Partitioning by salon (tenant): the key is part of the constraint via "="
CREATE TABLE bookings_by_salon (
  salon_id  bigint NOT NULL,
  master_id bigint NOT NULL,
  during    tstzrange NOT NULL,
  EXCLUDE USING gist (salon_id WITH =, master_id WITH =, during WITH &&)
) PARTITION BY HASH (salon_id);
CREATE TABLE bookings_by_salon_0 PARTITION OF bookings_by_salon FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE bookings_by_salon_1 PARTITION OF bookings_by_salon FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE bookings_by_salon_2 PARTITION OF bookings_by_salon FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE bookings_by_salon_3 PARTITION OF bookings_by_salon FOR VALUES WITH (MODULUS 4, REMAINDER 3);

INSERT INTO bookings_by_salon VALUES (7, 1, tstzrange('2026-10-20 10:00+05', '2026-10-20 11:00+05'));
INSERT INTO bookings_by_salon VALUES (7, 1, tstzrange('2026-10-20 10:30+05', '2026-10-20 11:30+05'));
Two ways to partition the bookings table
terminal
-- 1) bookings_by_month
ERROR:  unique constraint on partitioned table must include all partitioning columns
DETAIL:  EXCLUDE constraint on table "bookings_by_month" lacks column "starts_at" which is part of the partition key.
-- 2) bookings_by_salon: table and 4 partitions created, first booking: INSERT 0 1, second:
ERROR:  conflicting key value violates exclusion constraint "bookings_by_salon_3_salon_id_master_id_during_excl"
DETAIL:  Key (salon_id, master_id, during)=(7, 1, ["2026-10-20 10:30:00+05","2026-10-20 11:30:00+05")) conflicts with existing key (salon_id, master_id, during)=(7, 1, ["2026-10-20 10:00:00+05","2026-10-20 11:00:00+05")).
Real output, trimmed to results

The PostgreSQL 17 docs explain why: an exclusion constraint on a partitioned table must include all partition key columns and compare them for equality, not with &&. Partitioning by salon, that is by tenant, satisfies the rule: all of a staff member's bookings live in one partition, and the second overlapping booking is rejected. With this key, old bookings move to an archive via a periodic job. Tenant isolation models are covered in the multi-tenant SaaS lesson.

Constraint's GiST index

When to add it
From day one
What it gives
Double-booking protection and fast overlap search

More API instances

When to add it
API CPU near its limit at peak
What it gives
Linear throughput growth, since the API is stateless

Slot cache

When to add it
p95 for /slots misses the requirement, the database is busy reading
What it gives
Takes most reads off the database

Read replica

When to add it
After caching the database is still read-bound, or reports are needed
What it gives
Separates reads from writes

Partitioning

When to add it
The table gets in the way of maintenance or needs fast purging
What it gives
HASH on salon_id keeps the constraint

Observability, failures, trade-offs and the MVP

Metrics you cannot run this system without; the general approach to logs, metrics and alerts is in the lesson on monitoring and logging:

  • Bookings per minute and the share of 409 responses: a rising share means a rush on popular times or a stale slot cache.
  • p95 latency of /slots and POST /v1/bookings, checked against the non-functional requirements.
  • Lock wait time and transactions longer than a second: a sign that a slow call crept into the booking transaction.
  • Outbox lag: the number of unprocessed events and the age of the oldest.
  • Failed sends per channel and the number of dead jobs in the queue.
  • Replica lag, if reads go to a replica.
sql/33-outbox-lag.sql
-- "Outbox lag" metric: how many events are waiting and how long the oldest has waited
SELECT count(*)                         AS pending,
       coalesce(now() - min(created_at), interval '0') AS oldest_age
FROM outbox
WHERE processed_at IS NULL;
Outbox lag: a query to export as a metric
terminal
 pending |   oldest_age
---------+----------------
       1 | 00:00:03.34143
(1 row)
Real output on the stand: one event has been waiting for about 3 seconds

An alert on the age of the oldest event is more reliable than one on the count: the backlog legitimately grows at peak, but an event waiting 10 minutes means the relay has stopped.

PostgreSQL primary

What the client sees
Booking unavailable, 503
What we do
Fail over to the standby; with async replication the last transactions may be lost, a trade-off recorded explicitly

Redis cache

What the client sees
Slots load a little slower
What we do
Fall back to reading the database: the cache is not the source of truth

Relay or queue

What the client sees
Booking works, SMS are delayed
What we do
Events accumulate in outbox and go out after recovery

SMS gateway

What the client sees
The confirmation arrives later
What we do
Retries with growing delays and random jitter, plus a fallback channel

Client retries a request

What the client sees
Nothing
What we do
Idempotency-Key returns the same booking

An API instance

What the client sees
Some requests fail and the client retries
What we do
The load balancer drops the instance via its readiness check

On lost transactions the PostgreSQL docs are explicit: with asynchronous replication, if the primary crashes, some committed transactions may not have reached the standby. Synchronous replication removes that risk at the cost of latency on every COMMIT. For salon bookings, in our experience, an async replica plus backups with continuous WAL archiving is usually enough, but the decision is made together with the client.

EXCLUDE constraint in the database

We gain
A race-free guarantee for any number of API instances
We pay
Part of the booking logic lives in the schema; migrations get harder

Modular monolith

We gain
One deployment, ordinary transactions
We pay
It scales as a whole, not per module

Outbox instead of sending directly

We gain
A notification is never lost and never sent without a booking
We pay
Seconds of delay and at-least-once delivery

Slot cache with a TTL

We gain
Cheap reads at peak
We pay
A client occasionally sees a taken slot and gets 409

Async replica

We gain
Reads and reports without loading the primary
We pay
Lag, and the risk of losing the last transactions in a crash

What goes into the MVP:

  • One PostgreSQL database with the EXCLUDE constraint, idempotency_key and backups
  • A stateless API service, at least two instances behind a load balancer
  • The outbox and one relay; confirmations and reminders with retries
  • Metrics for bookings, the 409 share, latency and outbox lag; an alert on outbox age
  • A load test of "20 clients on one slot" before launch
  • Later, when metrics call for it: slot cache, replica, partitioning, synchronous replica

The next step is to record the key decisions as ADRs: why a database constraint, why the outbox, which async replica risks were accepted. How to write such records is covered in the lesson on software architecture fundamentals.

How this was verified10 · passed1 · not runchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.