Scope the requirements
The numbers are deliberately modest. Candidates who start sharding and adding queues have already failed; the interviewer wants the inventory model, the concurrency story on one hot row, and idempotency on retries.
Functional — what it must do
- Search hotels by location and date range; view hotel and room details.
- Reserve a room by room type — not a specific room number — for a date range.
- Cancel or modify a reservation.
- Hotel admins manage inventory, room types and nightly rates.
- Support controlled overbooking (sell up to 110% of physical inventory) as a business rule.
- Explicitly cut: loyalty points, multi-room group bookings, channel managers — extensions if asked.
Non-functional — the numbers that shape the design
- No double-booking beyond the overbooking allowance — the consistency guarantee the whole design serves.
- Reserve path p99 under 1 s; search can be slower and eventually consistent.
- Scale: 5,000 hotels, 1M rooms — bookings land at roughly 3 TPS.
- Bursty contention on hot rows: one property, one weekend, everyone booking at once.
- Availability over latency on the read side; the booking transaction favours consistency.
I'll say the uncomfortable thing first: at three bookings a second, one relational database handles this without noticing. So the interview isn't about scale — it's about modelling inventory so concurrency is cheap, and making retries safe. That's where I'll spend my time.
Back-of-envelope estimates
Two minutes of arithmetic whose whole purpose is to kill over-engineering: the funnel shows every layer is small, and the inventory table — the only interesting sizing — fits on one machine with room to spare.
| Quantity | Estimate | How you got there |
|---|---|---|
| Reservations/day | ~240,000 | 1M rooms × 70% occupancy ÷ 3-night average stay. Sounds big; it isn't. |
| Booking writes | ~3/s | 240k ÷ 86,400 s. Any Postgres or MySQL instance absorbs this at idle. |
| Traffic funnel | 300 → 30 → 3 QPS | Rule of ~10× drop-off per step: view room → view booking page → place reservation. Sizes each tier honestly. |
| Inventory rows | ~73M | 5k hotels × ~20 room types × 730 days (a 2-year booking window). One row per room type per date — fits one MySQL instance easily. |
| Row size / total | ~100 B → ~7 GB | hotel_id, room_type_id, date, two counters. The entire inventory table is smaller than one photo album. |
Seventy-three million rows and three writes a second — I get to say 'single ACID database, replicas for reads' with a straight face, and sharding by hotel_id stays in my back pocket as the future story, not the design.
Sketch the API
Standard CRUD except for one deliberate detail: the client generates the reservation id before submitting, which is the idempotency key that makes retries safe.
reservationId is a client-generated idempotency key.- The client mints
reservationId(a UUID) when the booking page loads. A double-click, a timeout retry, or a flaky network resubmits the same id; a unique constraint makes the second insert a no-op that returns the original result. Idempotency designed in, not bolted on. - Price is re-quoted server-side at reserve time from the rates table — the client's displayed price is advisory. If it moved, return 409 with the new quote rather than silently charging a different amount.
Data model
The single most important decision in this question is one table: inventory as counters per (hotel, room type, date), not per physical room.
total_reserved on every night in the range.MySQL or Postgres, one primary — money-adjacent inventory wants ACID transactions, and 3 TPS doesn't begin to justify anything else. Read replicas serve search and hotel pages. When I'd switch: if inventory outgrew one box or one region, I'd shard by hotel_id — every query in the system carries it, so shards never talk to each other — but at 7 GB of inventory that's a slide about the future, not part of this design.
High-level design
A read side that's all cache and replicas, and a write side where one ACID transaction checks inventory, increments counters for every night, and inserts the reservation — atomically or not at all.
- Search: guest queries by location and dates → search service reads cached availability from Redis, falling back to a MySQL replica. Staleness here is fine by design — the booking transaction re-checks.
- Reserve: guest submits with a client-generated
reservationId→ the reservation service opens one transaction: for every night in the range,UPDATE room_type_inventory SET total_reserved = total_reserved + 1 WHERE hotel_id=? AND room_type_id=? AND date=? AND total_reserved < total_inventory * 1.1, then inserts the reservation row aspending. - If any night's update matches zero rows, the whole transaction rolls back and the guest sees "no longer available" — partial multi-night bookings can't exist.
- Pay: the service creates a payment with the PSP; the webhook flips the reservation
pending → paid. A failed or expired payment triggers the compensating transaction: decrement the counters back, markrejected. - CDC streams inventory changes into Kafka to invalidate cached availability and feed the search index — the cache heals itself without dual writes.
Deep dives — where the interview is won
Book room types, not room numbers — the model that deletes the contention
The naive schema locks a specific physical room (room 204) per night. That invents contention that doesn't exist in the business: guests don't care which room they get, hotels assign numbers at check-in, and maintenance swaps rooms freely. Modelling it that way turns every popular property into hundreds of tiny lock fights and makes 'move the guest to an identical room' a data migration.
The right shape is a counter: room_type_inventory (hotel_id, room_type_id, date, total_inventory, total_reserved) — one row per room type per date. Booking three nights touches exactly three rows. Availability is one indexed range read. Contention collapses from per-room to per-(type, date), and the hotel's real-world flexibility is preserved because physical assignment happens at the desk, outside this system. This single modelling decision is worth more than any scaling discussion in this interview.
Concurrency in one conditional update, overbooking included
Two guests race for the last room. Three standard defences: pessimistic locking (SELECT ... FOR UPDATE on the inventory rows) — simple, correct, but serialises every booking on a hot date and invites deadlocks when two multi-night bookings lock date rows in different orders. Optimistic locking (version column, retry on conflict) — great when contention is low, which at 3 TPS it almost always is. The constraint approach: make the update itself conditional — SET total_reserved = total_reserved + 1 WHERE ... AND total_reserved < total_inventory * 1.1 — and let a zero row count mean 'sold out'.
I'd lead with the conditional update, optionally backed by a CHECK (total_reserved <= total_inventory 1.1) constraint so no code path can oversell even by accident. It's the least machinery: no lock manager, no retry loop, no version column — the database's own atomicity does the work. And notice where overbooking went: it's not a subsystem, it's the literal 1.1 in the predicate, tunable per hotel by storing the factor on the hotel row. Interviewers love seeing a scary-sounding requirement dissolve into one multiplication.
Idempotency: the double-click must not book two rooms
The failure that actually happens in production: a guest clicks Book, the request succeeds server-side, the response times out, and the client retries — or the guest just double-clicks. Without protection that's two reservations and two charges. The fix costs one design decision: the client generates reservationId when the booking form loads, and it's the primary key of the reservation table. The retry inserts the same key, violates the constraint, and the service returns the original reservation instead of creating anything.
The same discipline extends down the payment hop: the PSP call carries an idempotency key derived from the reservation id, so a retried charge is deduplicated on their side too. The general rule worth stating: exactly-once is always at-least-once plus idempotency — you can't prevent retries, so you make them harmless at every hop that has side effects.
Reservation plus payment without 2PC: keep one transaction, compensate the rest
The reserve flow spans my database and an external payment provider, and no transaction coordinator covers both — two-phase commit isn't on the menu and interviewers dock points for reaching for it. The pattern is a small saga: commit the inventory decrement and the pending reservation locally first, then attempt payment. Success flips the status to paid; failure or timeout runs the compensating transaction — decrement the counters back, mark the reservation rejected. Every state is explicit in the status enum, so a crash mid-saga leaves a row a reconciliation job can find and finish.
This is also my argument for keeping inventory and reservations in one database: the only cross-service hop left is the payment, which is unavoidably external. Splitting inventory and reservations into separate services with separate stores — microservice dogma — would manufacture a second distributed boundary and force saga machinery where a local ACID transaction was available for free.
pending reservation whose payment will fail — so you accept a sweeper that expires stale pendings and returns their inventory.Follow-ups you should expect
How do you make a 3-night booking atomic — what if night two is sold out?
The booking page showed a room, but it's gone by the time the guest books. Acceptable?
How would this change for Airbnb rather than hotels?
When would you actually shard, and how?
A guest cancels — how does the room come back?
Where candidates lose the room
- Modelling inventory as physical room numbers and locking room 204 per night — invented contention, messy data, and the interviewer knows hotels assign rooms at check-in.
- Scaling theatre: sharding, queues and NoSQL for 3 TPS — the numbers were chosen small precisely to test whether you notice.
- No idempotency key, so a timeout retry after payment books and charges twice — the most production-real failure in the whole question.
- Reaching for 2PC or a distributed transaction across reservation and payment instead of a saga with compensating updates.
- Treating overbooking as a scary subsystem instead of a one-line change to the update predicate — it signals you didn't really own the inventory model.