SevyDevy

Guide · System design

Hotel Management LLD: Rooms, Bookings and Billing

29 min readUpdated By Saket Bhatnagar

Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.

In short

  • A hotel management LLD (low-level design) is the class design inside one hotel program. It names the classes for rooms, bookings and bills, what each one holds and which rules each one guards.
  • Book a room type, such as Deluxe, for a range of dates, and pick the real room number at check-in. Store each stay as half-open dates: the check-in night counts, the check-out day does not.
  • A room type is free when every night of the request still has a room left. Counting the bookings that overlap the whole request is a common mistake: it turns away guests who would fit.
  • A booking moves through a few states (stages), such as RESERVED and CHECKED_IN, and only checked methods move it. Every charge and payment is a line on one folio, the guest's running bill, kept in BigDecimal, Java's exact decimal type.
  • Beginners can stop after the billing section. Engineers then get a 40,000-case test of the two availability rules and a race test with two desks booking at once. They also get a per-night guard in PostgreSQL, a popular open-source database, that will not sell the last room twice.

What goes wrong when a hotel books rooms on a spreadsheet?

A hotel goes wrong when it ties each booking to a room number months early and counts the checkout day as a booked night. Guests get keys to the same room, rooms sit empty and bills come out wrong. A good hotel management LLD fixes all three with a few small classes.

Picture a common setup. A 60-room hotel on a pilgrimage route keeps its bookings in a shared spreadsheet. Each row holds a guest name, dates and a room number picked at booking time. The setup is made up, but the problems are real.

In the busy season the hotel is full most nights. Then one week goes wrong in three ways.

  • Room 204 was promised to a family months ahead. When the geyser (water heater) in another room broke, a clerk moved that guest into 204 and forgot the row. Both parties got keys to 204.
  • The sheet counted the checkout day as a booked night. A room freed on the morning of 14 November could not be sold for that night, so it stayed empty.
  • A family stayed four nights and ate in the hotel restaurant. Their checkout bill missed two laundry charges and subtracted their Rs 5,000 advance twice.

This guide builds a design that makes each mistake hard to repeat: the classes first, then the date rule, the booking states and the bill. The engineer sections then test the rules and move the key one into a database.

What is a hotel management LLD?

A hotel management LLD is the low-level design of a hotel program: its classes, their fields, their methods and the rules they protect. Low-level design, or LLD, means designing the code inside one program.

A high-level design draws servers, databases and queues. A low-level design zooms into one service and asks which objects exist and which one may change what. In an LLD interview you usually sketch the classes and write a few key methods, often in Java.

This guide covers three parts, matching the three troubles. Rooms are the stock the hotel sells. Bookings are promises about dates. Billing is the money trail from booking to checkout.

Which classes does a hotel management system need?

A hotel management system needs about nine core classes: Hotel, RoomType, Room, Guest, Booking, Stay, RoomTypeInventory, Folio and Charge. Each one owns one kind of fact, so each rule has one obvious home.

The trick is to keep apart things that change at different speeds. A room's number and floor never change. Its cleaning status changes several times a day. A booking changes from the day it is made until the guest leaves.

Start at Guest in the class map and follow the arrows. Notice that a Booking points at a RoomType from the start, and at a Room only from check-in.

Hotel management class map

Each class owns one kind of fact. Notice that only RoomTypeInventory creates bookings, and a booking gets a room number only at check-in.

Diagram connections:

  • Guest to RoomTypeInventory (asks to book)
  • RoomTypeInventory to Booking (creates)
  • Booking to Stay (covers)
  • Hotel to RoomType (offers)
  • RoomType to RoomTypeInventory (sold through)
  • RoomType to Room (groups)
  • Booking to Room (gets at check-in)
  • Booking to Folio (bills to)
  • Folio to Charge (lists)
  • Guest: Name, phone and ID details
  • RoomTypeInventory: Only class that says a type is free
  • Booking: Guest, room type, stay, status
  • Stay: Check-in and check-out dates
  • Hotel: One property and its room types
  • RoomType: Deluxe: 2 guests, rate per night
  • Room: Number 204, floor 2, cleaning status
  • Folio: Running bill: charges and payments
  • Charge: One line: a night, laundry or a meal

The table puts each class next to the rule it guards. If you ever wonder where a new check belongs, find the class that owns the fact it reads.

