Airbnb
Hosts, listings, bookings, and a calendar that never double-books. The nuance hotel management misses: each property is unique with its own availability, price, and rules.
Key Abstractions
A property a host offers — address, amenities, nightly price, house rules
Per-listing availability. Tracks booked ranges with O(log n) conflict detection.
Guest-side reservation with state machine (requested, confirmed, checked-in, completed, cancelled)
Base price plus dynamic rules — weekend premium, minimum stay, last-minute discount
Two-way: guest reviews host, host reviews guest. Both revealed only after both submit.
Class Diagram
The Key Insight
Hotel management and Airbnb look similar until the calendar model. A hotel books a room type — any of N identical rooms. Airbnb books a specific listing — there's exactly one of it. That makes per-listing availability the atom of the whole design, and search becomes "find listings whose calendars say yes for these dates."
The calendar is an interval tree keyed by checkin date. For any date range request, two neighbor lookups (floor and ceiling) reveal conflicts in O(log n). Half-open intervals [checkin, checkout) keep the edges clean — one guest's checkout morning is the next guest's checkin afternoon, and the math agrees.
Pricing is the part most candidates skip. A nightly rate isn't a number; it's a function of the night (weekend/weekday, peak season, minimum stay, last-minute discount, length-of-stay discount). PricingStrategy pulls that out so the listing stays clean and Airbnb's real pricing experiments live in one class.
Requirements
Functional
- Hosts list properties with address, capacity, amenities, and price
- Guests search by location, dates, guest count
- Instant-book or host-approval flow
- Cancel bookings with status transitions
- Two-way reviews revealed only after both parties submit
Non-Functional
- O(log n) availability check per listing
- No double-booking under concurrent requests
- Price computation is extensible without editing
Listing - Search returns only actually-available listings (not post-filter)
Design Decisions
Why per-listing calendar vs global booking table?
Global table means "show me San Francisco listings available next weekend" scans every booking. Per-listing means each listing's calendar is small (hundreds of bookings max) and conflict-check is O(log n). Search filters the listing set first, then checks each calendar.
Why half-open date intervals?
A guest checking out Sunday morning doesn't conflict with a guest checking in Sunday afternoon. Closed intervals force either "checkout = next-day's checkin - 1" (surprising) or explicit "same day OK" logic (easy to forget). Half-open just works.
Why PricingStrategy and not a nightlyPrice column?
Airbnb's business is dynamic pricing. Weekend premiums, peak seasons, length-of-stay discounts, last-minute deals. Encoding each as a strategy means the listing doesn't know or care; a new pricing experiment is a new strategy class deployed to a test cohort.
Why two-blind reviews?
If Bob writes a bad review first and Alice can see it, she writes a retaliatory bad review. The bad review is then seen as "warranted" by future guests. Hiding both until both submit (or a deadline passes) breaks the retaliation loop — this is policy that shapes the data model.
Why reserve at request time?
Without it, two guests racing to book the same dates both see "available" and both commit. One gets a rejection after they've entered payment details. Reserving at request time (even for host-approval flow) holds the dates; host rejection releases the hold. Similar to the airline two-phase pattern.
Interview Follow-ups
- "How does Superhost status work?" Aggregate per-host metrics (response rate, review score, cancellation rate). A
HostBadgeentity evaluated nightly. Not part of the booking path — purely observed from review and booking data. - "What about cleaning fees and taxes?" Extend
PricingStrategyto return aPriceBreakdown(base, cleaning, taxes, service fee). Invariant: breakdown sums to total. Easier for display and refund math. - "How do you handle concurrent host pricing edits?" Version on
PricingStrategyper listing. Edits are copy-on-write; in-flight bookings keep their quoted price. - "What about multi-currency?"
Moneystores currency. Pricing and payment both parameterize by it. Guest pays in their currency via FX at booking time; host receives in their payout currency. - "How would you prevent discrimination-based host rejection?" Hide guest profile (name, photo) until after host approval, or shift to instant-book. Design choice with legal weight.
Code Implementation
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta
from enum import Enum
from threading import RLock
from sortedcontainers import SortedDict # pip install sortedcontainers
import uuid
class BookingStatus(Enum):
REQUESTED = "requested"
CONFIRMED = "confirmed"
CHECKED_IN = "checked_in"
COMPLETED = "completed"
CANCELLED = "cancelled"
class CancelledBy(Enum):
GUEST = "guest"
HOST = "host"
SYSTEM = "system"
@dataclass(frozen=True)
class Money:
cents: int
currency: str = "USD"
def __add__(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise ValueError("currency mismatch")
return Money(self.cents + other.cents, self.currency)
def __mul__(self, n: int | float) -> "Money":
return Money(int(self.cents * n), self.currency)
@dataclass(frozen=True)
class Address:
street: str
city: str
country: str
@dataclass
class User:
id: str
name: str
email: str
class PricingStrategy(ABC):
@abstractmethod
def price(self, checkin: date, checkout: date) -> Money: ...
class DynamicPricing(PricingStrategy):
"""Base + weekend premium. Weekends = Fri & Sat nights."""
def __init__(self, base_price: Money, weekend_multiplier: float = 1.25, minimum_stay: int = 1):
if base_price.cents <= 0:
raise ValueError("base price must be positive")
if minimum_stay < 1:
raise ValueError("minimum stay must be >= 1 night")
self._base = base_price
self._weekend_mult = weekend_multiplier
self._minimum_stay = minimum_stay
def price(self, checkin: date, checkout: date) -> Money:
nights = (checkout - checkin).days
if nights < self._minimum_stay:
raise ValueError(f"Minimum stay is {self._minimum_stay} night(s)")
total_cents = 0
current = checkin
while current < checkout:
# Friday = 4, Saturday = 5 in Python's weekday().
mult = self._weekend_mult if current.weekday() in (4, 5) else 1.0
total_cents += int(self._base.cents * mult)
current += timedelta(days=1)
return Money(total_cents, self._base.currency)
@dataclass
class DateRange:
start: date
end: date # exclusive — half-open interval [start, end)
def overlaps(self, other: "DateRange") -> bool:
return self.start < other.end and other.start < self.end
class Calendar:
"""Availability per listing. Sorted map keyed by checkin date."""
def __init__(self):
self._bookings: SortedDict[date, DateRange] = SortedDict()
self._booking_index: dict[str, date] = {} # booking_id -> checkin key
self._blocked: list[DateRange] = []
self._lock = RLock()
def is_available(self, checkin: date, checkout: date) -> bool:
if checkout <= checkin:
raise ValueError("checkout must be after checkin")
with self._lock:
# Blocked ranges: linear scan is fine; hosts rarely block more than a few ranges.
for blk in self._blocked:
if blk.overlaps(DateRange(checkin, checkout)):
return False
# Prior booking that could still overlap.
idx = self._bookings.bisect_right(checkin) - 1
if idx >= 0:
prior = self._bookings.peekitem(idx)[1]
if prior.end > checkin:
return False
idx_next = self._bookings.bisect_left(checkin)
if idx_next < len(self._bookings):
nxt = self._bookings.peekitem(idx_next)[1]
if nxt.start < checkout:
return False
return True
def reserve(self, booking_id: str, checkin: date, checkout: date) -> None:
with self._lock:
if not self.is_available(checkin, checkout):
raise RuntimeError("dates unavailable")
self._bookings[checkin] = DateRange(checkin, checkout)
self._booking_index[booking_id] = checkin
def release(self, booking_id: str) -> None:
with self._lock:
key = self._booking_index.pop(booking_id, None)
if key is not None:
del self._bookings[key]
def block(self, start: date, end: date) -> None:
self._blocked.append(DateRange(start, end))
class Listing:
def __init__(self, host: User, address: Address, max_guests: int,
pricing: PricingStrategy, amenities: set[str] | None = None):
if max_guests < 1:
raise ValueError("max_guests must be >= 1")
self.id = str(uuid.uuid4())[:8]
self.host = host
self.address = address
self.max_guests = max_guests
self.pricing = pricing
self.amenities = amenities or set()
self.calendar = Calendar()
@dataclass
class Booking:
id: str
listing_id: str
guest_id: str
checkin: date
checkout: date
guest_count: int
total_price: Money
status: BookingStatus = BookingStatus.REQUESTED
@dataclass
class Review:
booking_id: str
author_id: str
rating: int # 1..5
text: str
submitted_at: datetime = field(default_factory=datetime.utcnow)
visible: bool = False # flipped to True when both submit OR the timeout elapses
@dataclass
class BookingRequest:
listing: Listing
guest: User
checkin: date
checkout: date
guest_count: int
class AirbnbService:
def __init__(self):
self._listings: dict[str, Listing] = {}
self._bookings: dict[str, Booking] = {}
self._reviews: dict[str, list[Review]] = {} # booking_id -> up to two reviews
def add_listing(self, listing: Listing) -> None:
self._listings[listing.id] = listing
def search(self, city: str, checkin: date, checkout: date,
guests: int) -> list[Listing]:
out = []
for listing in self._listings.values():
if listing.address.city != city: continue
if listing.max_guests < guests: continue
if listing.calendar.is_available(checkin, checkout):
out.append(listing)
return out
def request_booking(self, req: BookingRequest) -> Booking:
if req.guest_count < 1 or req.guest_count > req.listing.max_guests:
raise ValueError("invalid guest count")
price = req.listing.pricing.price(req.checkin, req.checkout)
booking_id = str(uuid.uuid4())[:8]
# Reserve now — prevents race during "instant book" flow. Hosts can reject later.
req.listing.calendar.reserve(booking_id, req.checkin, req.checkout)
booking = Booking(
id=booking_id,
listing_id=req.listing.id,
guest_id=req.guest.id,
checkin=req.checkin,
checkout=req.checkout,
guest_count=req.guest_count,
total_price=price,
status=BookingStatus.REQUESTED,
)
self._bookings[booking_id] = booking
return booking
def confirm_booking(self, booking_id: str) -> None:
booking = self._bookings[booking_id]
if booking.status != BookingStatus.REQUESTED:
raise RuntimeError(f"cannot confirm a {booking.status.value} booking")
booking.status = BookingStatus.CONFIRMED
def cancel_booking(self, booking_id: str, by: CancelledBy) -> None:
booking = self._bookings[booking_id]
if booking.status in (BookingStatus.CANCELLED, BookingStatus.COMPLETED):
return
booking.status = BookingStatus.CANCELLED
listing = self._listings[booking.listing_id]
listing.calendar.release(booking_id)
# In reality the policy module decides refund amount based on `by` and timing.
def check_in(self, booking_id: str) -> None:
booking = self._bookings[booking_id]
if booking.status != BookingStatus.CONFIRMED:
raise RuntimeError("Can only check in a confirmed booking")
booking.status = BookingStatus.CHECKED_IN
def submit_review(self, booking_id: str, author_id: str, rating: int, text: str) -> None:
if not 1 <= rating <= 5:
raise ValueError("rating 1..5")
booking = self._bookings.get(booking_id)
if booking is None or booking.status not in (BookingStatus.CHECKED_IN, BookingStatus.COMPLETED):
raise RuntimeError("review only after stay")
bucket = self._reviews.setdefault(booking_id, [])
if any(r.author_id == author_id for r in bucket):
raise RuntimeError("duplicate review")
bucket.append(Review(booking_id, author_id, rating, text))
# Double-blind: reveal all reviews once both sides have submitted.
if len(bucket) == 2:
for r in bucket:
r.visible = True
def reviews_for(self, booking_id: str) -> list[Review]:
return [r for r in self._reviews.get(booking_id, []) if r.visible]
def sweep_reviews(self, now: datetime | None = None,
timeout: timedelta = timedelta(days=14)) -> int:
"""Reveal any single-sided review whose submission is older than `timeout`.
Prevents a silent counterparty from hiding a review forever."""
at = now or datetime.utcnow()
revealed = 0
for bucket in self._reviews.values():
if not bucket or all(r.visible for r in bucket):
continue
earliest = min(r.submitted_at for r in bucket)
if at >= earliest + timeout:
for r in bucket:
if not r.visible:
r.visible = True
revealed += 1
return revealed
if __name__ == "__main__":
svc = AirbnbService()
host = User("h1", "Alice", "alice@host.com")
guest = User("g1", "Bob", "bob@guest.com")
listing = Listing(
host=host,
address=Address("123 Ocean Rd", "San Francisco", "USA"),
max_guests=4,
pricing=DynamicPricing(Money(15_000), weekend_multiplier=1.5, minimum_stay=2),
)
svc.add_listing(listing)
# Thursday 2026-04-16 to Sunday 2026-04-19 → 3 nights (Thu, Fri, Sat).
# Fri & Sat are weekend multiplier: 150 + 225 + 225 = $600.
req = BookingRequest(
listing=listing,
guest=guest,
checkin=date(2026, 4, 16),
checkout=date(2026, 4, 19),
guest_count=2,
)
booking = svc.request_booking(req)
print(f"Booking {booking.id}: ${booking.total_price.cents / 100:.2f}")
# Any concurrent booking attempt on the same dates fails.
try:
svc.request_booking(BookingRequest(listing, guest, date(2026, 4, 17), date(2026, 4, 19), 1))
except RuntimeError as e:
print(f"Overlapping booking rejected: {e}")
# Back-to-back booking (checkin == previous checkout) is fine.
adjacent = svc.request_booking(BookingRequest(listing, guest, date(2026, 4, 19), date(2026, 4, 21), 1))
print(f"Adjacent booking OK: {adjacent.id}")
svc.confirm_booking(booking.id)
svc.check_in(booking.id)
svc.submit_review(booking.id, guest.id, 5, "Lovely stay!")
print(f"Reviews visible after 1 submission: {len(svc.reviews_for(booking.id))}")
svc.submit_review(booking.id, host.id, 4, "Great guest.")
print(f"Reviews visible after 2 submissions: {len(svc.reviews_for(booking.id))}")
# Single-sided review stays hidden until the timeout sweep catches it.
svc.confirm_booking(adjacent.id)
svc.check_in(adjacent.id)
svc.submit_review(adjacent.id, guest.id, 3, "Fine place.")
assert len(svc.reviews_for(adjacent.id)) == 0 # host hasn't reviewed yet
revealed = svc.sweep_reviews(now=datetime.utcnow() + timedelta(days=15))
print(f"Sweep revealed {revealed} single-sided review(s)")
assert len(svc.reviews_for(adjacent.id)) == 1Common Mistakes
- ✗Using a global booking table keyed only by listing_id. Conflict check becomes a range scan over millions of rows.
- ✗Storing prices as a single number on the listing. Weekends, peak season, and last-minute rules live nowhere.
- ✗Letting hosts cancel confirmed bookings freely. Policies and penalties are load-bearing business logic.
- ✗Modeling availability as 'free/booked' only. Real listings have blocked-by-host dates, maintenance windows, minimum stays.
Key Points
- ✓Per-listing calendar with sorted interval tree. Booking check is O(log n), not a scan of all reservations.
- ✓Half-open date intervals [checkin, checkout). One night's checkout equals the next night's checkin without conflict.
- ✓Price is a strategy, not a scalar. Airbnb's real revenue is in dynamic pricing rules.
- ✓Reviews are two-blind — neither party sees the other's review until both submit, preventing retaliation.