Hotel Booking System Design: Complete Architecture
Hotel booking system design is hard because of inventory: a multi-night stay is a bundle of date-scoped room-nights that must be allocated all at once or not at all, while search traffic forty times larger reads the same data. This page builds the architecture from the simplest design, adding one mechanism only when the previous one breaks.
Problem Definition and the Core Invariant
A hotel reservation system lets travelers search hotels by destination and dates, price a stay, hold it, pay and receive a confirmation, while hotels manage their own inventory, rates and restrictions. Its core rule: never confirm more stays for a room type on any night than that night's sellable inventory, and give a multi-night stay all of its nights or none. The architecture that follows has two very different halves. A horizontally scaled search path reads an eventually consistent availability projection (allowed to lag about 60 seconds). A small, strongly consistent booking path has an Inventory Service allocate every night of a stay with ordered conditional updates in one database transaction, places a short-lived hold (600-second TTL) while a Booking Orchestrator authorizes and captures payment, and converts the hold into a reservation in one confirmation transaction that also writes an outbox event. Background workers expire abandoned holds and reconcile payments that outran a crash; hotel extranet and PMS updates write the same ledger in per-hotel cursor order; and the booking database is sharded by hotel_id to contain hot properties.
The unit of inventory is the room-night
A hotel does not sell "a room". It sells a room type (Deluxe, Standard) per night. A 2-night stay consumes the Deluxe inventory of night one and the Deluxe inventory of night two — two independently scarce resources requested together. If either night is sold out, the whole stay must fail; confirming night one and silently failing night two produces a reservation nobody asked for.
That gives the invariant the entire design is graded against: for every (hotel, room type, night), confirmed plus held never exceeds sellable, and a multi-night stay is allocated atomically — every night or none. Every later component — the read model, holds, the state machine, idempotency keys, the outbox, reconciliation, PMS cursors — exists to keep that sentence true under concurrency, retries, crashes and scale.
The running example
One small example is used throughout. Hotel HTL-204 in Bengaluru has 5 Deluxe rooms (room type RT-DLX). A traveler wants 2 nights: check-in 2026-11-14, check-out 2026-11-16, i.e. the nights of 11-14 and 11-15. On 11-14, 3 Deluxe rooms are still free; on 11-15, only 1 is. That single remaining room-night is where every interesting failure in this design happens: two requests want it at once, a hold on it expires just as a payment lands, a stale search result keeps advertising it, and the hotel's own PMS reports a different count for it.
Scope
In scope: destination/date search, room-type and rate-plan browsing, multi-night availability and pricing, quotes, holds, booking, payment authorization and capture, confirmation, cancellation, date modification, hotel-side rates, capacity and restrictions (stop-sell, minimum stay), and synchronization with a hotel's property-management system (PMS) or channel manager.
Out of scope beyond a mention: payment-provider internals (see the companion Payment System design; this design owns the inventory/booking boundary around payment), loyalty, dynamic-pricing algorithms, geo-search ranking, and PCI compliance details.
Visual Design Journey
Watch a hotel reservation system evolve stage by stage, the way you would draw it on a whiteboard: from one service and one database to an outbox-fed search projection, ordered per-night conditional allocation, fenced holds, payment coordination with crash reconciliation, cancellation and modification, hotel restrictions and PMS sync, and a Booking DB sharded by hotel — using the same example and numbers as the Complete Design.
Domain Model: Property, Room Type, Room-Night and Rate Plan
Most correctness bugs in hotel booking come from mixing up a handful of domain terms. Each one below becomes a separate table with its own lifetime and guarantees.
Property (hotel)
A physical hotel at one location, such as HTL-204. It owns room types, rate plans, policies and restrictions, and it is the natural partition key for nearly all data in the system.
Room type
A sellable category of room — RT-DLX (Deluxe, 2 adults) or RT-STD (Standard). Inventory and prices are tracked per room type, so a traveler books "a Deluxe room", not "room 412".
Physical room
A numbered room belonging to a room type. It is assigned by the front desk at check-in, based on which rooms are clean and ready. Assigning it at booking time is a legitimate alternative (some resorts sell specific villas), but it turns a count check into a room-by-room scheduling problem; see Trade-offs & Decisions.
Room-night inventory
The atomic unit of scarcity: one row per (hotel_id, room_type_id, stay_date) holding total (rooms of that type), sellable (total minus rooms out of order), held (claimed by active holds) and confirmed (sold). Availability is derived: available = sellable - held - confirmed. An N-night stay touches N of these rows.
Rate plan
The commercial terms a room type is sold under: price, cancellation policy, inclusions. The Deluxe room is sold as RP-FLEX (free cancellation until 24 hours before check-in) and RP-SAVER (cheaper, non-refundable). Both rate plans draw on the same room-night inventory rows; rate plans differentiate price, not capacity.
Restriction
A hotel rule that blocks a sale even when counts allow it: stop-sell on a date, minimum length of stay, closed to arrival. Restrictions are their own rows, checked when inventory is allocated.
Three different promises: quote, hold, reservation
| Object | What it promises | Lifetime | Introduced |
|---|---|---|---|
| Search result | Nothing — a recent, possibly stale view | Seconds to a minute | Stage 1 (Chapter 09) |
| Quote | The same price will be recalculated for this exact stay | 10 minutes | Chapter 07 |
| Hold | These room-nights are reserved for this traveler only | TTL 600 s, then expires | Stage 4 (Chapter 12) |
| Confirmed reservation | The stay is booked and paid — the only real guarantee | Until the stay, cancellation or modification | Stage 6 (Chapter 14) |
A stay is calendar nights, not elapsed hours
A "2-night stay" means two property-local calendar nights, not 48 hours. Stay dates are evaluated in the hotel's timezone (Asia/Kolkata for HTL-204) and stored as DATE values, never as a UTC timestamp plus a duration. A check_in + 48h model silently lands on the wrong night across daylight-saving transitions and produces confirmation emails whose dates disagree with the hotel's records. Nightly rates and taxes are likewise computed per local night.
Functional Requirements
Two groups of users drive the requirements — travelers and hotel operators — and both act on the same room-night inventory. A third group of requirements comes from the fact that networks, clients and services fail.
Traveler-facing
Search hotels by destination, check-in/check-out dates and occupancy, with filters (price, amenities), sorting and pagination. View a property, its room types, rate plans and policies, and availability for every night of the requested stay, not just check-in night. Get a reproducible quote: nightly rates, taxes and fees, currency, cancellation policy, price version and expiry. Hold the stay while paying, book it with payment authorized and captured, and receive a confirmation. View bookings, cancel per the rate plan's policy, and modify dates or room type.
Hotel-operator-facing
Publish room types, rate plans, nightly rates and capacity. Close dates (stop-sell) and set minimum-stay or closed-to-arrival restrictions. Optionally connect a property-management system (PMS) or channel manager that pushes capacity and rates because the hotel also sells through other channels.
Robustness requirements
Tolerate double-clicks, client retries after timeouts, duplicated or out-of-order payment webhooks, delayed events, and a crash at any point of the booking pipeline — without losing a paid booking, charging twice, or selling a room-night twice. These are requirements, not edge cases: they determine the state machine and transaction boundaries.
Non-Functional Requirements and SLOs
The targets below are illustrative; what matters is the split they encode. Search tolerates staleness and must be cheap at high volume. Allocation and confirmation tolerate nothing but are low volume.
| Target | Value | Why this value |
|---|---|---|
| Search latency | p95 ≤ 300 ms | Served from a read model and index, never from the authoritative database |
| Booking API latency (hold, book) | p95 ≤ 800 ms platform-side | Short conditional transactions plus an idempotency lookup; provider time is bounded separately by auth_timeout = 60 s |
| Search freshness | Projection lag ≤ ~60 s | Staleness only affects what is displayed; every allocation re-checks the authoritative rows |
| Allocation correctness | Zero oversells against the platform ledger | A hard invariant enforced by the database transaction, not a percentile |
| Peak search | ~1,800 QPS (bursts ~5,400) | Derived in Chapter 05; dominates infrastructure cost |
| Peak booking | ~45 attempts/s | Derived in Chapter 05; dominates correctness effort |
| Availability | 99.95% (≈ 4.4 h/year) | Search degrades gracefully (serve stale results); the booking path fails closed rather than risk an oversell |
| Durability | RPO ≈ 0 for confirmed reservations within a region; RTO minutes | A lost confirmation is a stranded guest and a financial error; cross-region RPO caveats in Stage 10 |
| PCI scope | Card data never touches booking services | Payment-provider tokenization keeps inventory and booking databases out of PCI scope |
Cost follows the same split. Search, with thousands of queries per second, is where compute, caching and CDN money goes. Booking, with tens of requests per second, is where engineering rigor goes: transactions, constraints, idempotency, reconciliation.
Capacity Estimation
Every number is a labeled assumption chosen for the right order of magnitude. The estimate's real job is to show which problem is hard: not storage, not aggregate write throughput, but read volume on one side and single-row contention on the other.
Inventory rows
Assume 50,000 properties with 3 room types each: 150,000 room types. With a 400-day bookable horizon, the authoritative ledger holds 150,000 × 400 = 60,000,000 room-night rows. At ~80 bytes per row that is ~4.8 GB, ~10 GB with indexes — small. Prices live in a separate DailyRate table (2 rate plans on average: 120,000,000 rows) because prices change when a hotelier edits them, whereas inventory rows change on every hold, confirmation, expiry and cancellation; keeping them apart means a price update never touches a hot inventory row.
Search traffic
Assume 25,000,000 searches per day: 25,000,000 / 86,400 ≈ 289 QPS on average. Travel search is diurnal and seasonal; with a 6× peak factor, 289 × 6 ≈ 1,734, rounded to ~1,800 QPS. Travelers compare 2-3 properties per search, so full-stay availability reads run at roughly 1,800 × 3 = 5,400 QPS at peak, and a holiday surge can triple search again.
Booking traffic
Assume 2.5% of searches lead to a booking attempt. At peak: 1,800 × 2.5% = 45 attempts/s. Per day: 25,000,000 × 2.5% = 625,000 attempts, of which ~90% succeed (the rest lose a race, are declined or abandoned): ≈ 562,500 confirmed reservations/day. Each attempt runs about three short write transactions (hold, booking-attempt insert, confirmation), so the primary sees on the order of 45 × 3 ≈ 135 write transactions/s at peak, plus expiries and cancellations — comfortably within one well-provisioned relational primary.
The real hot spot: one row
Aggregate write volume is modest, but it is not evenly spread. During a city-wide conference, the single row (HTL-204, RT-DLX, 2026-11-15) may see 50-200 allocation attempts within a few seconds, because every multi-night request covering that date touches it. Row-level locking serializes them. If one allocation transaction holds the row lock for 2-5 ms, that row can process roughly 200-500 allocations per second — enough, provided no transaction ever holds the lock across a network call. Capacity planning for the booking path is about per-row critical-section time, not cluster QPS.
Derived volumes
Read model: 150,000 room types × 400 nights × ~20 bytes ≈ 1.2 GB, which fits in memory with room for replication. Events: each attempt emits about 4 outbox events (hold created, hold consumed or expired, reservation confirmed, notification), 625,000 × 4 = 2,500,000 events/day ≈ 29/s average and low hundreds per second at peak. Transactional storage: 562,500 × 365 ≈ 205,000,000 reservations/year at ~1.5 KB each (reservation, nights, attempt, payment reference, events) ≈ 307 GB/year.
- 60M room-night rows (~10 GB): the authoritative ledger is small; size is not the scaling problem
- Search (~1,800 QPS, ~5,400 availability reads) is about 40× booking (~45 attempts/s): the two paths need different architectures
- ~135 short write transactions/s fit a single primary; sharding later is for blast radius and hot-property isolation, not throughput
- One popular room-night can see 50-200 concurrent attempts: keep the allocation transaction short and never hold a row lock across a network call
Core Data Model
Each entity represents one distinct fact. The room-night ledger is the only scarce resource; quotes, holds and reservations are different objects precisely because they make different promises.
| Entity | Represents | Key / notes |
|---|---|---|
| Hotel, RoomType, PhysicalRoom | Property, sellable category, numbered room (assigned at check-in) | hotel_id; property-local timezone |
| RatePlan, DailyRate | Commercial terms; price per (room type, rate plan, night) | Read-heavy, cached; changes only when the hotel edits rates |
| RoomNightInventory | total / sellable / held / confirmed per (hotel, room type, night) | The authoritative scarcity ledger (DDL below) |
| Restriction | Stop-sell, minimum stay, closed to arrival | Checked inside the hold transaction |
| AvailabilityProjection | Search read model of availability and price | Derived from outbox events; lag ≤ ~60 s (Stage 1) |
| Quote | Priced, time-boxed stay calculation with price_version | Expires after 10 minutes; claims no inventory |
| InventoryHold | Temporary claim on every night of a stay | state ACTIVE → CONSUMED / EXPIRED / RELEASED; TTL 600 s (Stage 4) |
| BookingAttempt | One idempotent booking submission | Unique on (principal, endpoint, idempotency key) (Stage 6) |
| PaymentIntent, Refund | Authorization/capture and refunds at the provider | Linked to the attempt by a stable reference |
| Reservation, ReservationNight | The confirmed booking and its nights | state, version; modifications keep it CONFIRMED (Stage 8) |
| ReservationModification | Old and new night sets of one modification | Audit of each version change |
| OutboxEvent | Domain event written in the same transaction as the change | Feeds the read model, notifications and refunds |
| InventoryAdjustment, SyncCursor | Hotel or PMS change to capacity/rates; last applied cursor per hotel | Ordering for PMS sync (Stage 9) |
CREATE TABLE room_night_inventory (
hotel_id TEXT NOT NULL,
room_type_id TEXT NOT NULL,
stay_date DATE NOT NULL, -- property-local calendar night
total INT NOT NULL,
sellable INT NOT NULL, -- total minus rooms out of order
held INT NOT NULL DEFAULT 0,
confirmed INT NOT NULL DEFAULT 0,
PRIMARY KEY (hotel_id, room_type_id, stay_date),
CHECK (held >= 0 AND confirmed >= 0),
CHECK (sellable <= total),
CHECK (held + confirmed <= sellable) -- the invariant, enforced by the database
);
-- (HTL-204, RT-DLX, 2026-11-15): total=5, sellable=5, held=0, confirmed=4 -> available = 1Notice the shape: RoomNightInventory is referenced by the booking path directly (inside transactions) and by the search path only indirectly (through the projection). The booking tables — inventory, holds, attempts, reservations and outbox — live in one database per hotel shard, so every booking step is a local ACID transaction; no distributed transaction is ever needed.
Representative APIs
Read endpoints say how fresh they are; write endpoints carry an Idempotency-Key and return errors that name the exact reason. Both properties come straight from the requirements.
Search (served from the read model)
GET /v1/search?city=Bengaluru&checkin=2026-11-14&checkout=2026-11-16&adults=2&page=1
200 OK
{
"results": [
{ "hotel_id": "HTL-204", "from_price": { "amount": 6500, "currency": "INR", "per": "night" },
"availability_as_of": "2026-10-19T09:14:00+05:30" }
],
"page": 1,
"cache_freshness_seconds": 42
}Full-stay availability
GET /v1/hotels/HTL-204/availability?checkin=2026-11-14&checkout=2026-11-16&adults=2
200 OK
{
"room_types": [
{ "room_type_id": "RT-DLX",
"nights": [ { "date": "2026-11-14", "available": 3 }, { "date": "2026-11-15", "available": 1 } ],
"stay_available": true,
"restrictions": ["min_stay_2_nights"] }
]
}Quote
POST /v1/quotes
{ "hotel_id": "HTL-204", "room_type_id": "RT-DLX", "rate_plan_id": "RP-FLEX",
"checkin": "2026-11-14", "checkout": "2026-11-16", "adults": 2 }
201 Created
{
"quote_id": "QTE-1", "price_version": 7,
"nightly": [ { "date": "2026-11-14", "amount": 6500 }, { "date": "2026-11-15", "amount": 6500 } ],
"subtotal": { "amount": 13000, "currency": "INR" },
"taxes": [ { "name": "GST", "rate": 0.12, "amount": 1560 } ],
"total": { "amount": 14560, "currency": "INR" },
"cancellation_policy": "free_cancellation_until_24h_before_checkin",
"expires_at": "2026-10-19T09:24:00+05:30"
}Hold
POST /v1/holds
Idempotency-Key: 3f9c2a71-hold
{ "quote_id": "QTE-1" }
201 Created
{ "hold_id": "HLD-1", "status": "ACTIVE", "ttl_seconds": 600, "expires_at": "2026-10-19T09:24:10+05:30" }
409 Conflict
{ "error": "INVENTORY_UNAVAILABLE", "night": "2026-11-15", "available": 0 }Book
POST /v1/bookings
Idempotency-Key: 3f9c2a71-book
{ "hold_id": "HLD-1", "payment_method_token": "tok_…" }
201 Created
{ "booking_attempt_id": "BKA-1", "reservation_id": "RES-1", "status": "CONFIRMED", "payment_intent_id": "PAY-1" }
202 Accepted (same key retried while payment is still in flight)
{ "booking_attempt_id": "BKA-1", "status": "PENDING_PAYMENT" }Cancel and modify
POST /v1/bookings/RES-1/cancel
Idempotency-Key: 3f9c2a71-cancel
200 OK
{ "reservation_id": "RES-1", "status": "CANCELLED",
"refund": { "policy": "free_cancellation_until_24h", "amount": 14560, "status": "PENDING" } }
POST /v1/bookings/RES-1/modify
Idempotency-Key: 3f9c2a71-modify
{ "checkin": "2026-11-15", "checkout": "2026-11-17" }
200 OK
{ "reservation_id": "RES-1", "status": "CONFIRMED", "version": 2, "modification_id": "MOD-1",
"released_nights": ["2026-11-14"], "added_nights": ["2026-11-16"],
"price_delta": { "amount": 0, "currency": "INR" } }Hotel-side restriction
PUT /v1/hotels/HTL-204/room-types/RT-DLX/restrictions
{ "date": "2026-11-20", "restriction_type": "STOP_SELL", "reason": "renovation" }
200 OK
{ "restriction_type": "STOP_SELL", "date": "2026-11-20", "applied_at": "2026-10-19T09:00:00+05:30" }Error taxonomy
| Status | Code | When |
|---|---|---|
| 400 | INVALID_REQUEST | Malformed or impossible request (checkout before checkin, zero occupancy) |
| 404 | NOT_FOUND | Quote, hold or reservation does not exist or does not belong to the caller |
| 409 | INVENTORY_UNAVAILABLE | A night failed its conditional allocation; the response names the night |
| 409 | IDEMPOTENCY_KEY_CONFLICT | Key reused with a different request fingerprint |
| 410 | QUOTE_EXPIRED / HOLD_EXPIRED | Quote or hold past its deadline; client must re-quote or re-hold |
| 422 | RESTRICTION_VIOLATED | Stop-sell, minimum stay or closed-to-arrival blocks the stay although counts allow it |
Stage 0 — The Naive Design and Where It Breaks
Start with the simplest design that meets the functional requirements, then let concrete limits justify each addition. Skipping straight to the final architecture hides the reasoning that makes it defensible.
One application service and one relational database holding hotels, room types, rates, room_night_inventory and reservations. Search is a SQL query over hotels and inventory for the requested dates. Booking reads each requested night, checks that it has a free unit, then writes:
-- naive booking: read, decide in the application, then write
SELECT stay_date, sellable - held - confirmed AS available
FROM room_night_inventory
WHERE hotel_id = 'HTL-204' AND room_type_id = 'RT-DLX'
AND stay_date IN ('2026-11-14', '2026-11-15');
-- application: if every night has available >= 1, then for each night:
UPDATE room_night_inventory SET confirmed = :confirmed_read + 1
WHERE hotel_id = 'HTL-204' AND room_type_id = 'RT-DLX' AND stay_date = :night;
INSERT INTO reservation (...) VALUES (...);For a handful of hotels and bookings a day this is the right amount of infrastructure, and the logic looks correct on a whiteboard. It breaks along four lines as traffic and concurrency grow:
Search and booking share one database
At ~1,800 search QPS (~5,400 availability reads), date-range aggregation queries compete with booking writes for the same primary, connections and buffer pool. Browsing traffic that does not need exact numbers slows down the path that does. → Stage 1.
Check-then-act is not atomic
Two requests can both read available = 1 for the last room-night and both write. → Stage 2 explains the anomaly; Stage 3 fixes it.
Nights are allocated independently
If night two fails after night one was written, the system is left with a partial stay — or must hand-write compensation for it. → Stage 3.
Booking and payment are one blind step
There is no time-bounded claim while the traveler pays, no durable record of a payment attempt, and no way to tell after a crash whether the card was charged. → Stages 4-7.
Stage 1 — Separate Search from Booking Authority
Problem: search is about 40 times the booking volume, and it tolerates a minute of staleness; booking needs exact numbers but is small. Serving both from one database makes the critical path pay for the browsing path.
Simplest options first. Read replicas move search load off the primary but keep the same normalized row layout, so every query still aggregates per-night rows across thousands of properties, and replica lag is unbounded rather than contractual. A cache-aside TTL cache in front of search queries helps hot queries, but date-range × occupancy × filter combinations have a low hit rate and misses still land on the database. A periodic full rebuild of a search index from 60M rows is simple but slow and wasteful, since only a tiny fraction of rows change between rebuilds.
Chosen: an incrementally maintained projection. Every transaction that changes a room-night (hold, confirmation, expiry, cancellation, hotel adjustment) also inserts an outbox row in the same transaction. An outbox relay delivers those events to a projector, which updates only the affected (hotel, room type, night) entries in a search-optimized availability read model — a search index plus an in-memory store (~1.2 GB), combined with cached rates. The freshness target is a projection lag of at most ~60 seconds, monitored as a metric, not a rebuild interval.
The staleness contract
| Situation | What the traveler sees | Cost | Why it is acceptable |
|---|---|---|---|
| Stale positive: the last unit sold seconds ago | "1 Deluxe room left" | A failed hold: 409 INVENTORY_UNAVAILABLE naming the night | The hold re-checks the authoritative row, so it is one wasted attempt, never a double booking |
| Stale negative: a hold expired or a booking was cancelled seconds ago | "Sold out" | A missed sale until the projection catches up | A revenue cost bounded by the lag, not a correctness bug |
| Lag exceeds the SLO | Older availability_as_of | More stale positives and negatives | Alert on lag; search keeps serving rather than failing |
What changes in the request flow: an API gateway routes search, property pages and availability to the Search Service, which reads only the read model and returns availability_as_of. Holds and bookings go to the booking path, which never reads the projection to decide anything. The outbox relay introduced here is reused later for notifications and refunds.
Remaining limit: the booking path is still read → check → write against the primary. The next stage shows what happens when two requests do that for the same last room-night.
Stage 2 — The Last-Room Race and the Lost Update
Problem: two requests want the same last room-night at the same moment. With read → check → write, both can succeed, and the database may not even record that anything went wrong.
Request A and request B both want the running example's 2-night Deluxe stay. Night 11-15 has confirmed = 4 of sellable = 5. Each request runs the naive code from Stage 0:
| Time | Request A | Request B | Row 11-15 (confirmed / sellable) |
|---|---|---|---|
| t1 | Reads 11-15: available = 1 | 4 / 5 | |
| t2 | Reads 11-15: available = 1 | 4 / 5 | |
| t3 | Writes confirmed = 5 (4 + 1), inserts its reservation, commits | 5 / 5 | |
| t4 | Writes confirmed = 5 (its own 4 + 1), inserts its reservation, commits | 5 / 5 |
Two reservations now exist for one room, yet the ledger says confirmed = 5: B's write overwrote A's. This is a lost update — each transaction computed a new value from a read that was stale by the time it wrote. The oversell is invisible to every counter and is discovered when two guests arrive. Writing confirmed = confirmed + 1 instead fixes the arithmetic but not the decision: the row then reads 6 / 5 — at least visibly oversold, but oversold.
The root cause is that the decision ("is a unit free?") was made in application memory, between two statements. Whether a stronger isolation level rescues it depends on the engine: PostgreSQL's REPEATABLE READ aborts the second writer of the same row with a serialization error, while MySQL InnoDB's default REPEATABLE READ lets the UPDATE proceed against the latest row. A correctness rule should not depend on that detail.
Multi-night stays make it worse
Independent per-night writes add a second failure: request B's write on 11-14 can succeed while its 11-15 write fails (or its process crashes between them), leaving a reservation for one night of a two-night stay, or an inventory decrement that no reservation explains.
Candidate fixes
| Approach | How it works | Trade-off |
|---|---|---|
Lock then check (SELECT … FOR UPDATE) | Lock each night's row, check in the application, then update | Correct, but two round trips per night while holding locks; needs a lock order |
| Conditional update (compare-and-set in the WHERE clause) | UPDATE … SET held = held + 1 WHERE … AND sellable - held - confirmed >= 1; 0 rows affected means no unit | One statement per night; the database evaluates the condition under the row lock |
| Optimistic version column | Read version, write WHERE version = :v, retry on conflict | Correct, but retry storms on a hot row; every unrelated change to the row causes a conflict |
| SERIALIZABLE isolation | Let the database detect the anomaly and abort | Correct, but aborts and retries under contention; behavior varies by engine |
| Distributed lock or counter in Redis | Serialize access outside the database | Not the system of record; a crash between Redis and the database leaves them disagreeing |
Next: Stage 3 adopts the conditional update, applied to every night of the stay in one transaction.
Stage 3 — Atomic Multi-Night Allocation
Move the decision into the write: one conditional UPDATE per night, issued in ascending date order inside one transaction, with the stay committed only if every night matched.
BEGIN;
-- one statement per night, sorted by stay_date ascending (whatever order the request listed them)
UPDATE room_night_inventory SET confirmed = confirmed + 1
WHERE hotel_id = 'HTL-204' AND room_type_id = 'RT-DLX' AND stay_date = '2026-11-14'
AND sellable - held - confirmed >= 1; -- rows_affected must be 1
UPDATE room_night_inventory SET confirmed = confirmed + 1
WHERE hotel_id = 'HTL-204' AND room_type_id = 'RT-DLX' AND stay_date = '2026-11-15'
AND sellable - held - confirmed >= 1; -- rows_affected must be 1
-- application: if any statement affected 0 rows -> ROLLBACK, return 409 naming that night
INSERT INTO reservation (...) VALUES (...);
INSERT INTO reservation_night (...) VALUES (...), (...);
COMMIT;Why it is correct
Each UPDATE takes the row lock, then evaluates its WHERE clause against the latest committed version of the row (PostgreSQL re-checks the condition after waiting on a lock; InnoDB updates use a locking "current" read). So when A and B race for 11-15, B waits for A's transaction, re-evaluates sellable - held - confirmed >= 1 against A's committed result, and matches 0 rows. This works at ordinary READ COMMITTED; no application-side read is involved.
Why one statement per night, in ascending date order
A single UPDATE … WHERE stay_date IN (…) is shorter, but the database does not promise the order in which it locks the rows. If request X locks 11-14 then waits for 11-15 while request Y locks 11-15 then waits for 11-14, they deadlock. Sorting the nights and issuing one statement per night gives every transaction the same global lock order, which makes that cycle impossible instead of detecting and retrying it. It also lets the application check rows_affected per night and name the blocking night in the 409.
All nights or none
The application sums rows_affected and requires it to equal the number of nights. Any 0 rolls back the whole transaction, including nights that had matched, so no partial stay can survive.
The CHECK constraint as a backstop
CHECK (held + confirmed <= sellable) (Chapter 06) means even a buggy code path that forgets the WHERE guard, or a bulk admin script, cannot commit an oversell. It also rejects a capacity reduction below what is already held or sold (Stage 9).
Why not a Redis lock or counter instead
Redis can decrement atomically, but it is not the system of record: a crash between the Redis decrement and the reservation write leaves them disagreeing with no transactional way back. A distributed lock serializes a critical section but does not make the read-modify-write durable, and a lock lease can expire during a GC pause while the holder keeps writing. A per-(hotel, room type) lock or queue in front of the database is still useful under extreme contention to shed load — as an optimization. Remove it and the system gets slower, never incorrect.
What changes architecturally: an Inventory Service becomes the only component allowed to change room-night counts. Every path that allocates or releases inventory — booking, expiry, cancellation, modification, hotel adjustments — goes through its ordered, conditional operations.
Remaining limit: allocation still happens at click time, before payment. If the card is declined or the traveler abandons the payment page, a reservation exists that nobody paid for; if payment is taken first, a traveler can be charged for a room that sells out while they type. Stage 4 separates claiming from selling.
Stage 4 — Holds: A Temporary Claim While the Traveler Pays
Problem: paying takes time — entering card details, 3-D Secure, a provider round trip — and can fail. The inventory decision and the payment outcome cannot happen at the same instant, so something must protect the room-nights in between, and that protection must end on its own.
| Option | What goes wrong |
|---|---|
| Book at click time, then charge | A declined or abandoned payment leaves a CONFIRMED reservation that must be undone; "confirmed" stops meaning "paid" |
| Charge first, then allocate | The last unit can sell while the traveler pays; the system must refund a charge for a room it never had |
| Hold, then charge, then confirm | Room-nights are reserved for one traveler for a bounded time; an unfinished payment simply lets the hold expire |
A hold is a durable, expiring claim: an inventory_hold row plus held + 1 on every night of the stay, created by the same ordered conditional transaction as Stage 3. Holds count against availability (available = sellable - held - confirmed), so no one else can take those nights, but they are not sales.
BEGIN;
-- restrictions for these nights are read here too (Stage 9)
UPDATE room_night_inventory SET held = held + 1
WHERE hotel_id = 'HTL-204' AND room_type_id = 'RT-DLX' AND stay_date = '2026-11-14'
AND sellable - held - confirmed >= 1; -- must affect 1 row
UPDATE room_night_inventory SET held = held + 1
WHERE hotel_id = 'HTL-204' AND room_type_id = 'RT-DLX' AND stay_date = '2026-11-15'
AND sellable - held - confirmed >= 1; -- must affect 1 row
INSERT INTO inventory_hold (hold_id, hotel_id, room_type_id, check_in, check_out, quote_id, state, expires_at)
VALUES ('HLD-1', 'HTL-204', 'RT-DLX', '2026-11-14', '2026-11-16', 'QTE-1', 'ACTIVE', now() + interval '600 seconds');
INSERT INTO outbox_event (...) VALUES (... 'hold.created' ...); -- feeds the read model
COMMIT;TTL: 600 seconds
Long enough to enter card details and complete 3-D Secure; short enough that abandoned checkouts return scarce inventory quickly. It mirrors the 10-minute quote lifetime, and the hold records the quote it was created from, so the traveler pays the price they were shown.
Expiry worker
A TTL is a business rule enforced by a worker, not a database feature. Worker instances select ACTIVE holds past expires_at from an index on (state, expires_at) in bounded batches with FOR UPDATE SKIP LOCKED, so several instances never claim the same hold. For each one, it flips the hold to EXPIRED and only then releases the nights, in ascending date order, in one transaction. (Stage 6 adds one exception: holds whose payment is still in flight.)
BEGIN;
UPDATE inventory_hold SET state = 'EXPIRED'
WHERE hold_id = 'HLD-1' AND state = 'ACTIVE'; -- 0 rows: already consumed or released -> ROLLBACK, do nothing
UPDATE room_night_inventory SET held = held - 1
WHERE hotel_id = 'HTL-204' AND room_type_id = 'RT-DLX' AND stay_date = '2026-11-14' AND held > 0;
UPDATE room_night_inventory SET held = held - 1
WHERE hotel_id = 'HTL-204' AND room_type_id = 'RT-DLX' AND stay_date = '2026-11-15' AND held > 0;
INSERT INTO outbox_event (...) VALUES (... 'hold.expired' ...);
COMMIT;Fencing on the hold's own state
Expiry and confirmation can race at the last second. Both begin with a compare-and-set on the hold row's state: confirmation flips ACTIVE → CONSUMED, expiry flips ACTIVE → EXPIRED. Whichever commits first wins; the other matches 0 rows and stops. Two tempting alternatives are wrong. Fencing on "held > 0" alone could release a unit that now belongs to a newer hold. Fencing on a version column of the shared inventory row would make every unrelated hold or booking on that night look like a conflict. The hold row is the only thing both parties uniquely share.
What holds cost
A hold takes inventory off sale for up to its TTL even if the traveler leaves. On a scarce night, one abandoned hold hides the last room from everyone for 10 minutes — a stale negative created on purpose. Bots and indecisive travelers can hoard. Mitigations: release the hold explicitly when the traveler goes back or closes checkout; allow one active hold per traveler per stay; rate-limit hold creation; and consider shorter TTLs for very scarce inventory. Track the abandoned-hold rate and the room-nights it ties up.
What changes in the request flow: quote (Pricing/Quote Service, from cached DailyRate) → hold (Inventory Service, conditional per-night held + 1) → book. The Pricing/Quote Service is read-only; it never touches inventory.
Remaining limit: a booking now spans several steps and an external call, and each can fail separately. "Is this booking done?" no longer has a yes/no answer. Stage 5 names the states explicitly.
Stage 5 — The Booking State Machine
A booking is a lifecycle, not a boolean. Naming every state — including the awkward ones a crash can produce — is what lets each later mechanism say exactly which transition it guards.
| State | Meaning | Inventory effect | Leaves via |
|---|---|---|---|
| QUOTED | A priced quote exists | None | Hold, or quote expires (no side effects) |
| HELD | Hold ACTIVE, TTL running | held + 1 per night | Booking attempt starts, or TTL → EXPIRED |
| PENDING_PAYMENT | Booking attempt recorded; authorization/capture in flight | Still held; expiry deferred up to auth_timeout (60 s) | Capture + confirmation, decline, or crash |
| CONFIRMED | Reservation committed | held - 1, confirmed + 1 per night | Cancellation or modification |
| EXPIRED | Hold TTL elapsed before confirmation | held - 1 per night | Terminal |
| PAYMENT_FAILED | Provider declined | Hold released (or kept for a retry with another card while still ACTIVE) | Terminal for this attempt |
| CANCELLED | Confirmed reservation cancelled | confirmed - 1 per night; refund tracked separately | Terminal |
| PAYMENT_CAPTURED_BOOKING_FAILED | Money captured, confirmation never committed | Hold still ACTIVE or already EXPIRED | Reconciliation picks it up |
| RECONCILING | Worker is resolving the gap | Unchanged until resolved | CONFIRMED (hold still ACTIVE) or REFUNDED |
| REFUNDED | Captured money returned, no reservation | None | Terminal |
Where the states live
The lifecycle is spread over rows that already exist: inventory_hold.state (ACTIVE, CONSUMED, EXPIRED, RELEASED), booking_attempt.state (PENDING_PAYMENT, COMPLETED, FAILED, and the reconciliation states) and reservation.state with a version (CONFIRMED, CANCELLED). The diagram is their combined view.
Guarded transitions
Every transition is a conditional update on the expected current state: UPDATE … SET state = :to WHERE id = :id AND state = :from (plus AND version = :v for reservations). Zero rows affected means another actor moved first — a stale webhook, a duplicate retry, a racing cancellation — and the caller stops instead of overwriting. "Last write wins" becomes "only the write that matches reality wins".
MODIFIED is a transition, not a resting state
A successful modification (Stage 8) leaves the reservation CONFIRMED, increments its version and writes a modification record with the old and new nights. Because it stays CONFIRMED, every later guarded transition — cancel, modify again — works unchanged.
Remaining limit: PENDING_PAYMENT spans an external, slow, sometimes ambiguous provider call. Stage 6 decides the order of operations, where transactions begin and end, and how retries avoid double charges.
Stage 6 — Payment Coordination: Ordering, Idempotency and the Outbox
Problem: the booking must coordinate a database it controls with a payment provider it does not, without a distributed transaction and without ever holding a database lock while waiting on the network.
Ordering: hold → attempt → authorize → capture → confirm
The Booking Orchestrator sequences the steps. (1) The hold already exists. (2) It durably records a booking attempt. (3) It authorizes the amount against the held stay — funds reserved, no money moved. (4) It captures. (5) It runs the confirmation transaction. Holding before paying means a traveler is never charged for nights that were not claimable; confirming only after capture means a confirmed reservation always has money behind it.
The alternative order, authorize → confirm → capture, shrinks the crash window (a failed capture on an already-confirmed reservation is cancelled and the authorization voided), and it is the natural fit for pay-at-hotel or deferred-capture rate plans. For prepaid rates this design captures first and accepts reconciliation (Stage 7) as the price of never confirming unpaid stays; Trade-offs & Decisions compares the two.
Three short transactions, never one long one
T1 — hold (Stage 4). T2 — booking attempt, inserted before any provider call. T3 — confirmation, after capture. The provider calls happen between transactions, so no row lock is ever held across the network; on a hot room-night that would serialize every other traveler behind one slow card. Each transaction is short, local and ACID; the state machine and the idempotency key tie the sequence together.
BEGIN; -- T3: confirmation, all in the hotel's shard
UPDATE inventory_hold SET state = 'CONSUMED'
WHERE hold_id = 'HLD-1' AND state = 'ACTIVE'; -- fence: must affect 1 row, else ROLLBACK -> reconciliation/refund
UPDATE room_night_inventory SET held = held - 1, confirmed = confirmed + 1
WHERE hotel_id = 'HTL-204' AND room_type_id = 'RT-DLX' AND stay_date = '2026-11-14' AND held > 0;
UPDATE room_night_inventory SET held = held - 1, confirmed = confirmed + 1
WHERE hotel_id = 'HTL-204' AND room_type_id = 'RT-DLX' AND stay_date = '2026-11-15' AND held > 0;
INSERT INTO reservation (reservation_id, hold_id, state, version) VALUES ('RES-1', 'HLD-1', 'CONFIRMED', 1);
INSERT INTO reservation_night (reservation_id, stay_date) VALUES ('RES-1', '2026-11-14'), ('RES-1', '2026-11-15');
UPDATE booking_attempt SET state = 'COMPLETED', reservation_id = 'RES-1' WHERE booking_attempt_id = 'BKA-1';
INSERT INTO outbox_event (event_id, event_type, payload) VALUES ('EVT-1', 'reservation.confirmed', '{…}');
COMMIT;Idempotent booking attempts
CREATE TABLE booking_attempt (
booking_attempt_id TEXT PRIMARY KEY, -- BKA-1
principal_id TEXT NOT NULL, -- keys are scoped per caller...
endpoint TEXT NOT NULL, -- ...and per endpoint
idempotency_key TEXT NOT NULL,
hold_id TEXT NOT NULL,
request_fingerprint TEXT NOT NULL, -- hash(hold_id, amount, payment_method_token)
state TEXT NOT NULL, -- PENDING_PAYMENT | COMPLETED | FAILED | ...
reservation_id TEXT,
created_at TIMESTAMPTZ NOT NULL,
UNIQUE (principal_id, endpoint, idempotency_key)
);
-- at most one in-flight attempt per hold, so a retry with a NEW key cannot start a second payment
CREATE UNIQUE INDEX one_pending_attempt_per_hold ON booking_attempt (hold_id) WHERE state = 'PENDING_PAYMENT';The orchestrator inserts the attempt and lets the unique constraint decide, rather than reading first: two retries landing on two servers within the same millisecond cannot both win the insert. On a unique violation it loads the existing attempt. Same fingerprint and COMPLETED → return the original result. Same fingerprint and still PENDING_PAYMENT → 202, without re-authorizing. Different fingerprint → 409 IDEMPOTENCY_KEY_CONFLICT. The partial index closes the remaining gap: a client that retries with a new key for the same hold cannot start a second payment. Calls to the provider carry a provider idempotency reference derived from booking_attempt_id, so a retried authorize or capture resolves to the same charge.
Payment versus the hold's TTL
Authorization runs while the hold's clock is ticking. The expiry worker therefore defers any hold whose booking attempt is PENDING_PAYMENT and younger than auth_timeout = 60 seconds (the booking attempt stamps payment_started_at on the hold, so the worker can decide with a single-row read). A provider slower than that is abandoned locally: the attempt fails, and the hold may then expire normally. If that provider later reports a capture anyway, the confirmation's hold fence matches 0 rows and the money is refunded (Stage 7). Deferral removes the common race; the fence makes the rare one safe.
Transactional outbox
The reservation.confirmed event is written inside T3, so "committed but never announced" cannot happen. The outbox relay from Stage 1 publishes it at least once to an event bus; consumers — notifications, the availability projector, refunds — deduplicate by event_id, so a relay that crashes after publishing and republishes on restart still produces one email.
Remaining limit: one window is still open. If the process dies after the provider captures but before T3 commits, money has moved and no reservation exists.
Stage 7 — Crash Recovery and Reconciliation
Problem: the provider captured the payment, then the orchestrator crashed before the confirmation transaction committed. The system must neither charge again nor pretend the booking exists.
After the crash, the database shows an attempt in PENDING_PAYMENT, a hold that is still ACTIVE (or about to expire), and no reservation. From the inside, that looks the same as a payment that is merely slow. Guessing in either direction is wrong: retrying with a new authorization can charge twice; writing a confirmation without checking the hold can oversell.
Detection
A Reconciliation Worker sweeps attempts that have been PENDING_PAYMENT longer than a threshold beyond auth_timeout, and queries the provider by the attempt's stable reference. A slower second sweep compares the provider's settlement export against attempts, catching captures whose attempt was already marked failed.
Resolution
Provider says CAPTURED → the attempt moves to PAYMENT_CAPTURED_BOOKING_FAILED, then RECONCILING, and the worker runs the same T3 as the orchestrator. If the hold fence still matches (hold ACTIVE), the booking becomes CONFIRMED. If the hold already EXPIRED or was released, the nights may belong to someone else, so the worker refunds and the booking ends REFUNDED. (A product may first try a fresh allocation of the same nights before refunding; that is a policy choice, never a reason to skip the fence.) Provider says declined / not found → the attempt is FAILED and the hold is released through the same guarded transition.
Repairs are idempotent
The worker uses the same guarded transitions and idempotency references as the online path, and every repair action carries a stable action id. Re-running a sweep cannot confirm twice or refund twice.
Client retries during recovery
A traveler who retries with the same Idempotency-Key meanwhile gets 202 PENDING_PAYMENT for the existing attempt, then the final outcome once reconciliation resolves it — never a new charge.
Sweep cadence is an SLO decision: minutes for the PENDING_PAYMENT sweep (bounded by the durability and support goals), and the provider's export schedule for the settlement comparison. Remaining limit: confirmed reservations still change after the fact — travelers cancel and change dates.
Stage 8 — Cancellation and Modification
Cancellation releases inventory and starts a refund as two separate operations. Modification acquires the new nights before releasing the old ones, because a payment step sits in the middle.
Cancellation
A guarded transition CONFIRMED → CANCELLED (WHERE state = 'CONFIRMED' AND version = :v) in the same transaction as confirmed - 1 on each night, in ascending date order, plus a refund.requested outbox event. The refund amount follows the rate plan (RP-FLEX: full refund until 24 hours before check-in; RP-SAVER: zero). Releasing inventory never depends on refund eligibility, and a failing or retried refund never reopens the cancellation: the refund is its own record, retried idempotently against the provider. Because modification keeps a reservation CONFIRMED, cancel-after-modify works without special cases.
Cancellation versus confirmation races
A duplicate confirmation replay arriving after a cancellation matches 0 rows on its guarded transition and stops; a second cancellation request matches 0 rows too. Neither can resurrect or double-release a reservation.
Modification as a saga
Moving the running example from nights 11-14/11-15 to 11-15/11-16 drops 11-14 and adds 11-16; 11-15 is unchanged. Step 1: hold the added night (HLD-3) through the normal conditional allocation, checking restrictions. Step 2: settle any price difference with the provider (capture the extra amount or refund the difference). Step 3: one transaction swaps the nights. If step 1 fails, nothing changed. If step 2 fails, the compensation is releasing HLD-3. The original nights are released only in step 3. Releasing first would trade a guaranteed room for a chance at one, and a concurrent booking could take the new night in between.
BEGIN; -- step 3 of the modification saga
UPDATE reservation SET version = version + 1
WHERE reservation_id = 'RES-1' AND state = 'CONFIRMED' AND version = 1; -- must affect 1 row
UPDATE inventory_hold SET state = 'CONSUMED' WHERE hold_id = 'HLD-3' AND state = 'ACTIVE'; -- must affect 1 row
-- ascending stay_date across all touched nights
UPDATE room_night_inventory SET confirmed = confirmed - 1
WHERE hotel_id = 'HTL-204' AND room_type_id = 'RT-DLX' AND stay_date = '2026-11-14'; -- dropped night
UPDATE room_night_inventory SET held = held - 1, confirmed = confirmed + 1
WHERE hotel_id = 'HTL-204' AND room_type_id = 'RT-DLX' AND stay_date = '2026-11-16' AND held > 0; -- added night
-- replace reservation_night rows, record the change, announce it
INSERT INTO reservation_modification (modification_id, reservation_id, old_nights, new_nights, new_version)
VALUES ('MOD-1', 'RES-1', '{2026-11-14,2026-11-15}', '{2026-11-15,2026-11-16}', 2);
INSERT INTO outbox_event (...) VALUES (... 'reservation.modified' ...);
COMMIT;Restrictions apply to modifications
A modified stay is re-validated like a new one: extending into a stop-sold date or shortening below a minimum stay fails with 422 RESTRICTION_VIOLATED even when counts allow it.
Non-refundable rates
Under RP-SAVER, cancellation still releases the nights, but the refund resolves to zero. Whether such a booking may be modified (same rate plan only, or as cancel-and-rebook at the new price) is a rate-plan policy enforced by the state machine, not an ad-hoc decision in the orchestrator.
Remaining limit: so far only the platform writes to the ledger. Hotels change capacity, rates and restrictions themselves, often through a PMS that also sells on other channels.
Stage 9 — Hotel-Side Inventory, Restrictions and PMS Sync
Problem: the booking path is no longer the only writer. Hotel staff edit capacity, rates and restrictions in an extranet, and a property-management system (PMS) or channel manager pushes updates because the hotel also sells elsewhere. The system must say precisely what each writer may change and who wins when they disagree.
Rates
Rate changes update DailyRate and bump the price version; they never touch inventory rows. Existing quotes keep their price until they expire; the price is locked onto the hold when the hold is created, so a quote that has expired is rejected at hold creation with 410 QUOTE_EXPIRED and the guest re-quotes; a live hold keeps its price.
Restrictions are rows, not counts
Stop-sell, minimum stay and closed-to-arrival are Restriction rows read inside the hold transaction; a violation returns 422 RESTRICTION_VIOLATED. A stop-sell never sets sellable = 0: that would mix "rooms that physically exist" with "rooms we choose not to sell", be overwritten by the next capacity sync, and lose the reason. Existing reservations on a stop-sold date are untouched.
Capacity changes
Taking rooms out of order lowers sellable through the Inventory Service. If the new value would fall below held + confirmed, the CHECK constraint rejects it; the change goes to operations (relocate guests, or wait for holds to expire) rather than silently overselling.
PMS sync ordering
The PMS connector receives batches carrying a monotonic cursor per hotel. In one transaction it checks cursor > last_applied_cursor, applies the batch as InventoryAdjustment and restriction changes, and advances the cursor. A late or redelivered batch with an older cursor is rejected as stale instead of overwriting newer state, and replaying the same batch is a no-op.
Who wins
For bookings made on this platform, the platform ledger is authoritative. A PMS-reported capacity becomes an adjustment to sellable; if it would push sellable below held + confirmed, it is not applied and is flagged for reconciliation, because it means the two systems disagree about rooms already sold. Sales on other channels reach the platform only as later adjustments, so a cross-channel oversell window exists; it is bounded by sync latency and handled by the hotel, not eliminated by this design.
Onboarding a hotel publishes its room types, rate plans and an initial 400-day window of inventory and rates as one idempotent, resumable adjustment batch keyed by an onboarding cursor, so a retried import cannot create duplicate room-night rows. Every adjustment writes an outbox event, so hotel-side changes reach the search read model through the same projection as bookings.
Remaining limit: everything works on one primary, but one very popular hotel can still saturate shared connections and locks for every other hotel, and a regional failure takes the booking path down.
Stage 10 — Scaling: Hot Rows, Sharding, Replicas and Regions
Throughput is not the reason to scale the booking database: about 135 short write transactions per second fit a single primary (Chapter 05). The reasons are hot properties, blast radius and regional failure.
Hot rows
One conference can put 50-200 allocation attempts on (HTL-204, RT-DLX, 2026-11-15) within seconds. More application servers or more shards do not help: it is one row and one lock. What helps: keep T1 and T3 to a few milliseconds with no network calls inside; fail fast — the loser gets a quick 409 rather than a retry loop; optionally admit requests per (hotel, room type) through a short queue or lock in front of the database to shed load before it reaches the row; and for flash sales, a waiting room.
Sharding by hotel_id — for blast radius
All of a hotel's inventory, holds, attempts, reservations, outbox rows and sync cursor live on one shard, so every booking transaction stays local and the confirmation never needs cross-shard coordination. A spike at one hotel exhausts at most its own shard's connections and lock queues. Queries that cross hotels (a traveler's booking history, platform reporting) are served from projections keyed differently, such as by traveler, rather than by scatter-gather across shards.
Replicas
Each shard has an in-region synchronous standby for failover (RPO ≈ 0 for committed reservations within the region) and asynchronous read replicas for "my bookings" pages and operations dashboards. Allocation decisions never read a replica. A traveler who just booked reads their reservation from the primary (read-your-writes).
Search scales separately
The Search Service and read model scale horizontally and can be partitioned by destination or region; a search outage degrades discovery while booking stays correct, and a booking-database failover does not take search down.
Regional failure
Cross-region replication is asynchronous, so a region failover can lose the last seconds of commits (RPO > 0): a hold or even a confirmation committed in the failed region may be missing in the new one. During promotion the booking path fails closed. Afterwards, reconciliation compares payment-provider captures and PMS records against the promoted database and repairs any confirmations that were captured but lost. "Never oversold" therefore holds for the ledger that survives; it is not an absolute claim across a regional disaster.
Final Production Architecture and Request Flows
Every component below was added at a specific stage to remove a specific limit. The diagram is the Stage 0 whiteboard with all of those additions in place.
| Component | Responsibility | Stage |
|---|---|---|
| API Gateway | Authentication, rate limits, routes search vs. booking vs. hotel traffic | 1 |
| Search Service + Availability Read Model | Destination/date/occupancy search and per-night availability; eventually consistent (lag ≤ ~60 s) | 1 |
| Outbox Relay + projector | Publishes outbox events at least once; updates the read model; feeds notifications and refunds | 1, 6 |
| Inventory Service | The only writer of room-night counts: ordered conditional allocation, release, restriction checks | 3, 9 |
| Pricing / Quote Service | Reproducible, versioned quotes from cached DailyRate; never touches inventory | 4 |
| Expiry Worker | Expires ACTIVE holds past TTL with a state fence; defers holds with an attempt younger than 60 s | 4, 6 |
| Booking Orchestrator | hold → attempt → authorize → capture → confirm; owns booking attempts and idempotency | 6 |
| Payment Service | Authorize/capture/refund with provider idempotency references; tokenized, out of PCI scope | 6 |
| Reconciliation Worker | Resolves stuck attempts and provider/ledger mismatches to CONFIRMED or REFUNDED | 7 |
| Notifications | Confirmation and change messages from events, deduplicated by event_id | 6 |
| Hotel Extranet + PMS Connector | Rates, restrictions and capacity; PMS batches applied in per-hotel cursor order | 9 |
| Booking DB (sharded by hotel_id) | Inventory, holds, attempts, reservations, outbox and cursors in one local transaction scope; sync standby + read replicas | 0, 10 |
Request flows
Search. Gateway → Search Service → read model. Results carry availability_as_of; the property page shows per-night availability for the whole stay from the same projection. A quote comes from the Pricing/Quote Service with a price_version and a 10-minute expiry.
Book. POST /holds → Inventory Service runs T1: check restrictions, one conditional held + 1 per night in ascending date order, insert the hold and an outbox event → 201 HLD-… or 409/422. POST /bookings with an Idempotency-Key → Booking Orchestrator inserts the attempt (T2) → Payment Service authorizes, then captures → T3 fences the hold ACTIVE → CONSUMED, moves each night from held to confirmed, writes the reservation, completes the attempt and inserts reservation.confirmed → 201. The relay then drives the notification and the read-model update. A retry with the same key returns the same attempt's result.
Background. The Expiry Worker releases abandoned holds through the same fence; the Reconciliation Worker resolves attempts left in PENDING_PAYMENT by a crash or a slow provider.
Cancel and modify. Cancellation is a guarded CONFIRMED → CANCELLED with per-night releases and a separately tracked refund. Modification holds the added nights, settles the price difference, then swaps nights in one transaction, leaving the reservation CONFIRMED with version + 1.
Hotel sync. Extranet edits and PMS batches → Inventory Service (cursor check, adjustment or restriction rows, CHECK constraint) → outbox → read model. Conflicts that imply rooms already sold go to reconciliation.
Guarantees and Limitations
A precise boundary is a stronger answer than a broad claim. Here is what the design guarantees, and where it deliberately stops.
- Guarantees that the platform's ledger never records more held plus confirmed units than sellable for any room-night: ordered conditional updates enforce it and the CHECK constraint backs it up
- Guarantees that a multi-night stay is held and confirmed all nights or none, within one local transaction on the hotel's shard
- Guarantees idempotent holds, bookings, cancellations and modifications: a retry with the same key and request returns the original result; a reused key with a different request is rejected; one pending payment per hold
- Guarantees that hold expiry and confirmation cannot both win: each fences on the hold's state
- Guarantees that payment capture and confirmation converge after a crash — to CONFIRMED or to a refund — via the durable booking attempt and reconciliation; payment success alone is never treated as a booking
- Does NOT guarantee that search results are current: they may lag about 60 seconds, and every hold re-verifies against the authoritative rows
- Does NOT guarantee a specific physical room before check-in, only a room type
- Does NOT prevent cross-channel oversells that originate outside the platform: PMS sync is a separate boundary, ordered by cursors, with conflicts routed to reconciliation
- Does NOT make cross-region failover lossless: asynchronous replication means the newest commits may need repair through reconciliation with payment and PMS records
- Does NOT model deliberate overbooking: a platform that oversells by a controlled margin must model that policy explicitly
The thread from Chapter 01 runs through every stage: the room-night is the unit of scarcity, a stay is a transaction over several of them, and each component exists to keep that transaction honest under concurrency, retries and failure. Architecture Deep Dive goes into the exact SQL, isolation behavior and worker designs; Trade-offs & Decisions compares the alternatives at each fork; Failure & Scale Scenarios stresses the finished design; Interview Perspective turns it into a whiteboard plan.