Who owns which fact in the hotel design
ClassWhat it holdsRule it guards
HotelName, city, its room typesEvery room belongs to exactly one hotel
RoomTypeName, capacity, rate per nightOne price and capacity for every room of the type
RoomNumber, floor, type, cleaning statusOnly a clean, vacant room of the right type is given out
GuestName, phone, ID detailsContact details live in one place, not on every booking
StayCheck-in and check-out datesCheck-out comes after check-in
RoomTypeInventoryRoom count and bookings for one typeNo night ever has more bookings than rooms
BookingGuest, room type, stay, status, room once assignedOnly allowed status changes happen
FolioCharges, payments, a tax ruleLines are only added; checkout needs a zero balance
ChargeKind, description, amount, dateNever edited; mistakes get a correcting line

Should a hotel booking reserve a room type or a room number?

A booking should reserve a room type, such as Deluxe, for a range of dates. The front desk picks the room number at check-in, when it knows which rooms are clean and working.

Think of a dentist's clinic with four chairs. The appointment book records your name, the treatment and 5 pm, not chair 3. When you arrive, the assistant seats you in whichever chair is free and ready.

  • The treatment you booked is the RoomType: the kind of thing promised.
  • Each chair is a Room: one real object that can break or need cleaning.
  • The appointment book is RoomTypeInventory: it decides whether one more booking fits.
  • Being seated on arrival is check-in, when a real Room is assigned.

Where the picture breaks: a patient never stays in the chair for three days. A hotel guest does, so the hotel must plan around rooms already taken on later nights. That is why its free-room check works night by night.

The room 204 mix-up came from doing the opposite. The booking was glued to one room months ahead. When the clerk moved another guest, nothing in the sheet knew that 204 was already promised.

Booking by type gives the hotel room to manage. A geyser can break, a room can need painting, or a family can ask for a lower floor. The promise still holds as long as one Deluxe room is free.

So Booking has two fields for this. The roomType field is set when the booking is made. The room field starts empty and is filled at check-in with a clean, vacant Room of that type.

How do you check if a hotel room is free for given dates?

A room type is free for a stay when every night of that stay still has a room left. For each night, count the active bookings that cover it and compare with the number of rooms of that type.

Store a stay as half-open dates

First decide what a stay means. Store a check-in date and a check-out date. The guest uses the nights from check-in up to, but not including, check-out. So 12 to 14 November covers the nights of 12 and 13 November.

This is a half-open range: the start counts and the end does not. It fixes the empty-room bug. A stay ending on 14 November and one starting on 14 November do not clash, so the room is sold both times.

Java's LocalDate class fits well. It holds a date with no time and no time zone. It is immutable, so methods such as plusDays return a new date instead of changing the old one. Its datesUntil method leaves out the end date, which matches the same rule.

Here is the Stay class as a Java record, a short way to write a class whose fields are fixed once it is built (standard since Java 16).

java
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;

// Half-open: the check-in night counts, the check-out day does not.
public record Stay(LocalDate checkIn, LocalDate checkOut) {

    public Stay {                       // runs for every new Stay
        if (!checkIn.isBefore(checkOut)) {
            throw new IllegalArgumentException("Check-out must be after check-in");
        }
    }

    public boolean overlaps(Stay other) {
        return checkIn.isBefore(other.checkOut) && other.checkIn.isBefore(checkOut);
    }

    public boolean covers(LocalDate night) {
        return !night.isBefore(checkIn) && night.isBefore(checkOut);
    }

    public long nights() {
        return ChronoUnit.DAYS.between(checkIn, checkOut);
    }
}

Three methods do the work. The overlaps method says two stays clash only if each starts before the other ends. The covers method says whether one night belongs to the stay. The nights method counts paid nights, so 12 to 14 November gives 2.

The block with no parameter list is a compact constructor, and it runs for every new Stay. It refuses a stay where check-out is not after check-in, so a zero-night or backwards stay can never exist.

Martin Fowler describes this idea as the Range pattern: one object that holds a start and an end, with methods such as includes and overlaps. Keeping the rule in one class stops anyone rewriting it slightly wrong elsewhere.

You can check it yourself. Save StayDemo.java next to Stay.java, run javac Stay.java StayDemo.java, then run java StayDemo.

java
import java.time.LocalDate;

public class StayDemo {
    public static void main(String[] args) {
        Stay first = new Stay(LocalDate.of(2026, 11, 12), LocalDate.of(2026, 11, 14));
        Stay next = new Stay(LocalDate.of(2026, 11, 14), LocalDate.of(2026, 11, 15));

        System.out.println(first.nights());                       // nights paid for
        System.out.println(first.overlaps(next));                 // back to back
        System.out.println(first.covers(LocalDate.of(2026, 11, 14)));
        System.out.println(first.checkIn().datesUntil(first.checkOut()).toList());
    }
}

