Hotel booking

Design the booking part of a hotel app. Guests search rooms free for a range of days, book a room type, and cancel. Prices depend on room type and season, and no room is ever sold twice for the same night.

Design a hotel booking system. The hotel has rooms of different types. A guest searches for rooms that are free from a check-in day to a check-out day, books one, and can cancel it. The price depends on the room type and the season. Two guests must never get the same room for overlapping nights.

Questions to ask first

  • Do we use calendar dates? Assume whole day numbers (day 1, day 2 and so on). Calendar and time zone rules are a separate problem.
  • Is the check-out day a night the guest pays for? Assume no. A stay from day 10 to day 13 uses the nights of days 10, 11 and 12, and the guest leaves on the morning of day 13.
  • Does the guest pick a room number? Assume no. The guest picks a room type and the hotel gives the free room of that type with the lowest number.
  • How is the price worked out? Assume a base rate per night for each room type, multiplied for peak days. The total is fixed when the booking is made.
  • Can a room be booked again on the day another guest checks out? Assume yes. A stay that ends on day 13 and a stay that starts on day 13 do not clash.
  • Do many guests book at once? Yes. Assume one process with many threads. The deep dive covers what changes with a database.
  • What does cancel do? Assume it frees the room for those nights. Refund rules are an extension.

Requirements

  • Model rooms with a number and a type (standard, deluxe, suite).
  • Search the rooms of a type that are free for every night from check-in to check-out.
  • Book the lowest-numbered free room of a type for a guest, or refuse when none is free.
  • Price a stay by room type and season, and keep the price on the booking.
  • Cancel a confirmed booking so the room is free again for those nights.
  • Refuse invalid days, such as a check-out that is not after the check-in.
  • Never sell one room for overlapping nights, even when many guests book together.

Out of scope

  • Real payments, taxes and invoices.
  • Calendar dates, time zones and daylight saving.
  • Room photos, reviews, and search by amenities or distance.
  • Saving data to a database (in-memory storage is enough for the round).

Entities

  • Hotel: addRoom(room), availableRooms(type, stay), quote(type, stay), book(guest, type, stay), cancel(bookingId)
  • Room: number, type; isFree(stay), tryReserve(stay), release(stay)
  • RoomType: STANDARD, DELUXE, SUITE
  • Stay: checkIn, checkOut; overlaps(other), nights()
  • Booking: id, guest, room, stay, price, status; cancel()
  • BookingStatus: CONFIRMED, CANCELLED
  • PricingStrategy: nightlyRate(type, day), the rule for the price of one night
  • SeasonalPricing: a base rate per type with a higher rate on peak days

Classes in the model design

  • Hotel (class)
  • Room (class)
  • RoomType (enum)
  • Stay (class)
  • Booking (class)
  • BookingStatus (enum)
  • PricingStrategy (interface)
  • SeasonalPricing (class)

The main use case, step by step

  1. Asha wants a standard room from day 10 to day 13. The app calls `hotel.book("Asha", STANDARD, new Stay(10, 13))`.
  2. Hotel lists its STANDARD rooms in room-number order: 101, then 102. It asks room 101 to `tryReserve(stay)`.
  3. Inside `tryReserve`, the room checks its own list of stays with `overlaps`. Room 101 is empty, so it adds the stay and returns true. The check and the add are one step under the room's lock.
  4. Hotel asks the `PricingStrategy` for the rate of each night, days 10, 11 and 12, adds them up (3 nights at 100 is 300) and stores that price on a new `Booking` with id B1.
  5. Later Ben asks for days 12 to 15. Room 101 says no, because 12 is before 13 and 10 is before 15. Room 102 is empty, so Ben gets room 102.
  6. Cara asks for days 13 to 16. Room 101 now says yes, because Asha's stay ends on day 13 and does not clash. Cancelling a booking calls `room.release(stay)`, and the nights are free for the next guest.

