Skip to content

Design a meeting scheduler and calendar

TL;DR

  • You build a calendar service: meetings expand into Intervals, busy blocks merge, the first gap that fits wins, and a room is booked under its own lock.
  • Three decisions carry the interview: intervals are half-open ([start, end), so 10-11 and 11-12 do not clash), recurrence repeats wall-clock time (09:00 local stays 09:00 across a DST switch), and the room lock is per room so two organisers racing for one room have exactly one winner.
  • Patterns that earn their place: Strategy (recurrence, slot finding), Builder, Observer, Command with undo, Repository. A Calendar class is deliberately absent.

Problem statement

"Design a meeting scheduler. Users have calendars; an organiser creates a meeting with a time range, attendees, optionally a room, and optionally a recurrence rule. Attendees accept, decline or answer tentatively. The system detects conflicts, finds the earliest free slot for a group inside a search window, and sends notifications and reminders. Attendees are in different timezones. Handle cancelling or moving one occurrence of a recurring series, and tell me what happens when two people book the last room at the same instant."

Requirements

Functional

  • Per-user calendars; create a meeting with a time range, attendees, an optional room and an optional recurrence rule.
  • Conflict detection for attendees and rooms; rooms have a capacity.
  • Find the first free slot of a given length for N attendees inside a window.
  • Invitations with accept, decline and tentative; declining frees the attendee's time.
  • Update, reschedule (with undo) and cancel, each notifying the attendees.
  • Reminders a fixed lead time before an occurrence starts.
  • Recurring meetings (daily, weekly on chosen weekdays, with a count or an end date), including cancelling or moving a single occurrence.
  • Timezones: every attendee sees their own; the stored truth is one instant.

Non-functional and constraints

  • Two organisers must never double-book a room, whatever the interleaving.
  • Recurrence expansion is bounded: no query may expand an endless series.
  • Deterministic and testable: the clock is injected, expansion is pure, no wall-clock reads inside services.
  • In-memory behind a Repository protocol; a database is a swap, not a redesign.

Out of scope: external calendar sync (Exchange, Google), video-conference links, resource types other than rooms, free/busy sharing across organisations, mail delivery.

Clarifying questions and assumptions

Question to ask Assumption taken here
Are the endpoints inclusive? Half-open, [start, end). Everything downstream (merge, gaps, conflicts) is simpler and the 11:00 boundary stops being a bug.
Does a pending invitation block time? Yes. Pending and tentative are busy, declined is not - that is what a real calendar shows.
Where does a recurring meeting live? One row with a rule plus an exception list. Materialising every occurrence is a follow-up for very long series.
What does "every day at 09:00" mean across a DST switch? 09:00 local, always. That is what the organiser meant, and it is why the start time carries the organiser's zone.
Can an attendee be double-booked? Yes - real calendars allow it and warn. Only rooms are hard-enforced.
How far ahead does a room get booked for an endless series? 60 days, rolled forward by a job. Booking a room until the end of time is not a feature.
Who is the source of truth for "now"? An injected Clock. Reminders and tests need the same one.