On Java 21 it prints four lines: 2, false, false and [2026-11-12, 2026-11-13]. The stay pays for 2 nights, the back-to-back stay does not clash, and 14 November is not one of its nights.

Why the obvious count is wrong

The first rule most people write is: count the active bookings that overlap the request, and say yes if that is fewer than the rooms. It never sells a room twice. It is still wrong, because it refuses stays that fit.

Pause and predict. The hotel has 2 Deluxe rooms. One guest has the night of 12 November and another has the night of 13 November. Can a third guest stay from 12 to 14 November?

Yes. Each night has one guest, so one room stays free on both nights. But both bookings overlap the request, so the overlap count sees 2 of 2 rooms used and refuses.

The per-night rule asks the right question: on each night, how many active bookings cover it? Active means RESERVED or CHECKED_IN. Cancelled bookings, no-shows and finished stays hold no room.

java
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

public class RoomTypeInventory {
    private final RoomType type;
    private final int rooms;            // sellable rooms of this type
    private final TaxRule taxRule;
    private final List<Booking> bookings = new ArrayList<>();

    public RoomTypeInventory(RoomType type, int rooms, TaxRule taxRule) {
        this.type = type;
        this.rooms = rooms;
        this.taxRule = taxRule;
    }

    // Naive first try, kept only to compare: it refuses stays that would fit.
    boolean freeByOverlapCount(Stay want) {
        long clashing = bookings.stream()
                .filter(Booking::isActive)
                .filter(b -> b.stay().overlaps(want))
                .count();
        return clashing < rooms;
    }

    // The real rule: every night of the stay still has a room left.
    boolean isFree(Stay want) {
        return want.checkIn().datesUntil(want.checkOut())
                .allMatch(night -> bookedOn(night) < rooms);
    }

    long bookedOn(LocalDate night) {
        return bookings.stream()
                .filter(Booking::isActive)
                .filter(b -> b.stay().covers(night))
                .count();
    }

    // Check and save are one step: only one thread at a time runs this.
    public synchronized Optional<Booking> book(Guest guest, Stay stay) {
        if (!isFree(stay)) {
            return Optional.empty();
        }
        Booking b = new Booking(guest, type, stay, new Folio(taxRule));
        bookings.add(b);
        return Optional.of(b);
    }
}

What just happened? Read bookings.stream().filter(...).count() as: take the bookings, keep the matching ones, count them. The isFree method walks the nights with datesUntil and checks each one against the room count. The naive freeByOverlapCount stays only so the demo can compare the two.

The book method checks and saves in one synchronized step, explained in the engineer sections. It returns an Optional, Java's box for a value that may be missing, which is empty when the type is full.

Checking Deluxe for 12 to 14 November

The check runs once per night, not once per stay. Notice the question box: one full night is enough to refuse.

Diagram connections:

  • Deluxe, 12 to 14 November to List the nights
  • List the nights to Count bookings per night (for each night)
  • Count bookings per night to Fewer than rooms each night?
  • Fewer than rooms each night? to Save booking as RESERVED (Yes, every night)
  • Fewer than rooms each night? to Offer another type or date (No, one night is full)
  • Deluxe, 12 to 14 November: From the front desk or the website
  • List the nights: datesUntil gives 12 and 13 November
  • Count bookings per night: RESERVED or CHECKED_IN, covers is true
  • Fewer than rooms each night?: Compare each night with 2 Deluxe rooms
  • Save booking as RESERVED: Room type held, room number still empty
  • Offer another type or date: Super Deluxe, or 13 to 15 November

Why is a per-night count enough? It never lets a night hold more bookings than rooms. Every guest already in a room that night is one of those bookings, so an arriving guest always has a room left. The engineer section tests this.

What states does a hotel booking go through?

A hotel booking usually moves from RESERVED to CHECKED_IN to CHECKED_OUT, with CANCELLED and NO_SHOW as side exits. Each state allows only certain next moves, and every other move throws an error.

The status is a Java enum, a type with a fixed list of named values, so a typo fails to compile. These small types hold the plain data the other classes share.

java
import java.math.BigDecimal;

enum BookingStatus { RESERVED, CHECKED_IN, CHECKED_OUT, CANCELLED, NO_SHOW }

enum RoomStatus { VACANT_CLEAN, OCCUPIED, VACANT_DIRTY, OUT_OF_ORDER }

record RoomType(String name, int capacity, BigDecimal ratePerNight) {}

record Guest(String name, String phone) {}

