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.
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
EXCLUDEconstraint, anIdempotency-Keyand 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
Know beforehand
Verified with
PostgreSQL 17 (17.11, postgres:17-alpine)Docker 29
Checked against docs: October 11, 2026
Contents
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.
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.
-- 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; rows | table | indexes | bytes_per_row
--------+-------+---------+---------------
100007 | 14 MB | 15 MB | 293
(1 row)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-Keyheader; responses201,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
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}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.
# 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 postgresMSYS_NO_PATHCONV=1 so container paths are not rewritten-- 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;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');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
+05offset 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.
- 01ClientsBooking widget on the salon's site, admin panel, messenger bot. They talk only to the API.
- 02API serviceModular monolith: catalogue, schedule, bookings, notifications. Stateless, so instances are added behind a load balancer.
- 03PostgreSQLSource of truth: bookings, schedules, outbox. The EXCLUDE constraint is the last line of defence against double booking.
- 04Async pipelineA relay reads the outbox and enqueues jobs; workers send SMS and messages with retries.
- 05External channelsSMS gateway, messengers, email. Slow and unreliable, so they stay off the request path.
The path of one booking:
The client opens the widget
GET /slots: the API reads the staff member's working hours and bookings (later, from a cache).The client presses Book
POST /v1/bookingswithIdempotency-Key. The API validates input and computes the interval end from the service duration.One PostgreSQL transaction
Inserts into
bookingsandoutbox. If the interval overlaps another booking, theEXCLUDEconstraint rejects it and the API returns409.Response to the client
201 Createdright afterCOMMIT, without waiting for the SMS.Asynchronously
The relay moves the event from
outboxto the queue; a worker sends the confirmation and schedules reminders.
- 01Widget
- 02API
- 03PostgreSQL: booking + outbox
- 04Relay
- 05Queue
- 06SMS and messengers
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.
-- 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;$ 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
COMMITOn 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.
-- 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;
ROLLBACKWhat 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.
=# 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)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.
-- 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;-- 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")).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.
-- 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;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.
-- 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)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
outboxrows, 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.
-- 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; 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)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.
-- 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'));-- 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")).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
/slotsmisses 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_idkeeps 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
409responses: a rising share means a rush on popular times or a stale slot cache. - p95 latency of
/slotsandPOST /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.
-- "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; pending | oldest_age
---------+----------------
1 | 00:00:03.34143
(1 row)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
outboxand 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-Keyreturns 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
EXCLUDEconstraint,idempotency_keyand 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
409share, 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.