LLD Question 06
Hotel Management System
Design a hotel booking system with room types, guests, date-range availability checks, double-booking prevention, check-in/check-out lifecycle, and invoice generation.
Learning note
DateRange as an immutable value object with an overlaps() method keeps availability logic clean and testable independent of booking or hotel logic.
Problem Statement
Design a hotel management system where guests can search for available rooms, make bookings, check in, check out, and receive an invoice. The system must prevent double bookings.
Functional Requirements
- Rooms have a type, base price per night, and capacity.
- Guests can search available rooms by type and date range.
- A booking blocks a room for the requested dates.
- The same room cannot be booked by two guests for overlapping dates.
- Back-to-back bookings are allowed.
- Guests can check in and check out.
- Check-out generates an invoice with room charge and tax.
- A confirmed booking can be cancelled to free the room.
Core Entities
| Entity | Responsibility |
|---|---|
RoomType | Enum for STANDARD, DELUXE, and SUITE. |
BookingStatus | Enum for CONFIRMED, CHECKED_IN, CHECKED_OUT, and CANCELLED. |
DateRange | Immutable value object for a stay period with overlap detection. |
Guest | Holds guest identity: name, email, and phone. |
Room | Holds room number, type, base price per night, and capacity. |
PricingStrategy | Interface for price calculation. |
StandardPricingStrategy | Calculates price as base price times number of nights. |
Invoice | Calculates and prints room charge, tax, and total. |
Booking | Holds guest, room, dates, and status. Manages lifecycle transitions. |
Hotel | Singleton. Owns rooms and bookings, enforces availability and double-booking prevention. |
Design Decisions
DateRange as Value Object
DateRange is immutable. It owns the overlap logic as a method:
public boolean overlaps(DateRange other) {
return this.checkIn.isBefore(other.checkOut)
&& other.checkIn.isBefore(this.checkOut);
}Two half-open ranges [s1, e1) and [s2, e2) overlap if and only if s1 < e2 and s2 < e1. Back-to-back bookings where one ends exactly when the next begins have no overlap.
Double-Booking Prevention
Hotel.bookRoom() is synchronized. The availability check and booking creation happen atomically:
public synchronized Booking bookRoom(Guest guest, Room room, DateRange range) {
if (!isRoomAvailable(room, range)) { throw ... }
Booking booking = new Booking(guest, room, range);
bookings.put(booking.getBookingId(), booking);
return booking;
}Without the synchronized keyword two threads could both pass the availability check and both insert overlapping bookings.
Booking Owns Its Lifecycle
Booking enforces valid transitions:
CONFIRMED -> CHECKED_IN -> CHECKED_OUT
CONFIRMED -> CANCELLEDAttempting an invalid transition throws IllegalStateException. This keeps lifecycle rules in one place.
Strategy for Pricing
Hotel depends on PricingStrategy. Swapping in a weekend, seasonal, or surge pricing strategy requires only changing which implementation is injected.
Main Flow
- Create a
Hotelinstance and add rooms. - Create guests.
- Search available rooms by type and date range.
- Book a room with
bookRoom(). - Attempt overlapping booking to see rejection.
- Check in with
checkIn(). - Check out with
checkOut()which returns anInvoice. - Cancel a booking to free the room for those dates.
Complete Code: Bottom-Up
1. RoomType.java
public enum RoomType {
STANDARD,
DELUXE,
SUITE
}2. BookingStatus.java
public enum BookingStatus {
CONFIRMED,
CHECKED_IN,
CHECKED_OUT,
CANCELLED
}3. DateRange.java
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
// Immutable value object: [checkIn, checkOut)
// checkIn is inclusive, checkOut is exclusive.
// A guest staying Jan 1 -> Jan 3 occupies nights of Jan 1 and Jan 2.
// Another guest CAN check in on Jan 3.
public final class DateRange {
private final LocalDate checkIn;
private final LocalDate checkOut;
public DateRange(LocalDate checkIn, LocalDate checkOut) {
if (checkIn == null || checkOut == null) {
throw new IllegalArgumentException("Dates cannot be null");
}
if (!checkOut.isAfter(checkIn)) {
throw new IllegalArgumentException("Check-out must be after check-in");
}
this.checkIn = checkIn;
this.checkOut = checkOut;
}
// Two half-open ranges [s1, e1) and [s2, e2) overlap if: s1 < e2 AND s2 < e1
public boolean overlaps(DateRange other) {
return this.checkIn.isBefore(other.checkOut)
&& other.checkIn.isBefore(this.checkOut);
}
public long getNights() {
return ChronoUnit.DAYS.between(checkIn, checkOut);
}
public LocalDate getCheckIn() {
return checkIn;
}
public LocalDate getCheckOut() {
return checkOut;
}
@Override
public String toString() {
return "[" + checkIn + " -> " + checkOut + ")";
}
}4. Guest.java
import java.util.UUID;
public class Guest {
private final String guestId;
private final String name;
private final String email;
private final String phone;
public Guest(String name, String email, String phone) {
this.guestId = UUID.randomUUID().toString().substring(0, 8);
this.name = name;
this.email = email;
this.phone = phone;
}
public String getGuestId() { return guestId; }
public String getName() { return name; }
public String getEmail() { return email; }
public String getPhone() { return phone; }
@Override
public String toString() {
return name + " (" + guestId + ")";
}
}5. Room.java
public class Room {
private final String roomNumber;
private final RoomType type;
private final double basePricePerNight;
private final int capacity;
public Room(String roomNumber, RoomType type, double basePricePerNight, int capacity) {
this.roomNumber = roomNumber;
this.type = type;
this.basePricePerNight = basePricePerNight;
this.capacity = capacity;
}
public String getRoomNumber() { return roomNumber; }
public RoomType getType() { return type; }
public double getBasePricePerNight() { return basePricePerNight; }
public int getCapacity() { return capacity; }
@Override
public String toString() {
return "Room " + roomNumber + " (" + type + ", $" + basePricePerNight + "/night)";
}
}6. PricingStrategy.java
public interface PricingStrategy {
double calculatePrice(Room room, DateRange dateRange);
}7. StandardPricingStrategy.java
// Simple pricing: basePrice * nights.
// Strategy pattern lets us swap in WeekendPricing, SeasonalPricing, etc.
// without touching the booking flow.
public class StandardPricingStrategy implements PricingStrategy {
@Override
public double calculatePrice(Room room, DateRange dateRange) {
return room.getBasePricePerNight() * dateRange.getNights();
}
}8. Invoice.java
import java.util.UUID;
public class Invoice {
private static final double TAX_RATE = 0.10;
private final String invoiceId;
private final Booking booking;
private final double roomCharge;
private final double tax;
private final double totalAmount;
public Invoice(Booking booking, double roomCharge) {
this.invoiceId = UUID.randomUUID().toString().substring(0, 8);
this.booking = booking;
this.roomCharge = roomCharge;
this.tax = roomCharge * TAX_RATE;
this.totalAmount = roomCharge + tax;
}
public String getInvoiceId() { return invoiceId; }
public Booking getBooking() { return booking; }
public double getTotalAmount() { return totalAmount; }
public void print() {
System.out.println("----------- INVOICE " + invoiceId + " -----------");
System.out.println("Guest : " + booking.getGuest().getName());
System.out.println("Room : " + booking.getRoom().getRoomNumber()
+ " (" + booking.getRoom().getType() + ")");
System.out.println("Stay : " + booking.getDateRange()
+ " (" + booking.getDateRange().getNights() + " nights)");
System.out.printf("Room charge: $%.2f%n", roomCharge);
System.out.printf("Tax (10%%) : $%.2f%n", tax);
System.out.printf("TOTAL : $%.2f%n", totalAmount);
System.out.println("------------------------------------------");
}
}9. Booking.java
import java.util.UUID;
public class Booking {
private final String bookingId;
private final Guest guest;
private final Room room;
private final DateRange dateRange;
private BookingStatus status;
public Booking(Guest guest, Room room, DateRange dateRange) {
this.bookingId = UUID.randomUUID().toString().substring(0, 8);
this.guest = guest;
this.room = room;
this.dateRange = dateRange;
this.status = BookingStatus.CONFIRMED;
}
// A booking blocks a room only while CONFIRMED or CHECKED_IN.
public boolean isActive() {
return status == BookingStatus.CONFIRMED || status == BookingStatus.CHECKED_IN;
}
public boolean conflictsWith(Room room, DateRange range) {
return isActive()
&& this.room.getRoomNumber().equals(room.getRoomNumber())
&& this.dateRange.overlaps(range);
}
public void cancel() {
if (status != BookingStatus.CONFIRMED) {
throw new IllegalStateException("Only confirmed bookings can be cancelled");
}
status = BookingStatus.CANCELLED;
}
public void checkIn() {
if (status != BookingStatus.CONFIRMED) {
throw new IllegalStateException("Only confirmed bookings can check in");
}
status = BookingStatus.CHECKED_IN;
}
public void checkOut() {
if (status != BookingStatus.CHECKED_IN) {
throw new IllegalStateException("Guest must be checked in to check out");
}
status = BookingStatus.CHECKED_OUT;
}
public String getBookingId() { return bookingId; }
public Guest getGuest() { return guest; }
public Room getRoom() { return room; }
public DateRange getDateRange() { return dateRange; }
public BookingStatus getStatus() { return status; }
@Override
public String toString() {
return "Booking " + bookingId + " | " + guest.getName() + " | "
+ room.getRoomNumber() + " | " + dateRange + " | " + status;
}
}10. Hotel.java
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
// Central service (Singleton). Owns rooms and bookings.
// Double-booking prevention is enforced via synchronized bookRoom().
public class Hotel {
private static Hotel instance;
private final Map<String, Room> rooms = new ConcurrentHashMap<>();
private final Map<String, Booking> bookings = new ConcurrentHashMap<>();
private PricingStrategy pricingStrategy = new StandardPricingStrategy();
private Hotel() {}
public static synchronized Hotel getInstance() {
if (instance == null) {
instance = new Hotel();
}
return instance;
}
public void setPricingStrategy(PricingStrategy pricingStrategy) {
this.pricingStrategy = pricingStrategy;
}
public void addRoom(Room room) {
rooms.put(room.getRoomNumber(), room);
}
public boolean isRoomAvailable(Room room, DateRange range) {
for (Booking booking : bookings.values()) {
if (booking.conflictsWith(room, range)) {
return false;
}
}
return true;
}
public List<Room> searchAvailableRooms(RoomType type, DateRange range) {
return rooms.values().stream()
.filter(room -> type == null || room.getType() == type)
.filter(room -> isRoomAvailable(room, range))
.collect(Collectors.toList());
}
// DOUBLE-BOOKING PREVENTION:
// Availability check and booking creation are atomic inside synchronized.
// Without this, two threads could both pass the check and both insert.
public synchronized Booking bookRoom(Guest guest, Room room, DateRange range) {
if (!rooms.containsKey(room.getRoomNumber())) {
throw new IllegalArgumentException("Unknown room: " + room.getRoomNumber());
}
if (!isRoomAvailable(room, range)) {
throw new IllegalStateException(
"Room " + room.getRoomNumber() + " is not available for " + range);
}
Booking booking = new Booking(guest, room, range);
bookings.put(booking.getBookingId(), booking);
return booking;
}
public void cancelBooking(String bookingId) {
getBookingOrThrow(bookingId).cancel();
}
public void checkIn(String bookingId) {
getBookingOrThrow(bookingId).checkIn();
}
public Invoice checkOut(String bookingId) {
Booking booking = getBookingOrThrow(bookingId);
booking.checkOut();
double roomCharge = pricingStrategy.calculatePrice(booking.getRoom(), booking.getDateRange());
return new Invoice(booking, roomCharge);
}
public List<Booking> getBookingsForGuest(Guest guest) {
return bookings.values().stream()
.filter(b -> b.getGuest().getGuestId().equals(guest.getGuestId()))
.collect(Collectors.toList());
}
private Booking getBookingOrThrow(String bookingId) {
Booking booking = bookings.get(bookingId);
if (booking == null) {
throw new IllegalArgumentException("Booking not found: " + bookingId);
}
return booking;
}
public List<Room> getAllRooms() {
return new ArrayList<>(rooms.values());
}
}11. Main.java
import java.time.LocalDate;
import java.util.List;
public class Main {
public static void main(String[] args) {
Hotel hotel = Hotel.getInstance();
Room r101 = new Room("101", RoomType.STANDARD, 100.0, 2);
Room r102 = new Room("102", RoomType.STANDARD, 100.0, 2);
Room r201 = new Room("201", RoomType.DELUXE, 200.0, 3);
Room r301 = new Room("301", RoomType.SUITE, 400.0, 4);
hotel.addRoom(r101);
hotel.addRoom(r102);
hotel.addRoom(r201);
hotel.addRoom(r301);
Guest alice = new Guest("Alice", "alice@mail.com", "111-1111");
Guest bob = new Guest("Bob", "bob@mail.com", "222-2222");
// 1. Search available STANDARD rooms for Jul 1 -> Jul 5
DateRange aliceStay = new DateRange(LocalDate.of(2026, 7, 1), LocalDate.of(2026, 7, 5));
System.out.println("== Available STANDARD rooms for " + aliceStay + " ==");
hotel.searchAvailableRooms(RoomType.STANDARD, aliceStay).forEach(System.out::println);
// 2. Alice books room 101
Booking aliceBooking = hotel.bookRoom(alice, r101, aliceStay);
System.out.println("\nBooked: " + aliceBooking);
// 3. Bob tries overlapping booking on same room (Jul 4 -> Jul 6) -> REJECTED
System.out.println("\n== Bob tries overlapping booking on 101 (Jul 4 -> Jul 6) ==");
try {
hotel.bookRoom(bob, r101, new DateRange(LocalDate.of(2026, 7, 4), LocalDate.of(2026, 7, 6)));
} catch (IllegalStateException e) {
System.out.println("REJECTED: " + e.getMessage());
}
// 4. Bob books back-to-back starting Jul 5 (Alice's check-out day) -> ALLOWED
System.out.println("\n== Bob books 101 back-to-back (Jul 5 -> Jul 7) ==");
Booking bobBooking = hotel.bookRoom(bob, r101,
new DateRange(LocalDate.of(2026, 7, 5), LocalDate.of(2026, 7, 7)));
System.out.println("Booked: " + bobBooking);
// 5. Search again: room 101 no longer available for aliceStay
System.out.println("\n== Available STANDARD rooms for " + aliceStay + " (after Alice's booking) ==");
hotel.searchAvailableRooms(RoomType.STANDARD, aliceStay).forEach(System.out::println);
// 6. Alice checks in, then checks out -> invoice generated
hotel.checkIn(aliceBooking.getBookingId());
System.out.println("\nAlice checked in: " + aliceBooking.getStatus());
Invoice invoice = hotel.checkOut(aliceBooking.getBookingId());
System.out.println("Alice checked out: " + aliceBooking.getStatus() + "\n");
invoice.print();
// 7. Bob cancels -> room 101 free again for Jul 5 -> Jul 7
System.out.println("\n== Bob cancels; room 101 free again for Jul 5 -> Jul 7 ==");
hotel.cancelBooking(bobBooking.getBookingId());
DateRange bobRange = new DateRange(LocalDate.of(2026, 7, 5), LocalDate.of(2026, 7, 7));
System.out.println("Room 101 available for " + bobRange + "? "
+ hotel.isRoomAvailable(r101, bobRange));
}
}What I Learned
- An immutable
DateRangevalue object with anoverlaps()method keeps availability logic clean and testable without depending onBookingorHotel. synchronizedonbookRoom()is essential to prevent a check-then-act race condition in concurrent environments.- Booking lifecycle enforced inside
Bookingitself meansHotelnever has to know which transitions are valid. PricingStrategykeepsHotel.checkOut()independent of how prices are calculated.
Possible Improvements
- Add a
RoomServiceto separate room management from booking management. - Add
numGueststo booking and validate against room capacity. - Add a promotion/discount layer to
PricingStrategy. - In a distributed system, replace
synchronizedwith a database row lock or a distributed lock per room. - Add unit tests for overlap edge cases, back-to-back bookings, and invalid lifecycle transitions.