class Room {
    final String number;
    final RoomType type;
    RoomStatus status = RoomStatus.VACANT_CLEAN;

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

Each method on Booking checks the current status before it acts. Here is the whole class.

java
public class Booking {
    private final Guest guest;
    private final RoomType roomType;
    private final Stay stay;
    private final Folio folio;          // opened with the booking, so advances have a home
    private BookingStatus status = BookingStatus.RESERVED;
    private Room room;                  // empty until check-in

    Booking(Guest guest, RoomType roomType, Stay stay, Folio folio) {
        this.guest = guest;
        this.roomType = roomType;
        this.stay = stay;
        this.folio = folio;
    }

    public void checkIn(Room r) {
        require(BookingStatus.RESERVED);
        if (r.type != roomType || r.status != RoomStatus.VACANT_CLEAN) {
            throw new IllegalStateException("Room " + r.number + " is not a clean, vacant " + roomType.name());
        }
        room = r;
        r.status = RoomStatus.OCCUPIED;
        status = BookingStatus.CHECKED_IN;
    }

    public void cancel()     { require(BookingStatus.RESERVED); status = BookingStatus.CANCELLED; }
    public void markNoShow() { require(BookingStatus.RESERVED); status = BookingStatus.NO_SHOW; }

    public void checkOut() {
        require(BookingStatus.CHECKED_IN);
        if (folio.balanceDue().signum() != 0) {
            throw new IllegalStateException("Balance due: " + folio.balanceDue());
        }
        status = BookingStatus.CHECKED_OUT;
        room.status = RoomStatus.VACANT_DIRTY;   // housekeeping cleans it next
    }

    public boolean isActive() {
        return status == BookingStatus.RESERVED || status == BookingStatus.CHECKED_IN;
    }

    private void require(BookingStatus expected) {
        if (status != expected) {
            throw new IllegalStateException("Not allowed while " + status);
        }
    }

    public Stay stay() { return stay; }
    public Folio folio() { return folio; }
    public BookingStatus status() { return status; }
    public Room room() { return room; }
}

What just happened? Every move starts with require, which throws unless the booking is in the one status that move accepts. The checkIn method also refuses a dirty room or a room of another type, so a free upgrade would need its own method. The checkOut method refuses while money is owed, then marks the room dirty.

The life of a hotel booking

Every arrow is a method on Booking. Notice that nothing leaves CHECKED_OUT, CANCELLED or NO_SHOW, and walk-ins enter through the same book method.

Diagram connections:

  • Walk-in guest to RESERVED (book() for tonight)
  • RESERVED to CHECKED_IN (checkIn(room))
  • RESERVED to CANCELLED (cancel())
  • RESERVED to NO_SHOW (markNoShow(), nightly job)
  • CHECKED_IN to CHECKED_OUT (checkOut())
  • Walk-in guest: Booked for tonight, then checked in
  • RESERVED: Room type held, folio open, no room
  • CHECKED_IN: Room assigned, nightly charges added
  • CHECKED_OUT: Balance zero, room marked dirty
  • CANCELLED: Guest cancelled before arrival
  • NO_SHOW: Guest missed the arrival cut-off

Walk-ins are guests who arrive without a booking. A walk-in goes through the same book method for tonight, then checkIn straight away. There is no separate walk-in class, so billing, reports and the availability count see one kind of booking.

A no-show is a booking whose guest never arrives. A nightly job calls markNoShow on RESERVED bookings once the arrival cut-off passes. NO_SHOW is not active, so those nights are free to sell again, and any no-show fee goes on the folio.

How do you design billing and checkout in a hotel management system?

Give each booking a folio, a running bill that collects every charge and payment from booking to checkout. At checkout the guest pays the balance, and the booking closes only when the balance is zero.

Each night adds a room charge line. Laundry, room service and the restaurant add their own lines. Advances and part payments go in as payments, never as negative charges.

Money needs care in code. A double, Java's usual type for numbers with fractions, stores them in binary, so most decimal fractions are not exact: 0.1 + 0.2 prints 0.30000000000000004 on Java 21.

The BigDecimal documentation warns that new BigDecimal(0.1) copies that inexact double exactly, giving 0.1000000000000000055511151231257827021181583404541015625. So build amounts from strings, such as new BigDecimal("3600").

Martin Fowler's Money pattern starts from the same problem. He notes that no mainstream language has money as a built-in type, and rounding to the smallest unit can quietly lose paise. This folio rounds each tax amount to the paisa where it is worked out.

java
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;

enum ChargeKind { ROOM_NIGHT, LAUNDRY, RESTAURANT, LATE_CHECKOUT, CORRECTION }

// One line on the bill. Records cannot be changed after they are made.
record Charge(ChargeKind kind, String description, BigDecimal amount, LocalDate date) {}

interface TaxRule {
    BigDecimal taxOn(Charge c);
}

public final class Folio {
    private final List<Charge> charges = new ArrayList<>();
    private final List<BigDecimal> payments = new ArrayList<>();
    private final TaxRule taxRule;