Core entities and relationships

  • Interval — a frozen, timezone-aware, half-open range. Every conflict question in this design is one overlaps call.
  • Meeting — one series: organiser, attendees, first start (in the organiser's zone), duration, optional RecurrenceRule, optional room, plus cancelled_dates and moved_dates for the exceptions.
  • MeetingBuilder — eight fields, four optional and interdependent; the Builder is the one place a meeting can be born invalid.
  • Invitation — per attendee, with a five-value status and guarded transitions.
  • User — id, name and an IANA timezone; MeetingRoom — id, name, capacity; RoomBooking — a room, a meeting and one interval.
  • RecurrenceStrategy (NoRecurrence, DailyRecurrence, WeeklyRecurrence) — expands a series into starts inside a window; strategy_for is the factory that picks one from the rule.
  • SlotFinder (EarliestSlotFinder, WorkingHoursSlotFinder) — picks the slot; the second one adds each attendee's out-of-hours time as busy.
  • AvailabilityService — expand, drop exceptions, merge. RoomRegistry — bookings and the per-room locks. MeetingScheduler — the facade. NotificationService — an observer. RescheduleCommand — move with undo.

Multiplicities: meeting 1 -> * invitations, meeting 1 -> 0..1 recurrence rule, meeting 1 -> * occurrences (computed, never stored), room 1 -> * bookings, user 1 -> * meetings.

Class diagram

The domain: intervals, the meeting they come from, and the builder that assembles one.

classDiagram
    class Interval {
        +start: datetime
        +end: datetime
        +duration: timedelta
        +overlaps(other) bool
        +clipped_to(window) Interval
        +utc() Interval
    }
    class Meeting {
        +id: str
        +title: str
        +organizer_id: str
        +attendee_ids: tuple
        +start: datetime
        +duration: timedelta
        +status: MeetingStatus
        +cancelled_dates: Set~date~
        +moved_dates: Dict~date,datetime~
        +blocks(attendee_id) bool
    }
    class MeetingBuilder {
        +titled(title) MeetingBuilder
        +organized_by(organizer) MeetingBuilder
        +starting_at(local, zone) MeetingBuilder
        +lasting(minutes) MeetingBuilder
        +repeating(rule) MeetingBuilder
        +in_room(room_id) MeetingBuilder
        +build() Meeting
    }
    class RecurrenceRule {
        +frequency: Frequency
        +every: int
        +count: int
        +until: datetime
        +weekdays: tuple
    }
    class Invitation {
        +attendee_id: str
        +status: InvitationStatus
        +respond(status, at) None
        +withdraw() None
    }
    class User {
        +id: str
        +timezone: str
        +zone: ZoneInfo
    }
    class MeetingRoom {
        +id: str
        +capacity: int
    }
    class RoomBooking {
        +room_id: str
        +meeting_id: str
        +interval: Interval
    }
    class Reminder {
        +meeting_id: str
        +minutes_before: int
        +fires_at: datetime
    }
    MeetingBuilder ..> Meeting : builds
    Meeting "1" *-- "many" Invitation
    Meeting --> "0..1" RecurrenceRule
    Meeting ..> Interval : expands into
    Invitation --> User
    RoomBooking --> MeetingRoom
    RoomBooking --> Interval
    Reminder --> Interval

The services and the two Strategy families.

classDiagram
    class MeetingScheduler {
        -meeting_locks: Dict~str,RLock~
        +schedule(meeting) Meeting
        +schedule_first_available(title, organizer, attendee_ids, minutes, window) Meeting
        +find_slot(attendee_ids, minutes, window, finder) Interval
        +respond(meeting_id, attendee_id, status) None
        +cancel(meeting_id) Meeting
        +cancel_occurrence(meeting_id, on) Meeting
        +reschedule(meeting_id, new_start) RescheduleCommand
        +due_reminders(lead_minutes) List~Reminder~
    }
    class AvailabilityService {
        +occurrences(meeting, window) List~Interval~
        +busy(user_ids, window) List~Interval~
        +free(user_ids, window) List~Interval~
    }
    class RoomRegistry {
        -locks: Dict~str,Lock~
        +book(room_id, meeting_id, intervals) List~RoomBooking~
        +release(room_id, meeting_id) int
        +book_first_available(meeting_id, intervals, attendees) str
    }
    class MeetingRepository {
        <<interface>>
        +add(meeting) None
        +get(meeting_id) Meeting
        +for_attendee(user_id) List~Meeting~
    }
    class InMemoryMeetingRepository
    class MeetingObserver {
        <<interface>>
        +on_meeting_event(event, meeting, recipients) None
    }
    class NotificationService {
        +inbox(user_id) List~str~
    }
    class RescheduleCommand {
        +execute() Meeting
        +undo() Meeting
    }
    class RecurrenceStrategy {
        <<interface>>
        +starts(first, window) Iterator~datetime~
    }
    class DailyRecurrence
    class WeeklyRecurrence
    class NoRecurrence
    class SlotFinder {
        <<interface>>
        +find(attendees, busy, window, duration) Interval
    }
    class EarliestSlotFinder
    class WorkingHoursSlotFinder
    MeetingScheduler --> AvailabilityService
    MeetingScheduler --> RoomRegistry
    MeetingScheduler --> MeetingRepository
    MeetingScheduler --> "many" MeetingObserver
    MeetingScheduler ..> RescheduleCommand : creates
    MeetingScheduler ..> SlotFinder
    MeetingRepository <|.. InMemoryMeetingRepository
    MeetingObserver <|.. NotificationService
    AvailabilityService ..> RecurrenceStrategy
    RecurrenceStrategy <|.. DailyRecurrence
    RecurrenceStrategy <|.. WeeklyRecurrence
    RecurrenceStrategy <|.. NoRecurrence
    SlotFinder <|.. EarliestSlotFinder
    SlotFinder <|.. WorkingHoursSlotFinder

Design patterns applied

Pattern Where Why it earns its place
Strategy RecurrenceStrategy, SlotFinder The two things interviewers change mid-question. "Now only weekdays" is a class; "now respect working hours" is a class that reuses the same gap scan.
Builder MeetingBuilder Eight fields with rules between them (a start needs a zone, attendees always include the organiser). One build() gate means an invalid Meeting cannot exist.
Factory Method strategy_for(rule) The rule is data that can be stored or sent over the wire; the expansion is behaviour. Keeping them apart is what lets a rule be a database row.
Observer MeetingObserver -> NotificationService The scheduler announces; it does not know that email, push or an audit log exist. The announce loop runs outside every lock.
Command RescheduleCommand "Move it, no wait, put it back" is a real user action, and the command carries the previous start so undo is exact rather than reconstructed.
Repository MeetingRepository Availability is the query layer; swapping the in-memory dict for SQL changes one class.
State (lightweight) InvitationStatus with guarded transitions Five values and one rule (a withdrawn invitation cannot be answered). Enum plus a guard beats five classes.

What was deliberately not used: a Calendar class. It is the first noun everybody writes, and it turns out to be an empty shell - a calendar is the set of meetings a user is invited to, which AvailabilityService.busy computes on demand. Modelling it means keeping two copies of the same truth in sync every time an invitation changes. Say that in the room; the interviewer is watching for whether you can delete a noun.

Key flows

Find a slot and book it: expand, merge, first gap, room, notify.

sequenceDiagram
    autonumber
    actor O as Organizer
    participant S as MeetingScheduler
    participant A as AvailabilityService
    participant F as SlotFinder
    participant R as RoomRegistry
    participant N as NotificationService
    O->>S: schedule_first_available(title, attendees, 30 min, window)
    S->>A: busy(attendee_ids, window)
    loop each meeting of each attendee
        A->>A: expand recurrence, drop cancelled dates
    end
    A-->>S: merged busy blocks
    S->>F: find(attendees, busy, window, 30 min)
    alt no gap is long enough
        F-->>S: None
        S-->>O: NoFreeSlotError
    else gap found
        F-->>S: first gap of at least 30 minutes
        S->>R: book_first_available(meeting_id, slot, headcount)
        R->>R: per-room lock, overlap check, insert
        R-->>S: room id
        S->>N: on_meeting_event(invited, meeting, attendees)
        S-->>O: Meeting with invitations pending
    end

Invitation lifecycle. Every answer is reversible until the organiser cancels; WITHDRAWN is the only terminal state, and it is reached from all four others.

stateDiagram-v2
    [*] --> PENDING : invitation sent
    PENDING --> ACCEPTED : accept
    PENDING --> TENTATIVE : maybe
    PENDING --> DECLINED : decline
    TENTATIVE --> ACCEPTED : confirm
    ACCEPTED --> DECLINED : change of mind
    DECLINED --> ACCEPTED : reconsider
    PENDING --> WITHDRAWN : meeting cancelled
    ACCEPTED --> WITHDRAWN : meeting cancelled
    TENTATIVE --> WITHDRAWN : meeting cancelled
    DECLINED --> WITHDRAWN : meeting cancelled
    WITHDRAWN --> [*]

Implementation

Write the interval first. Every later method is a two-line call on it, and getting the half-open comparison right up front is what stops the rest of the hour turning into off-by-one debugging.

code/lld/meeting_scheduler/models.py — enums and errors
class Frequency(StrEnum):
    DAILY = "daily"
    WEEKLY = "weekly"


class MeetingStatus(StrEnum):
    SCHEDULED = "scheduled"
    CANCELLED = "cancelled"


class InvitationStatus(StrEnum):
    PENDING = "pending"  # invited, no answer yet - still counts as busy
    ACCEPTED = "accepted"
    TENTATIVE = "tentative"  # busy, but movable
    DECLINED = "declined"  # not busy
    WITHDRAWN = "withdrawn"  # the meeting was cancelled

    @property
    def blocks_time(self) -> bool:
        return self in (InvitationStatus.PENDING, InvitationStatus.ACCEPTED, InvitationStatus.TENTATIVE)


class SlotConflictError(ConflictError):
    """The room or an attendee is already busy for the requested interval."""


class NoFreeSlotError(ConflictError):
    """No gap long enough exists in the search window."""


class InvitationStateError(InvalidStateError):
    """The invitation cannot move to that status (answering a withdrawn invitation)."""


class MeetingNotFoundError(NotFoundError):
    """Unknown meeting, room or user id."""

merge_intervals sorts and folds; free_gaps is its complement inside a window. Those two functions are the whole availability engine, and both are pure.

code/lld/meeting_scheduler/models.py — intervals
@dataclass(frozen=True, slots=True, order=True)
class Interval:
    """A half-open range ``[start, end)``. Back-to-back meetings do not overlap."""

    start: datetime
    end: datetime

    def __post_init__(self) -> None:
        for moment in (self.start, self.end):
            if moment.tzinfo is None or moment.utcoffset() is None:
                raise ValidationError(f"interval bounds must be timezone-aware: {moment!r}")
        if self.end <= self.start:
            raise ValidationError(f"interval must be positive: {self.start} to {self.end}")

    @property
    def duration(self) -> timedelta:
        return self.end - self.start

    def overlaps(self, other: Interval) -> bool:
        """Half-open comparison: ``10:00-11:00`` and ``11:00-12:00`` are *not* a clash."""
        return self.start < other.end and other.start < self.end

    def contains(self, moment: datetime) -> bool:
        return self.start <= moment < self.end

    def clipped_to(self, window: Interval) -> Interval | None:
        start, end = max(self.start, window.start), min(self.end, window.end)
        return Interval(start, end) if start < end else None

    def utc(self) -> Interval:
        return Interval(self.start.astimezone(UTC), self.end.astimezone(UTC))

    def __str__(self) -> str:
        utc = self.utc()
        return f"{utc.start:%Y-%m-%d %H:%M}-{utc.end:%H:%M}Z"


def merge_intervals(intervals: list[Interval]) -> list[Interval]:
    """Sort by start, then fold anything that touches or overlaps into one block."""
    merged: list[Interval] = []
    for interval in sorted(interval.utc() for interval in intervals):
        if merged and interval.start <= merged[-1].end:
            merged[-1] = Interval(merged[-1].start, max(merged[-1].end, interval.end))
        else:
            merged.append(interval)
    return merged


def free_gaps(busy: list[Interval], window: Interval) -> list[Interval]:
    """The parts of ``window`` no busy block covers. Merging first is what makes it O(n log n)."""
    bounds = window.utc()
    gaps: list[Interval] = []
    cursor = bounds.start
    for block in merge_intervals(busy):
        if block.start > cursor:
            gaps.append(Interval(cursor, min(block.start, bounds.end)))
        cursor = max(cursor, block.end)
        if cursor >= bounds.end:
            return gaps
    gaps.append(Interval(cursor, bounds.end))
    return gaps

The entities are small because the behaviour lives in the services. Note Meeting.start keeping the organiser's zone and the two exception collections.

code/lld/meeting_scheduler/models.py — entities
@dataclass(frozen=True, slots=True)
class User:
    id: str
    name: str
    timezone: str = "UTC"

    @property
    def zone(self) -> ZoneInfo:
        return ZoneInfo(self.timezone)


@dataclass(frozen=True, slots=True)
class MeetingRoom:
    id: str
    name: str
    capacity: int


@dataclass(frozen=True, slots=True)
class Reminder:
    """A reminder that is due now for one occurrence of a meeting."""

    meeting_id: str
    occurrence: Interval
    minutes_before: int

    @property
    def fires_at(self) -> datetime:
        return self.occurrence.start - timedelta(minutes=self.minutes_before)


@dataclass(frozen=True, slots=True)
class RoomBooking:
    room_id: str
    meeting_id: str
    interval: Interval


@dataclass(frozen=True, slots=True)
class RecurrenceRule:
    """``every`` counts periods: DAILY/1 is every day, WEEKLY/2 is every other week."""

    frequency: Frequency
    every: int = 1
    count: int | None = None
    until: datetime | None = None
    weekdays: tuple[int, ...] = ()  # 0 = Monday, only used by WEEKLY

    def __post_init__(self) -> None:
        if self.every < 1:
            raise ValidationError("recurrence interval must be at least 1")
        if self.count is not None and self.count < 1:
            raise ValidationError("recurrence count must be at least 1")
        if any(day not in range(7) for day in self.weekdays):
            raise ValidationError("weekdays must be 0 (Monday) to 6 (Sunday)")


@dataclass(slots=True)
class Invitation:
    meeting_id: str
    attendee_id: str
    status: InvitationStatus = InvitationStatus.PENDING
    responded_at: float | None = None

    def respond(self, status: InvitationStatus, at: float) -> None:
        if self.status is InvitationStatus.WITHDRAWN:
            raise InvitationStateError(f"invitation for {self.meeting_id} was withdrawn")
        if status is InvitationStatus.WITHDRAWN:
            raise InvitationStateError("use MeetingScheduler.cancel to withdraw an invitation")
        self.status = status
        self.responded_at = at

    def withdraw(self) -> None:
        self.status = InvitationStatus.WITHDRAWN


@dataclass(slots=True)
class Meeting:
    """One series. ``start`` carries the organiser's zone, so recurrence is wall-clock."""

    id: str
    title: str
    organizer_id: str
    attendee_ids: tuple[str, ...]
    start: datetime
    duration: timedelta
    recurrence: RecurrenceRule | None = None
    room_id: str | None = None
    status: MeetingStatus = MeetingStatus.SCHEDULED
    invitations: dict[str, Invitation] = field(default_factory=dict)
    cancelled_dates: set[date] = field(default_factory=set)
    moved_dates: dict[date, datetime] = field(default_factory=dict)

    @property
    def first_interval(self) -> Interval:
        return Interval(self.start, self.start + self.duration)

    def is_active(self) -> bool:
        return self.status is MeetingStatus.SCHEDULED

    def blocks(self, attendee_id: str) -> bool:
        invitation = self.invitations.get(attendee_id)
        return self.is_active() and invitation is not None and invitation.status.blocks_time

The Builder is where the rules live. Everything it accepts is optional until build, which is the single gate.

code/lld/meeting_scheduler/models.py — builder
class MeetingBuilder:
    """Builder: a meeting has eight fields, four of them optional and interdependent.

    Every rule lives in ``build``, so there is exactly one place where an invalid
    meeting can be born - and the fluent calls stay free of validation noise.
    """

    def __init__(self, ids: IdGenerator) -> None:
        self._ids = ids
        self._title = ""
        self._organizer: User | None = None
        self._start: datetime | None = None
        self._duration: timedelta | None = None
        self._attendees: list[str] = []
        self._recurrence: RecurrenceRule | None = None
        self._room_id: str | None = None

    def titled(self, title: str) -> MeetingBuilder:
        self._title = title.strip()
        return self

    def organized_by(self, organizer: User) -> MeetingBuilder:
        self._organizer = organizer
        return self

    def starting_at(self, local: datetime | str, zone: str | None = None) -> MeetingBuilder:
        """Local wall-clock time. Without a zone the organiser's own zone is used."""
        moment = datetime.fromisoformat(local) if isinstance(local, str) else local
        if moment.tzinfo is None:
            if zone is None and self._organizer is None:
                raise ValidationError("set the organiser or pass a zone before the start time")
            key = zone or (self._organizer.timezone if self._organizer else "UTC")
            moment = moment.replace(tzinfo=ZoneInfo(key))
        self._start = moment
        return self

    def lasting(self, minutes: int) -> MeetingBuilder:
        self._duration = timedelta(minutes=minutes)
        return self

    def with_attendees(self, *user_ids: str) -> MeetingBuilder:
        self._attendees.extend(user_ids)
        return self

    def repeating(self, rule: RecurrenceRule) -> MeetingBuilder:
        self._recurrence = rule
        return self

    def in_room(self, room_id: str | None) -> MeetingBuilder:
        self._room_id = room_id
        return self

    def build(self) -> Meeting:
        if not self._title:
            raise ValidationError("a meeting needs a title")
        if self._organizer is None:
            raise ValidationError("a meeting needs an organiser")
        if self._start is None or self._duration is None:
            raise ValidationError("a meeting needs a start time and a duration")
        if self._duration <= timedelta(0):
            raise ValidationError("a meeting must last longer than zero minutes")
        attendees = dict.fromkeys([self._organizer.id, *self._attendees])  # ordered, deduplicated
        meeting = Meeting(
            id=self._ids.next_id(),
            title=self._title,
            organizer_id=self._organizer.id,
            attendee_ids=tuple(attendees),
            start=self._start,
            duration=self._duration,
            recurrence=self._recurrence,
            room_id=self._room_id,
        )
        meeting.invitations = {
            attendee: Invitation(meeting.id, attendee, InvitationStatus.ACCEPTED)
            if attendee == self._organizer.id
            else Invitation(meeting.id, attendee)
            for attendee in meeting.attendee_ids
        }
        return meeting

Now the part interviewers actually push on. Adding 24 hours to an instant is wrong twice a year; adding one day to the local naive time and re-attaching the zone is right. localise also handles the hour that does not exist on the morning the clocks jump forward.

code/lld/meeting_scheduler/recurrence.py — wall-clock arithmetic
def localise(naive: datetime, zone: ZoneInfo) -> datetime:
    """Attach a zone to a wall-clock time, stepping over the spring-forward gap.

    On the morning the clocks jump, 02:30 local does not exist. Python will happily
    build the value and then give it the pre-transition offset, so a round trip
    through UTC comes back as a different wall time - that is the test for a gap.
    """
    candidate = naive.replace(tzinfo=zone)
    round_trip = candidate.astimezone(UTC).astimezone(zone).replace(tzinfo=None)
    if round_trip != naive:
        candidate = (naive + timedelta(hours=1)).replace(tzinfo=zone)
    return candidate


def shifted(first: datetime, days: int) -> datetime:
    """The same wall-clock time, ``days`` later, in the same zone."""
    zone = first.tzinfo
    if not isinstance(zone, ZoneInfo):
        return first + timedelta(days=days)
    return localise(first.replace(tzinfo=None) + timedelta(days=days), zone)
code/lld/meeting_scheduler/recurrence.py — recurrence strategies
class RecurrenceStrategy(Protocol):
    """Expands a series into start times that fall inside a window."""

    def starts(self, first: datetime, window: Interval) -> Iterator[datetime]: ...


class NoRecurrence:
    def starts(self, first: datetime, window: Interval) -> Iterator[datetime]:
        if window.start <= first < window.end:
            yield first


class DailyRecurrence:
    def __init__(self, rule: RecurrenceRule) -> None:
        self._rule = rule

    def starts(self, first: datetime, window: Interval) -> Iterator[datetime]:
        rule = self._rule
        for index in range(MAX_OCCURRENCES):
            if rule.count is not None and index >= rule.count:
                return
            moment = shifted(first, index * rule.every)
            if rule.until is not None and moment > rule.until:
                return
            if moment >= window.end:
                return
            if moment >= window.start:
                yield moment


class WeeklyRecurrence:
    """``weekdays`` empty means "the weekday the series starts on"."""

    def __init__(self, rule: RecurrenceRule) -> None:
        self._rule = rule

    def starts(self, first: datetime, window: Interval) -> Iterator[datetime]:
        rule = self._rule
        weekdays = sorted(rule.weekdays) or [first.weekday()]
        emitted = 0
        for week in range(MAX_OCCURRENCES):
            monday = shifted(first, -first.weekday() + week * 7 * rule.every)
            for weekday in weekdays:
                moment = shifted(monday, weekday)
                if moment < first:
                    continue
                if rule.until is not None and moment > rule.until:
                    return
                if moment >= window.end or (rule.count is not None and emitted >= rule.count):
                    return
                emitted += 1
                if moment >= window.start:
                    yield moment


def strategy_for(rule: RecurrenceRule | None) -> RecurrenceStrategy:
    """Factory: the rule is data, the expansion is behaviour."""
    if rule is None:
        return NoRecurrence()
    if rule.frequency is Frequency.DAILY:
        return DailyRecurrence(rule)
    if rule.frequency is Frequency.WEEKLY:
        return WeeklyRecurrence(rule)
    raise ValidationError(f"unsupported recurrence frequency {rule.frequency}")

Slot finding is a scan over the gaps. WorkingHoursSlotFinder does not reimplement it: it adds each attendee's out-of-hours time as busy and delegates.

code/lld/meeting_scheduler/recurrence.py — slot finders
class SlotFinder(Protocol):
    """Picks an interval of ``duration`` that clears every busy block in ``window``."""

    def find(
        self,
        attendees: Sequence[User],
        busy: list[Interval],
        window: Interval,
        duration: timedelta,
    ) -> Interval | None: ...


class EarliestSlotFinder:
    """Merge the busy blocks, then take the first gap that is long enough."""

    def find(
        self,
        attendees: Sequence[User],
        busy: list[Interval],
        window: Interval,
        duration: timedelta,
    ) -> Interval | None:
        for gap in free_gaps(busy, window):
            if gap.duration >= duration:
                return Interval(gap.start, gap.start + duration)
        return None


class WorkingHoursSlotFinder:
    """Same scan, but every attendee's out-of-hours time counts as busy.

    Each attendee's working day is in *their* zone, so this is where three people on
    three continents discover they have no common hour - and the finder returns None
    instead of booking someone's 03:00.
    """

    def __init__(self, start_hour: int = 9, end_hour: int = 17) -> None:
        if not 0 <= start_hour < end_hour <= 24:
            raise ValidationError("working hours must satisfy 0 <= start < end <= 24")
        self._start_hour = start_hour
        self._end_hour = end_hour
        self._inner = EarliestSlotFinder()

    def find(
        self,
        attendees: Sequence[User],
        busy: list[Interval],
        window: Interval,
        duration: timedelta,
    ) -> Interval | None:
        blocks = list(busy)
        for attendee in attendees:
            blocks.extend(self._off_hours(attendee, window))
        return self._inner.find(attendees, blocks, window, duration)

    def _off_hours(self, attendee: User, window: Interval) -> list[Interval]:
        """Build this attendee's working days, then treat everything else as busy."""
        zone = attendee.zone
        working: list[Interval] = []
        day = window.start.astimezone(zone).date() - timedelta(days=1)
        last = window.end.astimezone(zone).date() + timedelta(days=1)
        while day <= last:
            opens = localise(datetime.combine(day, time(self._start_hour)), zone)
            closes = (
                localise(datetime.combine(day, time(self._end_hour)), zone)
                if self._end_hour < 24
                else localise(datetime.combine(day + timedelta(days=1), time(0)), zone)
            )
            working.append(Interval(opens, closes))
            day += timedelta(days=1)
        return free_gaps(working, window)

Availability turns meetings into busy blocks: expand within the window, drop cancelled dates, apply moved ones, merge.

code/lld/meeting_scheduler/services.py — availability
class AvailabilityService:
    """Turns meetings into busy blocks: expand, drop exceptions, merge."""

    def __init__(self, meetings: MeetingRepository) -> None:
        self._meetings = meetings

    def occurrences(self, meeting: Meeting, window: Interval) -> list[Interval]:
        """Every occurrence overlapping ``window``, with cancelled dates removed."""
        # Widen the search so a meeting that starts before the window but runs into it counts.
        expansion = Interval(window.start - meeting.duration, window.end)
        found: list[Interval] = []
        for start in strategy_for(meeting.recurrence).starts(meeting.start, expansion):
            on = start.astimezone(meeting.start.tzinfo).date()
            if on in meeting.cancelled_dates:
                continue
            moved = meeting.moved_dates.get(on)
            actual = moved if moved is not None else start
            found.append(Interval(actual, actual + meeting.duration))
        return found

    def busy(self, user_ids: Sequence[str], window: Interval) -> list[Interval]:
        blocks: list[Interval] = []
        for user_id in user_ids:
            for meeting in self._meetings.for_attendee(user_id):
                if meeting.blocks(user_id):
                    blocks.extend(self.occurrences(meeting, window))
        return merge_intervals(blocks)

    def free(self, user_ids: Sequence[str], window: Interval) -> list[Interval]:
        return free_gaps(self.busy(user_ids, window), window)

    def is_free(self, user_ids: Sequence[str], interval: Interval) -> bool:
        return not any(block.overlaps(interval) for block in self.busy(user_ids, interval))

The room registry owns the lock that matters. book is all-or-nothing across every occurrence of a series.

code/lld/meeting_scheduler/services.py — rooms
class RoomRegistry:
    """Rooms and their bookings. One lock per room: the finest granularity that works.

    ``book`` is all-or-nothing across every occurrence of a series - a weekly meeting
    that clashes on one Tuesday books no Tuesdays at all.
    """

    def __init__(self, rooms: Iterable[MeetingRoom]) -> None:
        self._rooms: dict[str, MeetingRoom] = {room.id: room for room in rooms}
        self._bookings: dict[str, list[RoomBooking]] = {room_id: [] for room_id in self._rooms}
        self._locks: dict[str, threading.Lock] = {room_id: threading.Lock() for room_id in self._rooms}

    def room(self, room_id: str) -> MeetingRoom:
        try:
            return self._rooms[room_id]
        except KeyError:
            raise MeetingNotFoundError(f"unknown room {room_id}") from None

    def bookings(self, room_id: str) -> list[RoomBooking]:
        with self._locks[room_id]:
            return list(self._bookings[room_id])

    def book(self, room_id: str, meeting_id: str, intervals: Sequence[Interval]) -> list[RoomBooking]:
        room = self.room(room_id)
        with self._locks[room_id]:
            for existing in self._bookings[room_id]:
                clash = next((i for i in intervals if existing.interval.overlaps(i)), None)
                if clash is not None:
                    raise SlotConflictError(f"{room.name} is already booked for {clash}")
            fresh = [RoomBooking(room_id, meeting_id, interval) for interval in intervals]
            self._bookings[room_id].extend(fresh)
            return fresh

    def release(self, room_id: str, meeting_id: str) -> int:
        with self._locks[room_id]:
            before = len(self._bookings[room_id])
            self._bookings[room_id] = [b for b in self._bookings[room_id] if b.meeting_id != meeting_id]
            return before - len(self._bookings[room_id])

    def book_first_available(
        self, meeting_id: str, intervals: Sequence[Interval], attendees: int
    ) -> str | None:
        """Try rooms smallest-that-fits first. Losing a race is a retry, not an error."""
        candidates = sorted(
            (r for r in self._rooms.values() if r.capacity >= attendees),
            key=lambda r: (r.capacity, r.id),
        )
        for room in candidates:
            try:
                self.book(room.id, meeting_id, intervals)
            except SlotConflictError:
                continue
            return room.id
        return None

The scheduler is a facade: it composes the pieces, holds one lock per meeting for status changes, and announces outside every lock.

code/lld/meeting_scheduler/services.py — the scheduler and the reschedule command
class MeetingScheduler:
    """Facade over repository, availability, rooms and notifications.

    Locks: one per meeting for status and invitation changes (``_lock_for``), one per
    room inside ``RoomRegistry``. Order is always meeting first, then room.
    """

    BOOKING_HORIZON_DAYS = 60

    def __init__(
        self,
        meetings: MeetingRepository,
        rooms: RoomRegistry,
        users: Iterable[User],
        observers: Iterable[MeetingObserver] = (),
        clock: Clock | None = None,
        ids: IdGenerator | None = None,
    ) -> None:
        self._meetings = meetings
        self._rooms = rooms
        self._users = {user.id: user for user in users}
        self._observers = list(observers)
        self._clock = clock or SystemClock()
        self._ids = ids or SequentialIdGenerator("M")
        self._availability = AvailabilityService(meetings)
        self._meeting_locks: dict[str, threading.RLock] = {}
        self._registry_lock = threading.Lock()

    @property
    def availability(self) -> AvailabilityService:
        return self._availability

    @property
    def rooms(self) -> RoomRegistry:
        return self._rooms

    def builder(self) -> MeetingBuilder:
        return MeetingBuilder(self._ids)

    def user(self, user_id: str) -> User:
        try:
            return self._users[user_id]
        except KeyError:
            raise MeetingNotFoundError(f"unknown user {user_id}") from None

    def schedule(self, meeting: Meeting) -> Meeting:
        """Book the room for every occurrence in the horizon, store, then announce."""
        occurrences = self.horizon_occurrences(meeting)
        if meeting.room_id is not None:
            room = self._rooms.room(meeting.room_id)
            if room.capacity < len(meeting.attendee_ids):
                raise ValidationError(
                    f"{room.name} seats {room.capacity}, meeting has {len(meeting.attendee_ids)} attendees"
                )
            self._rooms.book(meeting.room_id, meeting.id, occurrences)
        self._meetings.add(meeting)
        self._announce("invited", meeting)
        return meeting

    def schedule_first_available(
        self,
        title: str,
        organizer: User,
        attendee_ids: Sequence[str],
        minutes: int,
        window: Interval,
        finder: SlotFinder | None = None,
        with_room: bool = True,
    ) -> Meeting:
        """The headline flow: busy blocks, merge, first gap, room, invitations, notify."""
        attendees = tuple(dict.fromkeys([organizer.id, *attendee_ids]))
        slot = self.find_slot(attendees, minutes, window, finder)
        if slot is None:
            raise NoFreeSlotError(f"no {minutes}-minute slot for {len(attendees)} people in {window}")
        meeting = (
            self.builder()
            .titled(title)
            .organized_by(organizer)
            .with_attendees(*attendee_ids)
            .starting_at(slot.start.astimezone(organizer.zone))
            .lasting(minutes)
            .build()
        )
        if with_room:
            room_id = self._rooms.book_first_available(meeting.id, [slot], len(attendees))
            if room_id is None:
                raise NoFreeSlotError(f"no room free for {slot}")
            meeting.room_id = room_id
        self._meetings.add(meeting)
        self._announce("invited", meeting)
        return meeting

    def find_slot(
        self, attendee_ids: Sequence[str], minutes: int, window: Interval, finder: SlotFinder | None = None
    ) -> Interval | None:
        attendees = [self.user(user_id) for user_id in attendee_ids]
        busy = self._availability.busy(attendee_ids, window)
        return (finder or EarliestSlotFinder()).find(attendees, busy, window, timedelta(minutes=minutes))

    def horizon_occurrences(self, meeting: Meeting) -> list[Interval]:
        horizon = Interval(meeting.start, meeting.start + timedelta(days=self.BOOKING_HORIZON_DAYS))
        return self._availability.occurrences(meeting, horizon)

    def respond(self, meeting_id: str, attendee_id: str, status: InvitationStatus) -> None:
        meeting = self._meetings.get(meeting_id)
        with self._lock_for(meeting_id):
            invitation = meeting.invitations.get(attendee_id)
            if invitation is None:
                raise MeetingNotFoundError(f"{attendee_id} was not invited to {meeting_id}")
            invitation.respond(status, self._clock.now())
        self._announce(f"{attendee_id} is {status}", meeting, (meeting.organizer_id,))

    def cancel(self, meeting_id: str) -> Meeting:
        meeting = self._meetings.get(meeting_id)
        with self._lock_for(meeting_id):
            meeting.status = MeetingStatus.CANCELLED
            for invitation in meeting.invitations.values():
                invitation.withdraw()
            if meeting.room_id is not None:
                self._rooms.release(meeting.room_id, meeting.id)
        self._announce("cancelled", meeting)
        return meeting

    def cancel_occurrence(self, meeting_id: str, on: date) -> Meeting:
        """One Tuesday off, the series intact: the exception list, not a new meeting."""
        meeting = self._meetings.get(meeting_id)
        with self._lock_for(meeting_id):
            meeting.cancelled_dates.add(on)
        self._announce(f"occurrence on {on} cancelled", meeting)
        return meeting

    def move_occurrence(self, meeting_id: str, on: date, new_start: datetime) -> Meeting:
        meeting = self._meetings.get(meeting_id)
        with self._lock_for(meeting_id):
            meeting.moved_dates[on] = new_start
        self._announce(f"occurrence on {on} moved", meeting)
        return meeting

    def reschedule(self, meeting_id: str, new_start: datetime) -> RescheduleCommand:
        command = RescheduleCommand(self, self._meetings.get(meeting_id), new_start)
        command.execute()
        return command

    def due_reminders(self, lead_minutes: int = 10) -> list[Reminder]:
        now = self._clock.now_dt()
        window = Interval(now, now + timedelta(minutes=lead_minutes))
        return [
            Reminder(meeting.id, occurrence, lead_minutes)
            for meeting in self._meetings.all()
            if meeting.is_active()
            for occurrence in self._availability.occurrences(meeting, window)
            if now <= occurrence.start < window.end
        ]

    def move(self, meeting: Meeting, new_start: datetime) -> Meeting:
        """Used by the reschedule command in both directions."""
        with self._lock_for(meeting.id):
            if meeting.room_id is not None:
                self._rooms.release(meeting.room_id, meeting.id)
            previous, meeting.start = meeting.start, new_start
            if meeting.room_id is not None:
                try:
                    self._rooms.book(meeting.room_id, meeting.id, self.horizon_occurrences(meeting))
                except SlotConflictError:
                    meeting.start = previous
                    self._rooms.book(meeting.room_id, meeting.id, self.horizon_occurrences(meeting))
                    raise
        self._announce("rescheduled", meeting)
        return meeting

    def _lock_for(self, meeting_id: str) -> threading.RLock:
        with self._registry_lock:
            return self._meeting_locks.setdefault(meeting_id, threading.RLock())

    def _announce(self, event: str, meeting: Meeting, recipients: Sequence[str] | None = None) -> None:
        targets = recipients if recipients is not None else meeting.attendee_ids
        for observer in self._observers:  # outside every lock: a slow listener blocks nobody
            observer.on_meeting_event(event, meeting, targets)


class RescheduleCommand:
    """Command with undo: the organiser moved it by mistake, put it back."""

    def __init__(self, scheduler: MeetingScheduler, meeting: Meeting, new_start: datetime) -> None:
        self._scheduler = scheduler
        self._meeting = meeting
        self._new_start = new_start
        self._previous_start = meeting.start

    def execute(self) -> Meeting:
        return self._scheduler.move(self._meeting, self._new_start)

    def undo(self) -> Meeting:
        return self._scheduler.move(self._meeting, self._previous_start)

python -m lld.meeting_scheduler.demo runs a standup across the March DST switch, two slot searches and a room clash:

M-1 Standup: 09:00 Europe/Berlin daily, 7 in the week
before the DST switch: 2026-03-25 08:00-08:15Z | after it: 2026-03-31 07:00-07:15Z
cancelled 2026-03-27: 6 occurrences left
ada is busy ['2026-03-26 08:00-08:15Z', '2026-03-26 13:00-14:00Z']
first 30 min for ada+linus: 2026-03-26 08:15-08:45Z
inside 09:00-17:00 in Berlin and New York: 2026-03-26 14:00-14:30Z
adding Grace in Kolkata: None
room clash: Turing is already booked for 2026-03-26 13:30-14:00Z
invitations: {'ada': 'accepted', 'linus': 'declined', 'grace': 'tentative'}
rescheduled standup to 2026-03-25 10:00-10:15Z
undo put it back to 2026-03-25 08:00-08:15Z; 16 notifications sent
reminders due at 07:55Z: ['M-1']

Concurrency and edge cases

Which lock protects what.

  1. RoomRegistry._locks[room_id] — one lock per room, held across the overlap check and the insert. This is the only hard-enforced conflict in the system, so it is the only place that needs the check-then-act to be atomic. Per room rather than one global lock: two organisers booking two different rooms never wait for each other.
  2. MeetingScheduler._meeting_locks[meeting_id] — one per meeting, guarding status, invitations and the exception lists. Two attendees answering at the same instant touch the same dict; the lock makes "cancel the meeting" and "accept the invitation" a total order rather than a coin flip.
  3. InMemoryMeetingRepository._lock and NotificationService._lock — the dictionaries themselves.

The ordering rule: meeting lock, then room lock, never the reverse. cancel holds the meeting lock and calls release; nothing holds a room lock and reaches for a meeting.

The race it prevents. Eight threads book the same room for the same hour. Each one reads the booking list, sees no clash, and inserts. Without the lock all eight succeed and the room is eight times booked; with it, one wins and seven get SlotConflictError. book_first_available turns that loss into a retry on the next room, which is the optimistic pattern: never hold a lock while searching, only while claiming.

Half-open intervals. 10:00-11:00 and 11:00-12:00 do not overlap. With closed intervals they do, and you will spend the last ten minutes of the interview explaining why nobody can book the hour after yours. The test parametrises all four boundary cases.

Recurrence bounds. Every expansion takes a window and a hard cap (MAX_OCCURRENCES). A daily meeting with no end date is not a bug in the rule, it is a bug in any code that expands it eagerly. Room bookings use a 60-day horizon for the same reason - a rolling job extends it.

DST. Two things break, and both are in the tests: the 09:00 standup must stay 09:00 across the switch (its UTC instant moves by an hour), and a time that does not exist (02:30 on the spring-forward morning) must be pushed forward rather than silently given the wrong offset. The general rule to state: store instants, schedule wall-clock, convert at the edges.

Editing one occurrence. cancelled_dates removes a single Tuesday and moved_dates shifts one, both keyed by the occurrence's local date; the series stays one row. The honest limitation to name: an occurrence moved out of an expansion window disappears from that window's results, which is why production systems promote a moved occurrence to its own row that points back at the series.

Cost check. Searching a week for ten attendees with twenty meetings each is 200 intervals; merging is one sort, ~200 x log2(200) ≈ 1.5k comparisons. At the estimation cheatsheet's 100 ns main-memory reference that is well under a millisecond of real work - the expansion, not the merge, is what to bound.

Common mistake

Storing recurring meetings as UTC instants plus "add 86400 seconds". It passes every test you write in June and produces 08:00 standups in November. The fix is one line - add days to the local naive time and re-attach the zone - but only if the meeting kept its zone, which is why Meeting.start is aware and in the organiser's zone rather than normalised to UTC on the way in.

Extensibility and follow-ups

  • Minimum number of rooms for a set of meetings (the "Meeting Rooms II" question): sort starts, push ends onto a heap, pop everything that ended before the next start; the answer is the maximum heap size. Your Interval already has what it needs.
  • Ranked suggestions instead of the first gap: another SlotFinder that scores candidate gaps (fewest attendees dragged out of hours, least fragmentation of the day) and returns the best. Nothing else changes - that is the Strategy paying rent.
  • Working hours and holidays per user: WorkingHoursSlotFinder already reads each attendee's zone; give User a working-hours field and a holiday calendar and feed both into _off_hours.
  • External sync (Exchange, Google): an adapter that maps their event model onto Meeting plus a sync token; conflicts become last-writer-wins per field or a merge dialogue.
  • Materialised occurrences: for a series older than the horizon, write rows into an occurrence table so queries become index scans instead of expansions. That is the moment this becomes an HLD conversation about sharding by user and fanning out reminders.
  • Reminders at scale: today due_reminders polls. At millions of users that becomes a time-bucketed queue, which is the same design as a distributed job scheduler.

Interview tip

Draw the timeline before you write any code: a horizontal line, three attendees' busy blocks stacked, the merged row underneath, the gap you would pick. Then say "merge, then scan the gaps" and write the ten lines. Interviewers grade this problem on whether you found the interval algebra, and a drawing gets you there faster than a class diagram does.

Tests

tests/test_meeting_scheduler.py has 12 cases (16 with parametrisation). The boundary cases are parametrised (11:00-12:00 after 10:00-11:00 is not a clash); merge_intervals and free_gaps are checked as inverses on the same input; weekly recurrence expands only the named weekdays; a cancelled occurrence disappears while the series stays active; a declined invitation frees the attendee but not the organiser; a cancelled meeting withdraws invitations, releases the room and refuses further answers; the builder refuses an incomplete meeting three ways.

The two to walk through are DST and the room race:

code/lld/meeting_scheduler/tests/test_meeting_scheduler.py — DST
def test_a_daily_meeting_keeps_local_time_across_the_dst_switch(scheduler: MeetingScheduler) -> None:
    standup = scheduler.schedule(
        scheduler.builder()
        .titled("Standup")
        .organized_by(ADA)
        .starting_at("2026-03-25 09:00")  # 09:00 in Europe/Berlin
        .lasting(15)
        .repeating(RecurrenceRule(Frequency.DAILY))
        .build()
    )
    week = interval("2026-03-25 00:00", "2026-04-01 00:00")
    starts = [o.start.astimezone(UTC).strftime("%m-%d %H:%M") for o in scheduler.availability.occurrences(standup, week)]
    assert starts[:2] == ["03-25 08:00", "03-26 08:00"]  # CET, UTC+1
    assert starts[-2:] == ["03-30 07:00", "03-31 07:00"]  # CEST, UTC+2 - same 09:00 local
    assert len(starts) == 7


def test_a_wall_clock_time_that_does_not_exist_moves_forward() -> None:
    gap = localise(datetime(2026, 3, 29, 2, 30), BERLIN)  # the clocks jump 02:00 -> 03:00
    assert gap.hour == 3 and gap.minute == 30
    normal = localise(datetime(2026, 3, 30, 2, 30), BERLIN)
    assert normal.hour == 2 and normal.minute == 30
code/lld/meeting_scheduler/tests/test_meeting_scheduler.py — one room, eight threads
def test_only_one_of_many_racing_bookings_gets_the_room(scheduler: MeetingScheduler) -> None:
    slot = interval("2026-03-26 13:00", "2026-03-26 14:00")

    def grab(i: int) -> str | None:
        try:
            scheduler.rooms.book("R-1", f"M-{i}", [slot])
        except SlotConflictError:
            return None
        return f"M-{i}"

    with ThreadPoolExecutor(max_workers=8) as pool:
        winners = [name for name in pool.map(grab, range(8)) if name is not None]

    assert len(winners) == 1  # the per-room lock decides, everybody else is told no
    assert len(scheduler.rooms.bookings("R-1")) == 1

The reschedule test is worth a mention because it asserts on the room, not the meeting: after execute the booking has moved, after undo it is back where it started. Run everything with uv run pytest code/lld/meeting_scheduler -q.

45-minute pacing

Minutes What to do What to say or write
0-5 Clarify Half-open or closed? Do pending invitations block time? Recurrence in scope? Timezones? Out of scope: external sync, video links.
5-10 Entities and the timeline Draw busy blocks, the merged row and the gap. Name the entities: Interval, Meeting, Invitation, Room, RoomBooking.
10-16 Class diagram Meeting with a rule and two exception collections; the two Strategy families; where the locks live.
16-26 Interval algebra Write overlaps, merge_intervals, free_gaps and the earliest-slot scan. This is the core and it should be on the board by minute 26.
26-34 Recurrence and DST Expand within a window, drop exceptions, and say "wall-clock, not UTC arithmetic". Write shifted.
34-40 Rooms and concurrency Per-room lock, all-or-nothing booking, the eight-thread test, the lock order rule.
40-45 Extensions Minimum rooms with a heap, ranked suggestions as a strategy, materialised occurrences and the hand-off to HLD.