The whole design, in writing
Every step of the build above, written out: the problem each piece solves, the option that was taken and the ones that were not, the numbers, and how it fails in production.
The big idea
What is a hotel booking system?
A hundred people are looking at the same last room in the same hotel for the same weekend, at the same moment. Exactly one of them should get it — not zero (a room sits empty because everyone's request errored), and definitely not two (a guest arrives to find their room already occupied). And underneath the excitement of one room, there's a multi-night STAY: three nights need three separate date-rows to all agree to sell.
This is fundamentally an inventory consistency problem wearing a travel-industry costume — the same shape as a flash sale, but keyed by (room type, date) instead of a single SKU, and with the added twist that a booking spans a range of dates that must succeed or fail together. We'll build the naive version, break it, then fix it properly.
What the new pieces do
- Guestclient
- Someone searching a city and date range, then trying to lock in a specific room before someone else does.
Step 1 · The baseline
Read, then decrement
The obvious first implementation: read the current room count for a date, check if it's greater than zero, then write count-1 back. Two guests hit "book" for the last room within milliseconds of each other. What happens?
Read-then-decrement, two nearly-simultaneous requests for the last room. What's the failure mode?
A database serializes individual STATEMENTS, but "read count, then later write count-1" is two separate operations with a gap between them — exactly where a second request can read the SAME stale count before the first one's write lands.
This is the classic check-then-act race: both reads happen before either write, so both see the room as available and both think they succeeded. The hotel now has two confirmed guests for one room — a real, costly failure, not a hypothetical.
Nothing in a naive read-then-decrement makes the second request wait — that kind of serialization has to be explicitly designed in (a lock, or an atomic conditional write), not assumed for free.
Read-then-decrement is a classic check-then-act race: the gap between reading availability and writing the new count is exactly wide enough for two concurrent requests to both see "available" and both succeed, overselling the room. The fix has to make the check and the decrement one atomic operation, not two.
Why this piece earns its place
The conditional decrement fixes the race and quietly introduces a different one: it is not idempotent. The client that sent the booking request does not always find out what happened to it — a gateway timeout, a dropped connection, a phone switching cells mid-request — and the retry that follows looks byte-for-byte like the original. The row gets decremented twice, one guest ends up holding two rooms on a date where the second should have gone back on sale, and nothing anywhere logs an error; inventory just drifts quietly downward. The defense is to give every booking attempt a caller-generated idempotency key and write that key inside the same transaction as the decrement, so a replay collides with a key already recorded and returns the original outcome instead of taking another room. It is the same insight as the atomic write, one level up: the unit that has to happen exactly once is not decrement the counter, it is this specific attempt — and you only get that guarantee if the attempt carries a name the database can recognize the second time it sees it.
What the new pieces do
- Booking APIgateway
- The front door: routes searches to the index, and routes booking attempts to the strongly-consistent inventory path.
- Inventorydata
- The source of truth: how many rooms of each type remain for each specific date. Every write here must be atomic and strongly consistent — this is what "sold out" actually means.
Step 2 · Atomic, and across every night
A 3-night stay needs all 3 nights to agree
Fix the single-date race with an atomic conditional decrement: UPDATE inventory SET count = count - 1 WHERE date = ? AND count > 0, which only succeeds if a room was actually available. Good — but a real booking is a stay, spanning 3 consecutive nights, each its own date-row. What guarantees you don't end up with a room for night 1, but sold-out on night 2?
A 3-night stay touches 3 separate date-rows. What ensures they all succeed together or not at all?
A "partial booking" — a room for night 1 and 3 but not night 2 — isn't a valid product outcome for a hotel stay; it's a bug users would experience as your system randomly failing mid-checkout.
Assuming later nights are available just because the first one was ignores that different nights can have completely different demand and remaining inventory — that assumption will be wrong regularly.
A multi-night stay is only a valid booking if EVERY night is available. Wrapping all the conditional decrements in one database transaction gives you exactly that all-or-nothing guarantee — if night 2 is sold out, the transaction aborts and night 1's decrement (and every other night's) is rolled back too, leaving inventory untouched and the guest cleanly told "not fully available."
Wrap the whole stay in a single atomic transaction: attempt the conditional decrement for every night of the stay together, and commit only if all of them succeed. If any single night is sold out, the entire transaction rolls back — no partial booking, no inventory left in a half-decremented state.
Why this piece earns its place
That transaction changes the concurrency profile in a way worth saying out loud: the transaction now holds locks on several rows at once, for as long as it takes to try all of them. Two overlapping stays — one Friday to Sunday, one Saturday to Monday — can each end up holding a row the other still needs, and the database resolves that standoff by killing one of them. If transactions grab their nights in whatever order they happen to be written, that shows up as bookings failing at random on exactly the busy dates you care about. Acquire the date-rows in a fixed canonical order, ascending by date, and the cycle cannot form: the later stay simply waits behind the earlier one. The related discipline is to keep the transaction to the decrements alone — no payment call, no pricing lookup, no notification inside it — because a long stay already holds more rows for longer than a weekend does, and anything slow inside those boundaries multiplies contention on every date the stay touches.
- 1 txncovers every night of the stay
- all-or-nothingno partial bookings
- rollbackon any single sold-out night
Step 3 · Hold, don't just book
Reserve while the guest finishes checkout
A guest picks a room, but still has to enter payment details — that takes 30-90 seconds, sometimes longer. If you decrement inventory only at the very end (after payment succeeds), someone else could book the same room in the meantime and the guest's card gets charged for a room that's gone. If you decrement immediately and the guest abandons checkout, that room is lost to sale forever.
A guest is mid-checkout for 60 seconds. How do you prevent both overselling AND losing inventory to abandoned carts?
A guest who abandons checkout (closes the tab, gets distracted) would permanently remove a sellable room from inventory — over enough abandoned checkouts, you lose real revenue to rooms that sit empty but "sold" in the system.
A hold is the reserve-then-confirm pattern: inventory is atomically decremented immediately (so no one else can take it), but tagged as a temporary hold with an expiry. If payment completes in time, the hold becomes a real booking. If it times out (or the guest abandons), a background process releases the room back to the sellable pool automatically.
Holding a database lock open for the length of a human filling out a payment form (which could be seconds or minutes, or never finish) ties up the row and blocks every other concurrent booking attempt on that same date — a scalability disaster under real load.
Introduce a Hold Manager: the instant checkout starts, atomically decrement inventory and record a hold with a short TTL (e.g. 10-15 minutes). If payment succeeds, the hold converts to a confirmed booking. If it expires, a background sweep releases the inventory back to the sellable pool. This is the reserve-then-confirm pattern — the same shape used by flash-sale checkouts and ticketing systems.
Why this piece earns its place
The hold introduces exactly one number you have to choose, and both directions of it cost you something real. The 30-90 seconds of checkout above is the typical case, not the tail: a card that triggers a bank verification step, a guest who mistypes and starts over, a couple talking it through. Set the TTL near typical and those guests lose the room while they are still inside the form, on precisely the scarce dates where it will not be there when they resubmit. Go long instead and every abandoned cart parks a sellable room outside the pool for the whole window; on a nearly-full date, a handful of concurrent abandonments can make a hotel look sold out to every searcher while it is not. So I would set it from the observed distribution of completion times, out in the tail rather than at the median, and treat the held-but-unsold inventory as the price of the pattern. What makes a generous TTL affordable is that it is not the primary release path at all — it only ever covers the guest who walks away saying nothing.
- 10-15 mintypical hold TTL
- atomicdecrement happens at hold time
- auto-releaseon timeout or abandonment
What the new pieces do
- Hold Managercontrol
- Places a short-lived hold on inventory the instant a guest starts checkout, and automatically releases it back to the pool if payment doesn't complete in time.
Step 4 · Payment confirms the hold
Charge, then finalize
The hold is in place. Now the guest submits payment. What's the correct order of operations between charging the card and marking the booking confirmed?
Charge first, confirm second. Attempt the payment; only on success does the Hold Manager convert the hold into a permanent, confirmed booking. If the charge fails (declined card, timeout), the hold is released immediately rather than waiting for its full TTL to expire, so that inventory is back on sale as fast as possible instead of sitting artificially reserved for a booking that will never complete.
Why this piece earns its place
This ordering splits one outcome across two systems, and the gap between them is where the money sits. If the process dies after the provider has taken the charge but before the hold becomes a booking, the guest has paid and has nothing — and worse, the hold’s clock is still running, so the room returns to the pool and is sold to someone else. The rule that saves you is that an unknown payment outcome is not a failed one. A timeout from the provider must leave the hold in a pending state the sweep refuses to release, resolvable only by asking the provider what actually became of that attempt — which means the charge has to carry the hold’s id so it can be looked up rather than guessed at. Only a definite decline releases early. Get the asymmetry the wrong way round and it is expensive: releasing on ambiguity risks a charged guest with no room, while holding on ambiguity costs a few minutes of one room’s availability.
What the new pieces do
- Paymentservice
- Charges the guest and, only on success, converts the temporary hold into a confirmed, permanent booking.
Step 5 · Dynamic pricing
The rate changes with demand — but not on every request
Room rates should rise as a date fills up and fall for slow periods far in advance — classic revenue management. Millions of search page-views hit these rates every hour. Should you recompute the "correct" price live, from current occupancy, for every single page view?
Rates depend on live occupancy and demand signals. Should every page view trigger a fresh price computation?
"Fully live" pricing on every page view means every search-result impression (which vastly outnumbers actual bookings) triggers a real-time demand computation — that's enormous wasted work for a number that doesn't meaningfully change between one page view and the next one a second later.
Showing an "estimate" that could be meaningfully wrong at the moment of booking is a bad guest experience — the price a guest sees while browsing should be the price they can actually book at, with only rare, small drift.
Prices don't need millisecond freshness — they need to be right within a small, acceptable window. Recomputing periodically (or triggered by significant inventory movement) and caching the result means the vast majority of page views are cheap cache reads, while the rate itself still tracks real demand within minutes.
The Pricing Engine recomputes rates periodically (or when inventory moves meaningfully), keyed by room-type and date, and caches the result with a short TTL. Nearly all read traffic — search results, browsing — is served from that cache; only a small fraction of requests trigger an actual recomputation, keeping the system fast under enormous read volume while rates still track real demand.
Why this piece earns its place
This step quietly assumes the rate a guest saw is the rate they pay, and a periodically recomputed, cached price breaks that assumption in the most visible way there is. Between the search impression and the confirm, a recompute can land — and on a date that is filling, revenue management moves the number up, so someone who has already been through checkout is charged more than the page promised. The fix is to pin the price into the hold at the moment the hold is created and honor it for that hold’s life; the recompute then applies to the next guest, not the one already committed. Notice that the opposite error is the cheap one: a stale-low rate served from cache is bounded by the TTL, only ever undercharges, and can be budgeted for. That asymmetry is the whole argument for caching here. Staleness that costs a little margin is fine; staleness that changes the number after a guest has agreed to it is not a pricing problem, it is a chargeback.
What the new pieces do
- Pricing Engineservice
- Computes the rate for a room-type/date from occupancy, demand, and lead time — recomputed periodically, not on every single page view.
Step 6 · Search at scale
Split search from the source of truth
A city search returns thousands of properties across a date range. Should that query run live against the same strongly-consistent inventory table that bookings depend on for correctness?
Search across thousands of properties, high volume, low individual stakes. Query live inventory, or something else?
Search volume is orders of magnitude larger than booking volume, and hammering the same strongly-consistent store that guards against overselling with every browse-and-filter search would make that critical path slow and contended for everyone, including people actually trying to book.
This is a CQRS-style split: reads-for-browsing go to a fast, eventually-consistent index built for search (filters, sorting, geo, thousands of properties); the strongly-consistent inventory store is only touched at the moment of the atomic booking decrement, where correctness actually matters. A few seconds of index staleness is a fine trade for search performance, because search never itself sells a room.
A full city's live inventory, refreshed constantly, is far too large and too volatile to reasonably ship to and keep fresh on every client — this belongs server-side, close to the data.
Maintain a separate Search Index — denormalized, fast to filter and sort, fed asynchronously from inventory changes, and allowed to be a few seconds stale. All the high-volume browsing traffic hits this index; the strongly-consistent inventory store is only touched at the moment of actual booking, where an atomic conditional decrement is the real, final word on availability.
Why this piece earns its place
Eventually consistent is a promise about how far behind the index runs, and nothing built so far measures it. If the feed from inventory stalls — a stuck consumer, one poisoned message — search does not break. It keeps answering just as fast, out of an index that has quietly frozen, and guests browse an old snapshot with complete confidence. Every rejection then lands at the atomic decrement, which does its job and tells one guest after another that the room they just clicked is gone. So the health signal is not whether the pipeline process is alive; it is the age of the newest change in the index compared with now, because that is the only place the promise is actually stated as a number. Alert on it, and give the system somewhere to go when it is breached: fall back to a live availability check against inventory for the handful of results a guest is about to act on, or label availability as approximate rather than presenting frozen data as fact.
- thousandsof properties per search
- secondsof acceptable index staleness
- 1 source of truthonly inventory decides a real sale
What the new pieces do
- Search Indexcache
- A denormalized, fast-to-query index across every property in a city — deliberately allowed to lag a few seconds behind real inventory in exchange for handling enormous search volume.
Step 7 · Cancellations, and choosing to overbook
Give rooms back — or sell more than you have, on purpose
A guest cancels. That room must go back on sale immediately. But some hotels intentionally allow a small amount of overbooking beyond 100% capacity, betting on a statistically predictable no-show/cancellation rate — the same strategy airlines use. Is that a bug, or a deliberate feature?
Cancellation releases the held inventory back to the pool exactly like a hold-timeout does — same mechanism, different trigger. Separately, controlled overbooking is a legitimate business policy layered on top of the technical system: a property manager can set an overbooking margin (e.g. sell 102% of physical rooms for a date with historically high no-show rates), and the inventory system enforces THAT ceiling atomically instead of the physical room count. The technical guarantee (never sell past whatever the configured limit is) stays identical — only the limit itself becomes a business decision, not always equal to "100% of rooms."
Why this piece earns its place
Controlled overbooking rests on an assumption nobody writes down: that no-shows are independent, so a margin derived from a date’s history still holds on the night itself. They are not independent. The things that keep guests away — a cancelled conference, a storm closing the airport — move a whole date at once, and so do the things that make everybody turn up, which is the direction that hurts. The night a margin that averaged out fine all year meets a fully-attended event, the hotel is physically short and a guest with a confirmed booking gets walked. The atomic enforcement the earlier steps bought is not the hard part here. The hard part is that this switch should not be thrown until the operational path behind it exists: oversold dates surfaced to the property days before arrival rather than discovered at the front desk, and a re-accommodation budget that makes the extra sale worth making. Set the margin per property per date from that property’s own history, cap it, and drop it to zero on any date with a known correlated event.
What the new pieces do
- Confirm/Cancelevents
- Sends booking confirmations and, on cancellation, releases the held inventory back to the pool and triggers any applicable refund.
Step 8 · The sharp edges
The last room, and the bots
Two real-world stress tests: the exact last-room race under genuine concurrent load, and search traffic from scrapers/rate-shopping bots hammering the system far harder than real guests ever would.
The last-room race is resolved by the same primitive from Step 1-2: the atomic conditional decrement means only one of two simultaneous requests can ever actually commit, full stop — no additional locking needed. For bot traffic, put rate limiting in front of the API (per IP/API-key) and lean hard on the Search Index cache — scraper load hits cheap, cached reads, never the strongly-consistent inventory path, so aggressive automated traffic can't degrade real bookings even at high volume.
Why this piece earns its place
Per-IP rate limiting answers the traffic that is merely expensive, and the expensive traffic is not the dangerous kind. Scraped searches land on a cached index and cost very little, while a real rush of guests onto a sale date looks identical to an attack from the limiter’s point of view — so tuning that limit hard mostly turns away customers. The request actually worth protecting is the one that touches inventory, and the most abusable request on this whole page is the hold: free to make, it removes a real room from the sellable pool for the full TTL, and it never has to pay. A script opening holds across every room-type on a competitor’s sold-out weekend can take a hotel off sale without completing a single booking, and each individual request is indistinguishable from a guest starting checkout. So the limit that matters is on holds, not searches — per account, per device, per payment instrument — with identity required before a hold is granted and a tighter expiry for an unauthenticated session than for a signed-in guest with a card on file.
You did it
You just designed a hotel booking system.
Everything you assembled, in order
- Read-then-decrement is a check-then-act race that oversells the last room — the fix is one atomic conditional write.
- A multi-night stay needs every date-row's decrement wrapped in a single all-or-nothing transaction.
- Reserve-then-confirm (a TTL'd hold) prevents both overselling during checkout AND permanent loss to cart abandonment.
- Payment failure releases the hold immediately rather than waiting out its TTL, so inventory returns to sale as fast as possible.
- Dynamic pricing is computed periodically and cached — search volume is far too high to recompute live per page view.
- Search is a separate, eventually-consistent index (CQRS); only the strongly-consistent inventory store gets the final say at actual booking time.
- Cancellations release inventory the same way a hold timeout does; controlled overbooking is a business policy the same atomic system can enforce above 100% capacity.