Patterns used

  • Strategy: PricingStrategy (SeasonalPricing) is held by Hotel.. Rates change with seasons, events and discounts. With an interface, a new pricing rule is a new class and Hotel is untouched (the open/closed principle). Tests can pass a strategy with fixed numbers.
  • Value object: Stay holds a check-in and a check-out day and owns the overlap rule.. The overlap rule is the part of this problem that candidates get wrong. Putting it in one small class means it is written once, tested once and used by Room, search and booking alike.
  • Lock per room (atomic check and add): Room.tryReserve is synchronized on the room.. Checking a room and then adding a stay are two steps. Doing them under one lock stops two guests from both passing the check. Guests who want different rooms never wait for each other.
  • Facade: Hotel gives clients search, quote, book and cancel, and hides rooms, stays and pricing.. The caller does not need to know how rooms are tried or how prices are added up. The order of steps stays in one place and cannot be skipped by a careless caller.

Key code

Stay and Room: the overlap rule and the atomic reserve

/** A run of nights from checkIn up to, but not including, checkOut. */
record Stay(int checkIn, int checkOut) {

    /** Two stays clash when each one starts before the other ends. */
    boolean overlaps(Stay other) {
        return checkIn < other.checkOut && other.checkIn < checkOut;
    }

    int nights() { return checkOut - checkIn; }
}

enum RoomType { STANDARD, DELUXE, SUITE }

class Room {
    final int number;
    final RoomType type;
    private final List<Stay> stays = new ArrayList<>();

    Room(int number, RoomType type) {
        this.number = number;
        this.type = type;
    }

    synchronized boolean isFree(Stay wanted) {
        for (Stay s : stays) if (s.overlaps(wanted)) return false;
        return true;
    }

    /** Check and add as one step. Two threads cannot both get true for the same nights. */
    synchronized boolean tryReserve(Stay wanted) {
        if (!isFree(wanted)) return false;
        stays.add(wanted);
        return true;
    }

    synchronized void release(Stay stay) { stays.remove(stay); }
}

PricingStrategy and Hotel.book

interface PricingStrategy {
    int nightlyRate(RoomType type, int day);
}

/** A base rate per type, one and a half times on peak days 20 to 29. */
class SeasonalPricing implements PricingStrategy {
    @Override
    public int nightlyRate(RoomType type, int day) {
        int base = switch (type) {
            case STANDARD -> 100;
            case DELUXE -> 180;
            case SUITE -> 400;
        };
        boolean peak = day >= 20 && day <= 29;
        return peak ? base * 3 / 2 : base;
    }
}

class Hotel {
    private final Map<Integer, Room> rooms = new TreeMap<>();   // lowest number first
    private final Map<String, Booking> bookings = new ConcurrentHashMap<>();
    private final PricingStrategy pricing;
    private final AtomicInteger nextId = new AtomicInteger();

    Hotel(PricingStrategy pricing) { this.pricing = pricing; }

    int quote(RoomType type, Stay stay) {
        int total = 0;
        for (int day = stay.checkIn(); day < stay.checkOut(); day++) total += pricing.nightlyRate(type, day);
        return total;
    }

    /** Each room does the check and the add as one step. The first room that says yes wins. */
    Booking book(String guest, RoomType type, Stay stay) {
        for (Room room : roomsOf(type)) {
            if (room.tryReserve(stay)) {
                Booking b = new Booking("B" + nextId.incrementAndGet(), guest, room, stay, quote(type, stay));
                bookings.put(b.id, b);
                return b;
            }
        }
        return null;   // no room of this type is free for every night
    }
}

Deep dives

The interval overlap rule

Weak: Compare days for equality, or ask only whether the new check-in falls inside an existing stay. Take an existing stay from day 10 to 13 and a new request from day 8 to 15. The new check-in (day 8) is not inside the old stay, so this test says the room is free. But the new stay covers the old one, so the room is taken.

Good: Test three cases: the new check-in is inside the old stay, the new check-out is inside it, or the new stay contains it. This is correct. But it is long, it is easy to get a boundary wrong, and each reader has to work out why three cases are enough.

Great: Use one rule for two ranges that include their start and leave out their end. Stays a to b and c to d overlap exactly when a < d and c < b. The reason is simple. They do not overlap only when one ends on or before the day the other starts, which is b <= c or d <= a. Negate that and you get the rule. Check it on the boundary: stays 10 to 13 and 13 to 16 give 10 < 16 and 13 < 13. The second part is false, so they do not clash, which is right because the first guest leaves on the morning of day 13. Put the rule in Stay.overlaps and test the edges: touching, equal, nested, and one night apart.

