Loading learning content...
With requirements clearly understood, we now transition to entity identification and design—the foundational step in Low-Level Design. The entities we define here will shape every subsequent design decision, from class hierarchies to database schemas to API contracts.
A hotel booking system's domain model must capture the essential real-world concepts while remaining tractable for software implementation. We'll apply Domain-Driven Design principles: extracting entities from the problem space, defining their responsibilities precisely, and establishing clear boundaries between concepts.
By the end of this page, you will understand how to design the core entities of a hotel booking system—Hotel, Room, RoomType, Reservation, and Guest—with proper attributes, behaviors, and relationships. You'll learn why certain design choices prevent future problems and how to balance purity with pragmatism.
Before diving into individual entities, let's establish our identification methodology. We use noun extraction from requirements combined with domain expert validation to surface candidate entities.
From our requirements analysis, key nouns include:
Hotel, Room, Room Type, Reservation, Booking, Guest, Rate, Price, Date, Availability, Check-in, Check-out, Cancellation, Payment, Policy, Staff, Channel, Amenity, Promotion
Filtering criteria for entities:
| Concept | Type | Rationale |
|---|---|---|
| Hotel | Entity | Unique identity, persistent, has behavior (manages inventory) |
| Room | Entity | Unique identity (room number), state changes (available, occupied) |
| RoomType | Entity | Identity for referencing, but behavior is limited |
| Reservation | Entity | Primary domain entity with complex lifecycle |
| Guest | Entity | Unique identity, persists across reservations |
| Money/Price | Value Object | No identity, immutable, defined by value |
| DateRange | Value Object | No identity, immutable composite of dates |
| Address | Value Object | No identity, replaced as whole |
| ContactInfo | Value Object | No identity, can be embedded |
| Policy | Entity/VO | Could be either—depends on configuration needs |
These terms are often used interchangeably but represent slightly different concepts in some domains. A 'Reservation' might represent the initial hold, while 'Booking' indicates confirmed with payment. For our design, we'll use 'Reservation' as the primary entity with states that capture this lifecycle—avoiding two separate classes for what's essentially one evolving concept.
The Hotel entity represents a physical property—the top-level container for all booking-related operations. In a multi-property system (hotel chain), this becomes an aggregate root for property-specific data.
Core Responsibilities:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127
/** * Hotel entity - represents a physical hotel property * Acts as aggregate root for property-level operations */class Hotel { private readonly id: HotelId; private name: string; private code: string; // Unique property code (e.g., "NYC-GRAND") private address: Address; // Value object private contactInfo: ContactInfo; // Value object private timezone: Timezone; // IANA timezone (e.g., "America/New_York") // Configuration private checkInTime: LocalTime; // Default: 15:00 (3 PM) private checkOutTime: LocalTime; // Default: 11:00 (11 AM) private starRating: number; private facilities: Facility[]; // Pool, gym, restaurant, etc. // Inventory private roomTypes: Map<RoomTypeId, RoomType>; private rooms: Map<RoomNumber, Room>; // Policies private defaultCancellationPolicy: CancellationPolicy; private childPolicy: ChildPolicy; private petPolicy: PetPolicy; constructor(params: HotelCreationParams) { this.validateCreationParams(params); // ... initialization } // ============ Query Methods ============ /** * Check availability for given dates and requirements * This is a read operation - doesn't modify state */ checkAvailability(query: AvailabilityQuery): AvailabilityResult { // 1. Filter room types matching capacity/amenity requirements // 2. For each room type, check inventory across all dates // 3. Apply overbooking thresholds // 4. Return available options with prices } /** * Get all room types offered by this hotel */ getRoomTypes(): RoomType[] { return Array.from(this.roomTypes.values()); } /** * Convert hotel-local time to UTC for storage */ toUtc(localDateTime: LocalDateTime): Instant { return localDateTime.atZone(this.timezone).toInstant(); } /** * Convert UTC to hotel-local time for display */ toLocalTime(instant: Instant): LocalDateTime { return LocalDateTime.ofInstant(instant, this.timezone); } // ============ Command Methods ============ /** * Add a new room type to the hotel's offerings */ addRoomType(roomType: RoomType): void { if (this.roomTypes.has(roomType.id)) { throw new DuplicateRoomTypeError(roomType.id); } this.roomTypes.set(roomType.id, roomType); } /** * Add a physical room to inventory */ addRoom(room: Room): void { this.validateRoomTypeExists(room.roomTypeId); if (this.rooms.has(room.roomNumber)) { throw new DuplicateRoomNumberError(room.roomNumber); } this.rooms.set(room.roomNumber, room); } /** * Update check-in/checkout times * Affects future reservations only */ updateOperatingHours( checkInTime: LocalTime, checkOutTime: LocalTime ): void { this.validateOperatingHours(checkInTime, checkOutTime); this.checkInTime = checkInTime; this.checkOutTime = checkOutTime; } // ============ Private Helpers ============ private validateCreationParams(params: HotelCreationParams): void { if (!params.name?.trim()) { throw new InvalidHotelDataError("Hotel name is required"); } if (!params.timezone) { throw new InvalidHotelDataError("Timezone is required"); } // Additional validations... } private validateOperatingHours( checkIn: LocalTime, checkOut: LocalTime ): void { // Check-out must be before check-in to allow room turnover // Typical: checkout 11am, checkin 3pm = 4 hour turnover if (checkIn.isBefore(checkOut.plusHours(2))) { throw new InvalidOperatingHoursError( "Insufficient turnover time between checkout and checkin" ); } }}Hotel acts as an aggregate root for room inventory. External code should not directly modify Room entities—all changes go through Hotel methods. This ensures invariants like 'no duplicate room numbers' and 'room types must exist before rooms' are always enforced centrally.
RoomType represents a category of rooms with shared characteristics—a crucial abstraction for inventory management. Guests book room types, not specific rooms; room assignment happens at check-in.
Why RoomType is an Entity (not just an enum):
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
/** * RoomType entity - represents a category of rooms * Examples: Standard King, Deluxe Double, Executive Suite */class RoomType { private readonly id: RoomTypeId; private readonly hotelId: HotelId; private code: string; // Short code: "STD-K", "DLX-D" private name: string; // Display name: "Standard King Room" private description: string; // Capacity private baseOccupancy: number; // Default guests included in rate private maxOccupancy: number; // Maximum guests allowed private maxAdults: number; private maxChildren: number; // Physical attributes private bedConfiguration: BedConfiguration; // e.g., 1 King or 2 Queens private roomSize: SquareFootage; private view: ViewType; // Ocean, City, Garden, None private floor: FloorPreference; // High, Low, Any // Amenities and features private amenities: Set<Amenity>; // WiFi, TV, MiniBar, etc. private accessibilityFeatures: Set<AccessibilityFeature>; // Inventory management private totalInventory: number; // Physical room count of this type private overbookingThreshold: number; // Percentage allowed to overbook (e.g., 5%) // Pricing anchor private rackRate: Money; // Default/maximum price (reference point) constructor(params: RoomTypeCreationParams) { this.validateParams(params); // ... initialization } // ============ Core Methods ============ /** * Check if room type can accommodate the party */ canAccommodate(adults: number, children: number): boolean { const totalGuests = adults + children; return ( totalGuests <= this.maxOccupancy && adults <= this.maxAdults && children <= this.maxChildren ); } /** * Check if room type has all required amenities */ hasAmenities(required: Amenity[]): boolean { return required.every(amenity => this.amenities.has(amenity)); } /** * Calculate extra person charge for guests beyond base occupancy */ calculateExtraPersonCharge( adults: number, children: number, ratePerExtraAdult: Money, ratePerExtraChild: Money ): Money { const extraGuests = Math.max(0, adults + children - this.baseOccupancy); if (extraGuests === 0) return Money.zero(); // Prioritize adults as base, extras calculated proportionally const extraAdults = Math.max(0, adults - this.baseOccupancy); const extraChildren = Math.max(0, extraGuests - extraAdults); return ratePerExtraAdult.multiply(extraAdults) .add(ratePerExtraChild.multiply(extraChildren)); } /** * Get maximum overbooking count for this room type */ getMaxOverbookingCount(): number { return Math.floor(this.totalInventory * (this.overbookingThreshold / 100)); } /** * Check if meets accessibility requirements */ meetsAccessibilityNeeds(needs: AccessibilityFeature[]): boolean { return needs.every(need => this.accessibilityFeatures.has(need) ); } // ============ Modification Methods ============ updateDescription(name: string, description: string): void { if (!name?.trim()) throw new InvalidRoomTypeDataError("Name required"); this.name = name; this.description = description; } addAmenity(amenity: Amenity): void { this.amenities.add(amenity); } removeAmenity(amenity: Amenity): void { this.amenities.delete(amenity); } updateCapacity( baseOccupancy: number, maxOccupancy: number, maxAdults: number, maxChildren: number ): void { this.validateCapacity(baseOccupancy, maxOccupancy, maxAdults, maxChildren); this.baseOccupancy = baseOccupancy; this.maxOccupancy = maxOccupancy; this.maxAdults = maxAdults; this.maxChildren = maxChildren; } private validateCapacity( baseOccupancy: number, maxOccupancy: number, maxAdults: number, maxChildren: number ): void { if (baseOccupancy < 1) { throw new InvalidRoomTypeDataError("Base occupancy must be at least 1"); } if (maxOccupancy < baseOccupancy) { throw new InvalidRoomTypeDataError( "Max occupancy cannot be less than base occupancy" ); } if (maxAdults < 1) { throw new InvalidRoomTypeDataError("Must allow at least 1 adult"); } }} // Supporting typesinterface BedConfiguration { beds: BedSpec[];} interface BedSpec { type: BedType; // KING, QUEEN, DOUBLE, TWIN, SOFA_BED count: number;} type ViewType = 'OCEAN' | 'CITY' | 'GARDEN' | 'POOL' | 'NONE';type FloorPreference = 'HIGH' | 'LOW' | 'ANY';Notice that we track baseOccupancy (included in rate), maxOccupancy (physical limit), and separate adult/child maximums. A Deluxe Suite might allow 4 total guests but only 2 adults due to bed configuration. This granularity enables proper rate calculation and prevents physically impossible bookings.
Room represents a physical room—a specific unit that can be assigned to guests. While guests book room types, they ultimately occupy individual rooms.
Key Design Decision: Early vs. Late Room Assignment
There are two approaches:
We choose late assignment because:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
/** * Room entity - represents a physical room in the hotel * Room assignment happens at check-in, not at booking */class Room { private readonly id: RoomId; private readonly hotelId: HotelId; private readonly roomTypeId: RoomTypeId; private readonly roomNumber: RoomNumber; // e.g., "1204", "P-101" (P for penthouse) // Physical location private floor: number; private building: string; // For resorts with multiple buildings private wing: string; // East wing, West wing, etc. // Status private status: RoomStatus; private statusReason: string | null; // Why out of order, etc. private statusUntil: Instant | null; // When status expected to change // Assignment private currentReservationId: ReservationId | null; private currentGuestId: GuestId | null; // Features (may differ from room type defaults due to renovations) private actualBedConfiguration: BedConfiguration; private connectingRoomNumber: RoomNumber | null; private accessibilityModifications: AccessibilityFeature[]; constructor(params: RoomCreationParams) { this.validate(params); this.status = RoomStatus.AVAILABLE; // ... initialization } // ============ Status Management ============ /** * Check if room can be assigned for a given period */ isAvailableFor(checkIn: LocalDate, checkOut: LocalDate): boolean { if (this.status === RoomStatus.OUT_OF_ORDER) { // Check if blocked period overlaps if (this.statusUntil && this.statusUntil.isAfter(checkIn.atStartOfDay())) { return false; } } return this.status !== RoomStatus.PERMANENTLY_CLOSED; } /** * Mark room as out of order (maintenance, damage, etc.) */ markOutOfOrder(reason: string, until: Instant): void { if (this.currentReservationId) { throw new RoomOccupiedError( `Cannot mark room ${this.roomNumber} out of order - currently occupied` ); } this.status = RoomStatus.OUT_OF_ORDER; this.statusReason = reason; this.statusUntil = until; } /** * Return room to available inventory */ markAvailable(): void { if (this.currentReservationId) { throw new RoomOccupiedError( `Cannot change status - room ${this.roomNumber} is occupied` ); } this.status = RoomStatus.AVAILABLE; this.statusReason = null; this.statusUntil = null; } // ============ Assignment Management ============ /** * Assign room to a reservation (at check-in) */ assignToReservation( reservationId: ReservationId, guestId: GuestId ): void { if (!this.canBeAssigned()) { throw new RoomNotAvailableError( `Room ${this.roomNumber} cannot be assigned: ${this.status}` ); } if (this.currentReservationId) { throw new RoomAlreadyAssignedError( `Room ${this.roomNumber} already assigned to reservation ${this.currentReservationId}` ); } this.currentReservationId = reservationId; this.currentGuestId = guestId; this.status = RoomStatus.OCCUPIED; } /** * Release room (at check-out) */ release(): void { if (!this.currentReservationId) { throw new RoomNotAssignedError( `Room ${this.roomNumber} is not assigned to any reservation` ); } this.currentReservationId = null; this.currentGuestId = null; this.status = RoomStatus.NEEDS_CLEANING; // Housekeeping workflow } /** * Mark as cleaned and ready */ markClean(): void { if (this.status !== RoomStatus.NEEDS_CLEANING) { throw new InvalidRoomStateError( `Room ${this.roomNumber} does not need cleaning` ); } this.status = RoomStatus.AVAILABLE; } // ============ Query Methods ============ private canBeAssigned(): boolean { return [ RoomStatus.AVAILABLE, RoomStatus.NEEDS_CLEANING // Can assign, housekeeping will rush ].includes(this.status); } isOccupied(): boolean { return this.status === RoomStatus.OCCUPIED; } hasConnectingRoom(): boolean { return this.connectingRoomNumber !== null; }} /** * Room status enumeration */enum RoomStatus { AVAILABLE = 'AVAILABLE', // Ready for assignment OCCUPIED = 'OCCUPIED', // Guest checked in NEEDS_CLEANING = 'NEEDS_CLEANING', // Guest checked out, pending housekeeping OUT_OF_ORDER = 'OUT_OF_ORDER', // Temporarily unavailable (maintenance) PERMANENTLY_CLOSED = 'PERMANENTLY_CLOSED', // Room no longer in service} /** * Value object for room numbers * Encapsulates room number format and validation */class RoomNumber { private readonly value: string; constructor(value: string) { if (!this.isValid(value)) { throw new InvalidRoomNumberError(`Invalid room number: ${value}`); } this.value = value.toUpperCase(); } private isValid(value: string): boolean { // Allow alphanumeric room numbers (e.g., "1204", "P-101", "STE-A") return /^[A-Z0-9-]{1,10}$/.test(value.toUpperCase()); } equals(other: RoomNumber): boolean { return this.value === other.value; } toString(): string { return this.value; }}A common design mistake is conflating Room and RoomType. Remember: guests book RoomTypes (conceptual inventory), and physical Rooms are assigned at check-in. Your availability logic works with RoomTypes; your check-in logic works with Rooms. Keep these concerns cleanly separated.
The Guest entity represents a person who makes reservations. Unlike one-off transaction systems, hotels maintain ongoing relationships with guests—tracking preferences, history, and loyalty status.
Design Considerations:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
/** * Guest entity - represents a person who books/stays at hotels * Maintains profile, preferences, and history across stays */class Guest { private readonly id: GuestId; // Identity private title: Title; // Mr., Mrs., Dr., etc. private firstName: string; private lastName: string; private email: Email; // Primary identifier for deduplication private phone: Phone; private dateOfBirth: LocalDate | null; // For age verification, birthday recognition // Identity documents (for check-in verification) private identityDocuments: IdentityDocument[]; // Contact information private address: Address | null; private preferredLanguage: Language; private preferredCurrency: Currency; // Loyalty private loyaltyMemberId: string | null; private loyaltyTier: LoyaltyTier; private loyaltyPoints: number; // Preferences private preferences: GuestPreferences; // History private totalStays: number; private totalSpend: Money; private firstStayDate: LocalDate | null; private lastStayDate: LocalDate | null; // Internal flags private vipStatus: VIPStatus; private doNotRent: boolean; // Banned guest flag private doNotRentReason: string | null; private specialInstructions: string | null; // Timestamps private readonly createdAt: Instant; private updatedAt: Instant; constructor(params: GuestCreationParams) { this.validate(params); this.createdAt = Instant.now(); this.updatedAt = this.createdAt; this.loyaltyTier = LoyaltyTier.BASE; this.loyaltyPoints = 0; this.totalStays = 0; this.totalSpend = Money.zero(); this.vipStatus = VIPStatus.REGULAR; this.doNotRent = false; // ... initialization } // ============ Identity Methods ============ get fullName(): string { return `${this.title} ${this.firstName} ${this.lastName}`.trim(); } get displayName(): string { return `${this.firstName} ${this.lastName}`; } /** * Check if this guest can book (not banned) */ canBook(): boolean { return !this.doNotRent; } // ============ Loyalty Methods ============ /** * Award loyalty points for a stay */ awardPoints(points: number, reason: string): void { if (points <= 0) throw new InvalidPointsError("Points must be positive"); this.loyaltyPoints += points; this.evaluateTierUpgrade(); // Would also emit PointsAwarded event } /** * Redeem points for rewards */ redeemPoints(points: number, reason: string): void { if (points > this.loyaltyPoints) { throw new InsufficientPointsError( `Requested ${points}, available ${this.loyaltyPoints}` ); } this.loyaltyPoints -= points; // Note: tier downgrades typically happen on different schedule } private evaluateTierUpgrade(): void { // Tier thresholds - these would typically be configurable if (this.loyaltyPoints >= 100000 && this.totalStays >= 50) { this.loyaltyTier = LoyaltyTier.DIAMOND; } else if (this.loyaltyPoints >= 50000 && this.totalStays >= 25) { this.loyaltyTier = LoyaltyTier.PLATINUM; } else if (this.loyaltyPoints >= 20000 && this.totalStays >= 10) { this.loyaltyTier = LoyaltyTier.GOLD; } else if (this.loyaltyPoints >= 5000 && this.totalStays >= 5) { this.loyaltyTier = LoyaltyTier.SILVER; } } // ============ History Methods ============ /** * Record a completed stay */ recordStay(checkOut: LocalDate, spend: Money): void { this.totalStays += 1; this.totalSpend = this.totalSpend.add(spend); this.lastStayDate = checkOut; if (!this.firstStayDate) { this.firstStayDate = checkOut; } this.updatedAt = Instant.now(); } /** * Get guest's lifetime value category */ getValueSegment(): GuestValueSegment { if (this.totalSpend.isGreaterThan(Money.of(50000, 'USD'))) { return GuestValueSegment.HIGH_VALUE; } else if (this.totalSpend.isGreaterThan(Money.of(10000, 'USD'))) { return GuestValueSegment.MEDIUM_VALUE; } return GuestValueSegment.STANDARD; } // ============ Preference Methods ============ updatePreferences(preferences: Partial<GuestPreferences>): void { this.preferences = { ...this.preferences, ...preferences }; this.updatedAt = Instant.now(); } getPreference<K extends keyof GuestPreferences>( key: K ): GuestPreferences[K] { return this.preferences[key]; } // ============ Administrative Methods ============ /** * Flag guest as do-not-rent (ban) */ markDoNotRent(reason: string, authorizedBy: StaffId): void { this.doNotRent = true; this.doNotRentReason = reason; this.updatedAt = Instant.now(); // Would emit GuestBanned event for audit } /** * Remove do-not-rent flag */ clearDoNotRent(authorizedBy: StaffId): void { this.doNotRent = false; this.doNotRentReason = null; this.updatedAt = Instant.now(); }} // Supporting typesinterface GuestPreferences { roomFloor: 'HIGH' | 'LOW' | 'ANY'; bedType: BedType; pillowType: 'SOFT' | 'FIRM' | 'FEATHER' | 'HYPOALLERGENIC'; smokingRoom: boolean; quietRoom: boolean; nearElevator: boolean; dietaryRestrictions: string[]; allergies: string[]; specialNeeds: string[]; newspaperPreference: string | null;} enum LoyaltyTier { BASE = 'BASE', SILVER = 'SILVER', GOLD = 'GOLD', PLATINUM = 'PLATINUM', DIAMOND = 'DIAMOND',} enum VIPStatus { REGULAR = 'REGULAR', VIP = 'VIP', SUPER_VIP = 'SUPER_VIP',}Email is the primary deduplication key—if someone books with the same email, they're recognized as returning. However, be careful: a family sharing an email creates complications. Consider also matching on name + phone combinations. Real hotel systems often have manual merge capabilities for when automatic matching fails.
The Reservation entity is the core of our domain—the centerpiece that connects guests to rooms across time. It's the most complex entity, managing state transitions, pricing, modifications, and policies.
Key Design Principles:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
/** * Reservation entity - the core domain object * Represents a guest's booking for room(s) over a date range */class Reservation { // Identity private readonly id: ReservationId; private readonly confirmationNumber: ConfirmationNumber; private readonly hotelId: HotelId; // Guest information private readonly primaryGuestId: GuestId; private additionalGuestIds: GuestId[]; private guestCount: GuestCount; // Adults + children with ages // Booking details private readonly roomTypeId: RoomTypeId; private roomCount: number; // Number of rooms of this type private assignedRoomIds: RoomId[]; // Filled at check-in // Dates private checkInDate: LocalDate; private checkOutDate: LocalDate; private readonly originalCheckInDate: LocalDate; // Preserved for history private readonly originalCheckOutDate: LocalDate; // State private status: ReservationStatus; private statusHistory: StatusChange[]; // Financial private roomCharges: RoomCharge[]; // Rate per night private totalRoomCharge: Money; private taxAmount: Money; private fees: Fee[]; // Resort fee, parking, etc. private promotionsApplied: AppliedPromotion[]; private totalAmount: Money; // Payment private paymentStatus: PaymentStatus; private payments: Payment[]; private outstandingBalance: Money; // Policy private cancellationPolicy: CancellationPolicy; private ratePlanCode: string; // Source private readonly channel: BookingChannel; // DIRECT, BOOKING_COM, etc. private readonly source: string; // Website, Mobile, Phone private externalReferenceId: string | null; // OTA booking reference // Requests private specialRequests: SpecialRequest[]; private guestNotes: string | null; private staffNotes: string | null; // Timestamps private readonly createdAt: Instant; private updatedAt: Instant; private checkedInAt: Instant | null; private checkedOutAt: Instant | null; constructor(params: ReservationCreationParams) { this.validate(params); this.id = ReservationId.generate(); this.confirmationNumber = ConfirmationNumber.generate(); this.status = ReservationStatus.PENDING; this.createdAt = Instant.now(); this.updatedAt = this.createdAt; this.originalCheckInDate = params.checkInDate; this.originalCheckOutDate = params.checkOutDate; this.statusHistory = []; this.assignedRoomIds = []; this.recordStatusChange(ReservationStatus.PENDING, 'Reservation created'); // ... full initialization } // ============ State Transitions ============ /** * Confirm the reservation (payment received or guarantee accepted) */ confirm(): void { this.assertStatus([ReservationStatus.PENDING], 'confirm'); this.status = ReservationStatus.CONFIRMED; this.recordStatusChange( ReservationStatus.CONFIRMED, 'Reservation confirmed' ); } /** * Check in the guest */ checkIn(assignedRoomIds: RoomId[]): void { this.assertStatus([ReservationStatus.CONFIRMED], 'check in'); if (assignedRoomIds.length !== this.roomCount) { throw new InvalidAssignmentError( `Expected ${this.roomCount} rooms, got ${assignedRoomIds.length}` ); } this.assignedRoomIds = assignedRoomIds; this.status = ReservationStatus.CHECKED_IN; this.checkedInAt = Instant.now(); this.recordStatusChange( ReservationStatus.CHECKED_IN, `Checked in to room(s): ${assignedRoomIds.join(', ')}` ); } /** * Check out the guest */ checkOut(): void { this.assertStatus([ReservationStatus.CHECKED_IN], 'check out'); if (!this.isBalanceSettled()) { throw new OutstandingBalanceError( `Cannot check out with balance: ${this.outstandingBalance}` ); } this.status = ReservationStatus.CHECKED_OUT; this.checkedOutAt = Instant.now(); this.recordStatusChange( ReservationStatus.CHECKED_OUT, 'Guest checked out' ); } /** * Cancel the reservation */ cancel(reason: string): CancellationResult { this.assertStatus( [ReservationStatus.PENDING, ReservationStatus.CONFIRMED], 'cancel' ); const penalty = this.calculateCancellationPenalty(); this.status = ReservationStatus.CANCELLED; this.recordStatusChange( ReservationStatus.CANCELLED, `Cancelled: ${reason}` ); return { penaltyAmount: penalty, refundAmount: this.calculateRefund(penalty), }; } /** * Mark as no-show (guest didn't arrive) */ markNoShow(): void { this.assertStatus([ReservationStatus.CONFIRMED], 'mark no-show'); // Typically charged at least first night this.status = ReservationStatus.NO_SHOW; this.recordStatusChange( ReservationStatus.NO_SHOW, 'Guest did not arrive' ); } // ============ Modification Methods ============ /** * Modify reservation dates */ modifyDates(newCheckIn: LocalDate, newCheckOut: LocalDate): void { this.assertStatus( [ReservationStatus.PENDING, ReservationStatus.CONFIRMED], 'modify dates' ); this.validateDateRange(newCheckIn, newCheckOut); // Note: Caller must have already verified availability const oldCheckIn = this.checkInDate; const oldCheckOut = this.checkOutDate; this.checkInDate = newCheckIn; this.checkOutDate = newCheckOut; this.updatedAt = Instant.now(); this.recordStatusChange( this.status, // Status unchanged `Dates modified from ${oldCheckIn}-${oldCheckOut} to ${newCheckIn}-${newCheckOut}` ); } /** * Add rooms to reservation */ addRoom(): void { this.assertStatus( [ReservationStatus.PENDING, ReservationStatus.CONFIRMED], 'add room' ); this.roomCount += 1; this.updatedAt = Instant.now(); // Repricing handled by service layer } /** * Update guest count */ updateGuestCount(adults: number, children: number, childAges: number[]): void { this.assertStatus( [ReservationStatus.PENDING, ReservationStatus.CONFIRMED], 'update guest count' ); // Validation that room type can accommodate handled by service this.guestCount = new GuestCount(adults, children, childAges); this.updatedAt = Instant.now(); } // ============ Financial Methods ============ /** * Calculate cancellation penalty based on policy */ private calculateCancellationPenalty(): Money { const hoursUntilCheckIn = this.getHoursUntilCheckIn(); const applicableTier = this.cancellationPolicy.tiers .sort((a, b) => b.hoursBeforeCheckin - a.hoursBeforeCheckin) .find(tier => hoursUntilCheckIn >= tier.hoursBeforeCheckin); if (!applicableTier) { return this.totalAmount; // Full charge if no tier matches } if (applicableTier.fixedPenalty) { return applicableTier.fixedPenalty; } if (applicableTier.nightsCharged) { return this.roomCharges .slice(0, applicableTier.nightsCharged) .reduce((sum, charge) => sum.add(charge.amount), Money.zero()); } return this.totalAmount.multiply(applicableTier.penaltyPercentage / 100); } private calculateRefund(penalty: Money): Money { const totalPaid = this.payments .filter(p => p.status === 'COMPLETED') .reduce((sum, p) => sum.add(p.amount), Money.zero()); return totalPaid.subtract(penalty); } isBalanceSettled(): boolean { return this.outstandingBalance.isZero() || this.outstandingBalance.isNegative(); } // ============ Query Methods ============ getNightCount(): number { return this.checkInDate.until(this.checkOutDate, ChronoUnit.DAYS); } private getHoursUntilCheckIn(): number { const now = Instant.now(); const checkInTime = this.checkInDate.atTime(15, 0) // Assume 3pm check-in .toInstant(/* hotel timezone */); return now.until(checkInTime, ChronoUnit.HOURS); } isModifiable(): boolean { return [ ReservationStatus.PENDING, ReservationStatus.CONFIRMED ].includes(this.status); } isCancellable(): boolean { return [ ReservationStatus.PENDING, ReservationStatus.CONFIRMED ].includes(this.status); } // ============ Helper Methods ============ private assertStatus( allowedStatuses: ReservationStatus[], action: string ): void { if (!allowedStatuses.includes(this.status)) { throw new InvalidReservationStateError( `Cannot ${action} reservation in status ${this.status}` ); } } private recordStatusChange( newStatus: ReservationStatus, note: string ): void { this.statusHistory.push({ previousStatus: this.statusHistory.length > 0 ? this.statusHistory[this.statusHistory.length - 1].newStatus : null, newStatus, changedAt: Instant.now(), note, }); } private validateDateRange(checkIn: LocalDate, checkOut: LocalDate): void { if (!checkOut.isAfter(checkIn)) { throw new InvalidDateRangeError( 'Check-out must be after check-in' ); } if (checkIn.isBefore(LocalDate.now())) { throw new InvalidDateRangeError( 'Cannot book dates in the past' ); } }} // Supporting typesenum ReservationStatus { PENDING = 'PENDING', CONFIRMED = 'CONFIRMED', CHECKED_IN = 'CHECKED_IN', CHECKED_OUT = 'CHECKED_OUT', CANCELLED = 'CANCELLED', NO_SHOW = 'NO_SHOW',} interface StatusChange { previousStatus: ReservationStatus | null; newStatus: ReservationStatus; changedAt: Instant; note: string;} enum BookingChannel { DIRECT = 'DIRECT', BOOKING_COM = 'BOOKING_COM', EXPEDIA = 'EXPEDIA', // ... other OTAs}The assertStatus() method enforces state machine constraints. Every state-changing method declares which statuses it can transition from. This prevents impossible operations like checking out a cancelled reservation or cancelling an already checked-out guest. State machine rigor is non-negotiable for booking systems.
With core entities defined, let's visualize their relationships. Understanding cardinality and navigation direction is critical for implementation.
| Relationship | Cardinality | Navigation | Notes |
|---|---|---|---|
| Hotel → RoomType | 1 to many | Bidirectional | Hotel owns RoomTypes; types reference back for queries |
| Hotel → Room | 1 to many | Bidirectional | Hotel contains physical rooms |
| RoomType ← Room | 1 to many | Room → RoomType | Room knows its type; type doesn't track rooms |
| Guest → Reservation | 1 to many | Bidirectional | Guest makes reservations; history is valuable |
| Reservation → RoomType | Many to 1 | Reservation → RoomType | Reservation specifies what type of room was booked |
| Reservation → Room | Many to many | Reservation → Room[] | Multiple rooms per reservation; assigned at check-in |
| Reservation → Hotel | Many to 1 | Reservation → Hotel | Each reservation is for a specific hotel |
We've designed the core entities of our Hotel Booking System, establishing clear responsibilities, attributes, and relationships.
What's Next:
With core entities established, we'll explore the Booking and Pricing Design in detail—how rates are calculated per night, how promotions are applied, and how the complex repricing logic works during modifications. This is where the real financial complexity of hotel systems becomes apparent.
You now understand the core entity model for a Hotel Booking System. These entities—Hotel, RoomType, Room, Guest, and Reservation—form the foundation upon which all booking logic will be built. The clear separation of concerns and rigorous state management will pay dividends as we add complex features.