Design a meeting room reservation system
Book, cancel and list with conflict detection, capacities and recurring meetings.
- Intervals and scheduling — Meetings, bookings and conflicts as interval arithmetic.
- Specification and rule objects — Business rules as composable objects you can and, or and not.
- Designing method signatures — Inputs, outputs, errors and names that make misuse hard.
- Errors, exceptions and validation — Failing loudly at the boundary and never in the middle.
01The design, in the six-step framework
1. Requirements and use cases
The prompt is short, so the first minute goes on pinning down what a booking is. The version below is the one most interviewers settle on once asked.
- An organiser books a room for a half-open interval [start, end) with an expected head count, and gets a booking id back.
- An organiser cancels a booking; the slot becomes free immediately.
- Anyone lists the bookings of a room in a time window, in start order, a page at a time.
- A booking is rejected if it overlaps an existing booking of the same room or if the head count exceeds the room capacity.
- An organiser books a recurring meeting (weekly, n occurrences); every occurrence must be free or the whole request fails.
Out of scope: users and authentication, notifications, time zones (all times are UTC instants), and persistence. Non-functional constraints: a booking check on a room with thousands of existing bookings should be logarithmic, not linear; the rule set (capacity, business hours, maximum duration) will grow, so adding a rule must not touch the booking code; two concurrent requests for the same slot must never both succeed.
2. Core entities and relationships
Room: identity, capacity, and the calendar of its bookings. It owns the overlap check because it owns the data.Booking: an immutable record of who booked which room for which interval; recurring bookings share aseries_id.TimeSlot: a value object [start, end) with anoverlapsmethod, so interval arithmetic lives in one place.BookingRule: an interface with one method,check(room, request), returning a reason string orNone.ReservationService: the orchestrator; it runs the rules, asks the room to insert, and hands out ids.
Rooms compose bookings: a booking has no meaning outside its room, and deleting a room deletes its calendar. The service merely aggregates rules; rules are stateless objects that could be shared by several services, so the diamond is hollow there.
3. Interfaces and key methods
from dataclasses import dataclass
from typing import Optional, Protocol
@dataclass(frozen=True)
class TimeSlot:
start: int # unix seconds, inclusive
end: int # exclusive
def overlaps(self, other: "TimeSlot") -> bool:
"""Half-open intervals overlap iff each starts before the other ends."""
return self.start < other.end and other.start < self.end
def shift(self, seconds: int) -> "TimeSlot":
"""A new slot of the same length, moved forward by seconds."""
return TimeSlot(self.start + seconds, self.end + seconds)
@dataclass(frozen=True)
class BookingRequest:
room_id: str
slot: TimeSlot
organiser: str
attendees: int
class BookingRule(Protocol):
def check(self, room: "Room", req: BookingRequest) -> Optional[str]:
"""Return a human-readable reason to reject, or None to allow."""
class ReservationService:
def book(self, req: BookingRequest) -> "Booking":
"""Atomically validate and insert; raises BookingError with the first failing reason."""
def book_series(self, req: BookingRequest, every_seconds: int, count: int) -> list:
"""All-or-nothing: either every occurrence is booked or none is."""
def cancel(self, booking_id: str) -> None:
"""Idempotent: cancelling twice is not an error."""
def list_bookings(self, room_id: str, window: TimeSlot, page: int = 0, size: int = 50) -> list:
"""Bookings overlapping the window, sorted by start, paged."""
Two invariants carry the design. A room's calendar is always sorted by start and pairwise non-overlapping, which is what makes the conflict check a binary search. And book is atomic: the rule checks, the overlap check and the insert happen under one lock, so no second caller can see the gap between "it was free" and "it is mine".
4. Design patterns and the choices behind them
Interval arithmetic as a value object
The force: overlap logic is easy to get wrong at the boundaries and is needed by the room, the recurring booking and the listing query. Putting it in an immutable value object means one implementation, one test, and the half-open rule (a meeting that ends at 10:00 does not clash with one starting at 10:00) is written once. The rejected alternative, comparing raw integers inside Room, duplicates the boundary condition in three places. See intervals and scheduling for why half-open is the right convention.
Rules as specifications
The force: capacity today, business hours tomorrow, "no meeting longer than four hours" next quarter. Each rule is a small object with a check method, collected in a list the service iterates, following the specification idea. Adding a rule is adding a class and one line of configuration; the booking code never changes, which is the open-closed principle in its plainest form. The rejected alternative is a growing if chain inside book, which is where the rules would otherwise end up.
Explicit failure reasons
The force: a rejected booking needs to say why. Rules return a reason string rather than a boolean, and the service wraps the first reason in a BookingError. The caller gets an exception at the boundary, never a silent None; that is the error-handling stance the method design should make obvious from the signatures.
5. Core flow and code
- The caller builds a
BookingRequestand callsReservationService.book. - The service takes its lock and looks up the
Room; an unknown room is aBookingError. - Every
BookingRule.checkruns; the first non-empty reason aborts. - The room runs
conflicts(slot): a binary search on start times, then one overlap test against the neighbour on each side. - If free, the room inserts the
Bookingat the found position, keeping the calendar sorted, and the service returns it.
import bisect, itertools, threading
from dataclasses import dataclass, field
from typing import Optional
class BookingError(Exception):
pass
@dataclass(frozen=True)
class Booking:
id: str
room_id: str
slot: TimeSlot
organiser: str
attendees: int
series_id: Optional[str] = None
@dataclass
class Room:
id: str
capacity: int
calendar: list = field(default_factory=list) # Bookings sorted by slot.start, non-overlapping
def _index(self, start: int) -> int:
return bisect.bisect_left(self.calendar, start, key=lambda b: b.slot.start)
def conflicts(self, slot: TimeSlot) -> bool:
i = self._index(slot.start)
before = self.calendar[i - 1] if i > 0 else None
after = self.calendar[i] if i < len(self.calendar) else None
return any(b is not None and b.slot.overlaps(slot) for b in (before, after))
def add(self, booking: Booking) -> None:
self.calendar.insert(self._index(booking.slot.start), booking)
def remove(self, booking_id: str) -> None:
self.calendar = [b for b in self.calendar if b.id != booking_id]
class CapacityRule:
def check(self, room: Room, req: BookingRequest) -> Optional[str]:
if req.attendees > room.capacity:
return f"room {room.id} holds {room.capacity}, requested {req.attendees}"
return None
class BusinessHoursRule:
def __init__(self, open_hour: int = 8, close_hour: int = 20):
self.open_hour, self.close_hour = open_hour, close_hour
def check(self, room: Room, req: BookingRequest) -> Optional[str]:
start_h = (req.slot.start // 3600) % 24
end_h = ((req.slot.end - 1) // 3600) % 24
spans_days = req.slot.start // 86400 != (req.slot.end - 1) // 86400
if spans_days or start_h < self.open_hour or end_h >= self.close_hour:
return "outside business hours"
return None
class ReservationService:
def __init__(self, rooms: list, rules: list):
self.rooms = {r.id: r for r in rooms}
self.rules = list(rules)
self._lock = threading.RLock()
self._ids = itertools.count(1)
self._by_id: dict = {}
def _validate(self, req: BookingRequest) -> Room:
room = self.rooms.get(req.room_id)
if room is None:
raise BookingError(f"no room {req.room_id}")
for rule in self.rules:
reason = rule.check(room, req)
if reason:
raise BookingError(reason)
if room.conflicts(req.slot):
raise BookingError("slot already booked")
return room
def book(self, req: BookingRequest, series_id: Optional[str] = None) -> Booking:
with self._lock:
room = self._validate(req)
booking = Booking(str(next(self._ids)), room.id, req.slot, req.organiser, req.attendees, series_id)
room.add(booking)
self._by_id[booking.id] = booking
return booking
def book_series(self, req: BookingRequest, every_seconds: int, count: int) -> list:
if count < 1 or every_seconds < req.slot.end - req.slot.start:
raise BookingError("a series needs at least one occurrence, none overlapping the next")
with self._lock:
reqs = [BookingRequest(req.room_id, req.slot.shift(k * every_seconds), req.organiser, req.attendees)
for k in range(count)]
for r in reqs: # validate all first: all-or-nothing
self._validate(r)
series = f"s{next(self._ids)}"
return [self.book(r, series) for r in reqs]
def cancel(self, booking_id: str) -> None:
with self._lock:
b = self._by_id.pop(booking_id, None)
if b is not None:
self.rooms[b.room_id].remove(booking_id)
def list_bookings(self, room_id: str, window: TimeSlot, page: int = 0, size: int = 50) -> list:
with self._lock:
hits = [b for b in self.rooms[room_id].calendar if b.slot.overlaps(window)]
return hits[page * size:(page + 1) * size]
The shift method on TimeSlot returns a new slot moved forward; it is the only piece of recurrence logic, which is deliberate. Weekly, daily and fortnightly series differ only in every_seconds, and the all-or-nothing check in book_series validates every occurrence before inserting any, so a clash on week six leaves weeks one to five untouched. Occurrences are checked against the calendar, not each other, so a step shorter than the meeting is refused up front.
6. Extensions, concurrency and what interviewers probe
- "What if two people book the same slot at once?" The whole of
bookruns under one re-entrant lock, so the second caller re-runsconflictsafter the first insert and is rejected. At larger scale the lock would be per room, which the design allows because a room owns its own calendar; see locks and mutexes. - "Add a maximum duration" or "no bookings on holidays". One more
BookingRuleclass, registered in the list. The interviewer is checking that the booking method does not change. - "List bookings across all rooms for a person." Add a second index,
by_organiser, kept in step insidebookandcancel; the sorted calendar stays the source of truth. - "Recurring forever." Materialising occurrences does not work for an open-ended series; the answer is a
RecurrenceRuleexpanded lazily for the window being queried, with exceptions stored as explicit cancellations, as in calendar conflict handling. - Test first: back-to-back slots do not conflict; a cancelled slot can be rebooked; a series with one clash books nothing.
- Common mistake: checking overlap with a linear scan that compares against every booking, and using closed intervals so that adjacent meetings wrongly clash.
02Where this question was reported
- Educative · Grokking LLD chapter 13
- LLD Mastery · Google-tagged (Medium); conflict detection, recurring bookings; plus a 'list bookings' pagination variant
- LLD Mastery · Google-tagged with Microsoft/Amazon/Meta; time-overlap detection
- PracHub · PracHub OOD practice prompt
- Codemia · Codemia Hard 'Calendar System'
- thita.ai · thita.ai 'Hotel/Meeting Room Booking', advanced
- GitHub · Machine coding 'Event Calendar' (Flipkart)