Trade-off: The rule works because every stay uses the same convention: the check-in night is included and the check-out day is not. If one part of the code treated the check-out day as a night, you would need <= there and back-to-back bookings would be wrongly refused. Choose one convention, write it in a comment on Stay, and use it everywhere.

Double booking under concurrency

Weak: Call room.isFree(stay) and then room.add(stay) as two separate steps. Two guests can both see that room 101 is free before either adds a stay. Both then add, and one room holds two overlapping stays. This is a race condition: the result depends on which thread runs first.

Good: Make Hotel.book synchronized. Check and add now happen as one step for the whole hotel, so a double booking is impossible. It is easy to explain and correct. But every guest waits for every other guest, even when they want different room types or different weeks.

Great: Put the check and the add together inside the room. Room.tryReserve is synchronized on the room and returns false if the nights clash. Hotel tries the rooms of the type in order and takes the first one that says yes. Guests who end up in different rooms never wait for each other, and only one room lock is held at a time, so there is no deadlock. Never call slow work, such as payment, while holding a room lock.

Trade-off: A lock inside one process protects nothing once you run several servers. Then the database must decide, with a conditional insert or a unique rule on room and day, so the second insert fails and the caller retries with the next room. Say this aloud even if you only code the in-memory version.

Pricing by room type and season

Weak: An if on room type and another on the month, inside book. A new season, a weekend rate or a discount code each edits the same method, and each edit can break an old rule. Search shows a price that book may not charge, because the logic is copied.

Good: A PricingStrategy interface that returns the price of one night, with SeasonalPricing as the first implementation. Hotel adds up the nights. A new rule is a new class, and quote and book use the same method, so the price a guest sees is the price they pay.

Great: Keep the rates as data: a base rate for each room type and a list of seasons, each with a first day, a last day and a multiplier. The strategy reads this table, so a change in rates edits data and not code. Store the final price on the Booking when it is made. A later rate change then never changes an old booking, and a refund can read the amount it must return.

Trade-off: Pricing per night is simple and fits most rules, but it cannot express a rule such as a discount for the whole stay. For that, give the strategy the whole Stay instead of one day. Start with the per-night version and say that you would widen it if asked.

Now add one more feature

  • Now add a refund policy: full refund up to 3 days before check-in, half after that. What changes? Add a `RefundPolicy` interface and pass it to Hotel. `cancel` asks the policy for the refund using the stored price and the days left, and returns it. Rooms and stays do not change.
  • Now let a guest ask for a specific room number. What changes? Add a `bookRoom(guest, roomNumber, stay)` method that calls `tryReserve` on that one room. The type-based search stays as it is, and both paths use the same overlap rule.
  • Now let a guest extend a stay by two nights. What changes? Only the extra nights need checking: ask the room whether `new Stay(oldCheckOut, newCheckOut)` is free, and if it is, replace the stay. Do the check and the replace under the room's lock.
  • Now hold a room for 10 minutes while the guest pays. What changes? Add a HELD status and an expiry time to a stay, and treat an expired hold as free when checking. This is the same idea as holding seats in a movie booking system.
  • Now run on three servers. What changes? In-memory locks stop protecting anything. Move the check and the add into one conditional database write, keep `Room.tryReserve` as the class that wraps it, and let the caller retry the next room when it fails.

What each level is expected to show

SDE-1: A clean diagram with Hotel, Room, RoomType, Booking and a Stay or date range. Search and book work for a day range, and the overlap test is correct on the boundary, so back-to-back stays are allowed. Pricing sits in one place. Knows that two guests can race.

SDE-2: Everything at SDE-1, plus the overlap rule derived and tested on its edges, and an atomic check and reserve inside each room. Pricing sits behind a Strategy, with the price stored on the booking. Explains how the same guard moves to a database. Answers each extension by naming the classes that change.

Common mistakes

  • Testing overlap with only the new check-in or only the new check-out, which misses a new stay that contains an old one.
  • Treating the check-out day as a booked night, so a guest cannot arrive on the day another guest leaves.
  • Checking that a room is free and adding the stay as two separate steps, which lets two guests book the same nights.
  • One lock around the whole hotel, so every guest waits for every other guest.
  • Copying the price logic into search and into booking, so the quote and the charge can differ.
  • Computing the price again when showing an old booking, so a rate change alters what a guest already paid.
  • Putting room type checks as `if` statements inside Hotel, so a new room type edits many methods.

Lessons behind this answer