    public Folio(TaxRule taxRule) { this.taxRule = taxRule; }

    public void addCharge(Charge c) { charges.add(c); }
    public void addPayment(BigDecimal amount) { payments.add(amount); }

    public BigDecimal balanceDue() {
        BigDecimal total = BigDecimal.ZERO;
        for (Charge c : charges) {
            total = total.add(c.amount()).add(taxRule.taxOn(c));
        }
        for (BigDecimal p : payments) {
            total = total.subtract(p);
        }
        return total.setScale(2, RoundingMode.HALF_UP);   // tax lines are already whole paise; this shows two decimals
    }
}

Two choices matter most. Folio never knows a tax rate; it asks its TaxRule. And there is no remove or edit method, so a wrong laundry charge is fixed with a CORRECTION line and the bill keeps its history.

Here is the tax rule this hotel uses for GST (Goods and Services Tax, India's tax on most goods and services). The rates come in from settings, so it holds no number of its own.

java
import java.math.BigDecimal;
import java.math.RoundingMode;

// Rates come from settings. A GST change is a settings change, not a code change.
public record RoomGstRule(BigDecimal upToPerNight, BigDecimal rate) implements TaxRule {

    public BigDecimal taxOn(Charge c) {
        if (c.kind() != ChargeKind.ROOM_NIGHT) {
            return BigDecimal.ZERO;       // other lines are entered tax included in this design
        }
        if (c.amount().compareTo(upToPerNight) > 0) {
            throw new IllegalStateException("No rate set for a room night of Rs " + c.amount());
        }
        return c.amount().multiply(rate).setScale(2, RoundingMode.HALF_UP);   // round each tax line to the paisa
    }
}

RoomGstRule taxes only room nights. Other lines are entered tax included, to keep the example small. A fuller rule would pick a rate by ChargeKind, and would reverse the tax when a CORRECTION line undoes a room night.

Where you round matters. We first rounded only the final balance, and a Rs 2,999.70 night showed Rs 3,149.69 due. Paying exactly that left minus Rs 0.01, so checkout was refused.

The 5% tax on that night is Rs 149.985, which is not whole paise. Rounding each tax line inside the TaxRule keeps every line and the balance in whole paise, so paying the balance leaves zero.

Each night is its own line for a reason. The GST slab for rooms, the rate band it falls in, goes by the value per room per day. A rule that saw one Rs 14,400 line for four nights would pick the wrong slab.

The demo sets only that one slab. A room night above Rs 7,500 makes RoomGstRule throw, which is safer than charging a rate typed from memory.

Here is the family's folio rebuilt the right way: one Standard room at Rs 3,600 a night for four nights.

The family's folio at checkout, amounts in rupees (made up)
LineKindAmount
Room nights: 4 lines of 3,600Charge14,400
GST on room nights at 5%Tax720
Laundry, first bag, tax includedCharge350
Laundry, second bag, tax includedCharge250
Restaurant bills, tax includedCharge3,150
Advance paid at bookingPaymentminus 5,000
Balance due at checkoutTotal13,870

The old spreadsheet missed both laundry lines and subtracted the advance twice, so it asked for Rs 8,270 instead of Rs 13,870. With one folio and append-only lines, each charge is entered once and the advance appears once.

You can run the whole story. Hotel is left out of the code, because it only holds room types. Put Stay.java, Model.java, Booking.java, RoomTypeInventory.java, Folio.java, RoomGstRule.java and HotelDemo.java in one folder. Run javac *.java, then java HotelDemo.

java
import java.math.BigDecimal;
import java.time.LocalDate;

public class HotelDemo {
    public static void main(String[] args) {
        LocalDate nov12 = LocalDate.of(2026, 11, 12);
        TaxRule gst = new RoomGstRule(new BigDecimal("7500"), new BigDecimal("0.05"));

        // 1. Two Deluxe rooms, two back-to-back one-night bookings, then a two-night ask
        RoomType deluxe = new RoomType("Deluxe", 2, new BigDecimal("4800"));
        RoomTypeInventory inv = new RoomTypeInventory(deluxe, 2, gst);
        inv.book(new Guest("A", "1"), new Stay(nov12, nov12.plusDays(1)));
        inv.book(new Guest("B", "2"), new Stay(nov12.plusDays(1), nov12.plusDays(2)));
        Stay ask = new Stay(nov12, nov12.plusDays(2));
        System.out.println("Overlap count says free: " + inv.freeByOverlapCount(ask));
        System.out.println("Per-night count says free: " + inv.isFree(ask));

        // 2. A family: one Standard room for 4 nights at Rs 3,600 a night
        RoomType standard = new RoomType("Standard", 3, new BigDecimal("3600"));
        RoomTypeInventory std = new RoomTypeInventory(standard, 20, gst);
        Booking family = std.book(new Guest("Family", "3"), new Stay(nov12, nov12.plusDays(4))).orElseThrow();
        Folio f = family.folio();
        f.addPayment(new BigDecimal("5000"));                          // advance at booking
        Room r = new Room("204", standard);
        family.checkIn(r);
        for (int night = 0; night < family.stay().nights(); night++) {
            f.addCharge(new Charge(ChargeKind.ROOM_NIGHT, "Room night", standard.ratePerNight(), nov12.plusDays(night)));
        }
        f.addCharge(new Charge(ChargeKind.LAUNDRY, "Laundry bag 1", new BigDecimal("350"), nov12.plusDays(1)));
        f.addCharge(new Charge(ChargeKind.LAUNDRY, "Laundry bag 2", new BigDecimal("250"), nov12.plusDays(2)));
        f.addCharge(new Charge(ChargeKind.RESTAURANT, "Restaurant bills", new BigDecimal("3150"), nov12.plusDays(3)));
        System.out.println("Balance due: " + f.balanceDue());

        try {
            family.checkOut();
        } catch (IllegalStateException e) {
            System.out.println("Checkout refused: " + e.getMessage());
        }
        f.addPayment(f.balanceDue());
        family.checkOut();
        System.out.println(family.status() + ", room " + r.number + " is " + r.status);

        try {
            family.cancel();
        } catch (IllegalStateException e) {
            System.out.println("Cancel refused: " + e.getMessage());
        }
    }
}

On Java 21 it prints these six lines:

  • Overlap count says free: false
  • Per-night count says free: true
  • Balance due: 13870.00
  • Checkout refused: Balance due: 13870.00
  • CHECKED_OUT, room 204 is VACANT_DIRTY
  • Cancel refused: Not allowed while CHECKED_OUT

What just happened? The two-room example shows the overlap count refusing a stay that fits. The folio matches the table to the paisa. Checkout was refused while money was owed, the room was left dirty for housekeeping, and a late cancel was refused.

At the desk, checkout is a short, fixed routine around that method:

  1. Close the nights

    Add a room-night charge for each night not yet billed, at the rate used that night. A late checkout fee goes on as its own line.

  2. Show the folio

    Print every line with tax, payments and the balance. The guest checks it before paying.

  3. Take the payment

    Record the payment on the folio. If the balance is not zero, stop: collect the rest, or record a refund if the guest overpaid.

  4. Call checkOut

    It moves the booking to CHECKED_OUT only when the balance is zero, and marks the room vacant and dirty.

  5. Tell housekeeping

    The dirty room goes on the cleaning list, so nobody checks in before it is cleaned.

Beginners can stop here. You now have the classes, the date rule, the states and the bill, which is a complete interview answer. The rest is for engineers: tests of the availability rule, a race between two desks, and the same guard inside a database.

Why does counting overlapping bookings turn guests away?

Counting overlaps treats every booking that touches the request as needing its own room. But two bookings can both overlap the request without overlapping each other, so they could share one room.

The fair question is the busiest night. Scheduling theory calls it the depth: the largest number of stays covering any one point in time. Kevin Wayne's slides for the Kleinberg and Tardos textbook Algorithm Design prove that depth rooms are always enough.

The proof hands out stays in order of start time and gives each one any free room. That is exactly how check-in works: guests arrive in date order and get any clean room of their type. So if no night is over its count, every arriving guest finds a room, and nobody is moved mid-stay.

We tested both rules in Python against that ground truth. Across 40,000 random cases with 1 to 4 rooms, the per-night rule matched the truth every time. The overlap count never oversold, but refused 1,349 stays that fit.

A second test booked stays in random order, then ran check-in day by day, giving each arriving guest a random free room. In 20,000 runs with 136,421 arrivals, no guest ever found the type full.

Two availability rules for one room type, from our 40,000-case test
RuleEver oversold?Refused stays that fit?Work per request
Count bookings that overlap the requestNo, 0 casesYes, 1,349 casesOne pass over the bookings
Count bookings on each night of the requestNo, 0 casesNo, 0 casesOne pass per night of the stay

How do you stop two front desks selling the last room?

Make the check and the save one indivisible step. Inside one Java program, the synchronized keyword on book does that. Across several servers, the database must do it.

A race condition is a bug where the result depends on which of two requests runs first. Two desks both read the count, both see one room left, and both save a booking.

A synchronized method lets only one thread, one line of work in the program, run it on a given object at a time. Other threads calling it on that object wait. So book is safe as long as each room type has exactly one inventory object.

We tested it on Java 21 with two threads booking the last Deluxe room at once, 2,000 rounds per run. The synchronized book never double-booked. With synchronized removed, 7 and 14 rounds double-booked in two runs. These small numbers vary by machine and run.

With a 1 ms pause between check and save, like a slow database call, the version without synchronized double-booked 1,974 and 1,964 times out of 2,000. The synchronized one stayed at zero. A rare race becomes common as soon as anything slow sits in the middle.

One gap remains in this sketch. Booking's checkIn, cancel and markNoShow run outside the inventory's lock. So two desks could even hand one room to two guests, the room 204 bug again.

We tested two desks checking in to one clean room at once. Across three runs of 2,000 rounds, both desks got the room once. Rare is not never. In a multi-threaded program, route those moves through RoomTypeInventory too.

Move the guard into the database

Once several servers share one database, synchronized protects nothing, because each server holds its own lock. Keep one row per room type per night, with the rooms and the count booked. Then let a CHECK constraint, a rule the database tests on every row it writes, refuse an overbooked night.

sql
CREATE TABLE room_night (
    room_type  text NOT NULL,
    night      date NOT NULL,
    rooms      int  NOT NULL,              -- sellable rooms of this type that night
    booked     int  NOT NULL DEFAULT 0,
    PRIMARY KEY (room_type, night),
    CHECK (booked >= 0 AND booked <= rooms)
);

CREATE TABLE booking (
    id         bigserial PRIMARY KEY,
    room_type  text NOT NULL,
    check_in   date NOT NULL,
    check_out  date NOT NULL CHECK (check_out > check_in),
    status     text NOT NULL DEFAULT 'RESERVED'
);

INSERT INTO room_night (room_type, night, rooms)
SELECT 'Deluxe', d::date, 18
FROM generate_series(date '2026-11-01', date '2026-11-30', interval '1 day') AS d;

Booking runs in a transaction, a group of statements that take effect together at COMMIT or not at all. It adds one to every night of the stay in a single UPDATE. If any night would go over, the CHECK fails, the whole statement is undone and no night changes.

sql
BEGIN;
UPDATE room_night
SET booked = booked + 1
WHERE room_type = 'Deluxe'
  AND night >= date '2026-11-12' AND night < date '2026-11-14';
-- Expect UPDATE 2, one row per night. On an error or any other count: ROLLBACK.
INSERT INTO booking (room_type, check_in, check_out)
VALUES ('Deluxe', date '2026-11-12', date '2026-11-14');
COMMIT;

Check the count PostgreSQL prints. UPDATE 2 means both nights were held. A lower count means some nights have no row yet, for example beyond the loaded calendar, so roll back.

We ran this on PostgreSQL 16.15 with 17 of 18 rooms booked on 13 November. Desk A updated both nights, then slept 2 seconds before committing. Desk B started half a second later and waited about 1.5 seconds.

Then desk B failed with error 23514, a check constraint violation, on the 13 November row with 19 booked. Its transaction rolled back, so 12 November kept its count too. The hotel ended with one booking and 18 of 18 rooms booked.

This works at Read Committed, PostgreSQL's default isolation level, which sets how much one transaction sees of others. The PostgreSQL docs say a waiting UPDATE uses the newest version of the row once the first transaction commits.

Under Repeatable Read, the second desk instead got the error could not serialize access due to concurrent update, even with a room left. Code at that level must retry the whole transaction.

At any level, two bookings for overlapping nights can lock the same night rows in different orders. PostgreSQL then stops one with a deadlock error, so the booking code should retry it.

We forced that order on PostgreSQL 16.15 and got deadlock detected. The other booking committed, and the stopped one succeeded when run again. Nothing was oversold either way.

Cancel must give nights back exactly once

Cancelling has its own trap. A two-statement cancel sets the status, then subtracts one from each night. Run it twice and the second run still subtracts, because nothing checks that the first statement changed a row.

In our test two bookings held 12 and 13 November. Running the two-statement cancel twice on booking 1 left both nights at 0 booked, while booking 2 was still RESERVED. The hotel would sell that room again.

The fix is one statement. The status change returns the cancelled booking's dates, and the counts change only for what it returned.

sql
WITH cancelled AS (
    UPDATE booking SET status = 'CANCELLED'
    WHERE id = 1 AND status = 'RESERVED'
    RETURNING room_type, check_in, check_out
)
UPDATE room_night r
SET booked = r.booked - 1
FROM cancelled c
WHERE r.room_type = c.room_type
  AND r.night >= c.check_in AND r.night < c.check_out;

Run twice, it prints UPDATE 2 and then UPDATE 0, leaving each night at 1 booked for booking 2. Two desks cancelling at once gave the same result: the second waited, saw the status was no longer RESERVED and changed nothing. A no-show job should give nights back with the same kind of single statement.

Which design patterns fit a hotel management LLD?

Four patterns fit naturally: Strategy for tax and prices, State for booking status, Observer for housekeeping alerts and Repository for saving objects. Use each one only where it removes a real tangle of checks.

  • Strategy: one swappable piece of behaviour behind an interface. TaxRule already is one. A PricingRule for festival and long-weekend rates would be another.
  • State: each booking status becomes its own class that knows its allowed moves. With five states and the simple checks above, the enum is clearer; switch when each state gains its own fees and messages.
  • Observer: when a room turns vacant and dirty, a housekeeping listener is told. Booking then does not need to know housekeeping exists.
  • Repository: one class hides how bookings are saved and loaded. Production uses the database, while tests use an in-memory list like the one in RoomTypeInventory.

Pricing needs one more line. RoomType holds a rate per night, and a PricingRule can raise it in the busy season. The folio stores the price actually charged, so a rule change never rewrites an old bill.

How do you explain hotel management LLD in a system design interview?

Walk from requirements to classes to two or three key methods, and keep tying each class to the one rule it protects.

  1. Ask what is in scope

    One hotel or a chain? Front desk only, or online booking too? Is billing part of it? Settle this in the first two minutes.

  2. List the nouns

    Hotel, room type, room, guest, booking, stay, folio and charge. Add RoomTypeInventory as the one place that decides whether a type is free.

  3. Draw the class map

    Show that a booking holds a room type first and a room only from check-in. Explain why with the broken geyser.

  4. Write Stay and the per-night check

    Half-open dates with overlaps and covers, then an isFree that checks every night. Say why counting overlaps turns guests away.

  5. Draw the booking states

    Five states, walk-ins through the same book method, and a nightly no-show job using the hotel's own date.

  6. Show the folio

    BigDecimal built from strings, one line per night, append-only lines, a swappable tax rule and tax rounded to the paisa on each line.

  7. Name the limits

    Synchronized for one program, then per-night rows with a CHECK constraint once many servers share one database.

Questions people ask

Is a hotel management system the same as a hotel booking system?
Not quite. A hotel management system runs one hotel or chain: rooms, front desk, housekeeping and billing. A hotel booking system, like the ones travel sites run, focuses on search and availability across many hotels and many servers. The per-night availability rule is shared, but the scale problems are different.
How do you stop double booking in a hotel LLD?
Make the check and the save one step, and check every night. For each night of the requested stay, count the active bookings of that room type and save only if every night has a room left. Do it inside a synchronized method in one program, or as one UPDATE on per-night rows with a CHECK constraint in a shared database. Booking room types instead of room numbers also stops a room swap from breaking a promise.
Why use half-open date ranges for hotel stays?
Because hotel nights already work that way. A guest checking out on the 14th does not use the night of the 14th, so another guest can check in that day. Half-open ranges make the overlap test one line, the night count a simple subtraction, and datesUntil in Java lists exactly the paid nights.
Should I use the State pattern for hotel booking status?
Use it when each state has lots of its own behaviour. With five states and simple rules, an enum plus a status check in each method is clearer. In an interview, mention the State pattern and say when you would switch to it, such as when each state gains its own fees and messages.
Why use BigDecimal instead of double for hotel bills?
A double stores numbers in binary, so most decimal amounts, like 0.1, are not exact, and 0.1 + 0.2 prints 0.30000000000000004. BigDecimal keeps exact decimal values, and you decide when and how to round. Build it from strings, because new BigDecimal(0.1) copies the double's error.
How do you handle a guest who extends their stay?
Check availability for the extra nights only, using a Stay from the old check-out date to the new one. If every extra night has a room of that type, hold those nights through the inventory, then give the booking a new Stay. A Stay never changes; you swap in a new one. If the guest's own room is taken on those nights, the desk moves them, which works because the booking holds a type.
What should a hotel management LLD answer include?
Clear classes with one owner per rule, half-open stays, a per-night availability check, a booking state diagram and a folio for billing. Add short Java for Stay, the check and the bill. Close by naming where the lock lives in one program, and what moves into the database once many servers share the data.

Sources and further reading

Practise this with Lala

Practise