Meeting Scheduler

Calendars, rooms, attendees, time-zone arithmetic. The interval-management problem with first-class side-effects.

System Intermediate
13 min read
ood case-study meeting-scheduler strategy-pattern observer-pattern

Context#

A meeting scheduler lets users book a slot of time, optionally in a physical room, with a set of attendees who each carry their own calendar. The system must reject overlapping bookings, find rooms that satisfy capacity and equipment requirements, deal with attendees in different time zones, and notify everyone when something is scheduled, rescheduled, or cancelled. Recurring meetings (weekly stand-up, monthly review) sit on top.

The problem is a favourite second-round OOD prompt because it forces three uncomfortable conversations at once: interval arithmetic (overlap, merge, gap), time-zone correctness (store in UTC, render in zone), and side-effects (someone has to email everyone, and that someone is not the calendar). The candidate who collapses these into one class fails; the candidate who separates them cleanly looks senior.

The interviewer’s hidden objectives, in roughly the order they will be tested:

  • Can you clarify scope — single-room booking vs. global enterprise calendar — without spinning out?
  • Can you identify the entities — User, Calendar, Meeting, TimeSlot, MeetingRoom — and the asymmetry between them (a meeting has many attendees; an attendee has many meetings)?
  • Can you handle time zones correctly the first time, not on a follow-up?
  • Can you separate room-finding from notifying so each is its own pluggable concern?
  • Can you defend trade-offs when the interviewer pushes (recurring meetings, partial accepts, half-hour buffers, room equipment)?

Requirements (functional and non-functional)#

Clarifying in the room is the most points-bearing part. The scope below is the one most interviewers expect; anything outside it should be flagged out-of-scope so you can finish.

Functional — in scope.

  • A user can create a meeting with a title, a time slot (start, end), a set of attendees, and optionally a meeting room.
  • The system rejects the booking if the room is already booked for any overlapping slot, or if any required attendee is busy. Optional attendees do not block.
  • A user can cancel or reschedule their own meetings; attendees are notified.
  • Users can query their calendar for a given day or week; the answer renders in the user’s own zone.
  • A room is selected from a pool by capacity and required equipment (projector, video-conference, whiteboard).
  • Support recurring meetings via a simple rule (daily, weekly on weekdays X, monthly on day-of-month N) with a count or end date.

Functional — out of scope (called out explicitly). Half-day all-hands, resource booking beyond meeting rooms, calendar federation across organisations, ICS export/import, ML-driven optimal-time-finder. Acknowledge them so the interviewer knows you saw them.

Non-functional.

  • An organisation of 10^4 users, 10^3 rooms, 10^6 meetings/year. In-process data structures suffice for the design discussion; persistence is a separate concern.
  • A “free/busy” query for a user-week should return in under 50 ms.
  • Concurrency: two users may try to book the same room for overlapping slots at the same instant; the design must avoid double-booking.
  • Time zones are first-class: storage in UTC, presentation in the viewer’s zone, with DST transitions handled correctly.

Use case diagram#

┌────────────────┐
│ Organiser │
└────────┬───────┘
┌──────────────────┼──────────────────┐
▼ ▼ ▼
[create meeting] [reschedule] [cancel]
│ │ │
└──────────────────┼──────────────────┘
┌─────────────────────────┐
│ Meeting Scheduler │◄────── [view calendar] ◄── Attendee
└─────────┬───────────────┘
┌────────────────────────────────────┐
│ [find room] [notify attendees] │
└────────────────────────────────────┘
┌─────────┴────────┐
│ System Admin │ … manage room pool, equipment, policies
└──────────────────┘

Two primary actors (Organiser, Attendee), one administrative actor. The interesting use cases are find room and notify attendees — they are pulled out because they are the two pluggable surfaces in the design.

Class diagram#

┌──────────────────────────────┐
│ MeetingScheduler │
├──────────────────────────────┤
│ rooms : RoomRegistry │
│ users : UserRegistry │
│ finder : RoomFinder │ ◇── Strategy
│ notifier : Notifier │ ◇── Observer hub
├──────────────────────────────┤
│ schedule(req) : Meeting │
│ cancel(meetingId) │
│ reschedule(meetingId, slot) │
└──────────────┬───────────────┘
│ creates / mutates
┌──────────────────────────────┐
│ Meeting │
├──────────────────────────────┤
│ id, title │
│ organiser : User │
│ slot : TimeSlot │
│ required, optional : Set<User>│
│ room : MeetingRoom? │
│ recurrence: RecurrenceRule? │
│ status : Status │
└──────────────────────────────┘
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────────┐
│ User │ │ MeetingRoom │ │ TimeSlot │
├─────────────────┤ ├──────────────────┤ ├─────────────────────────┤
│ id, name, zone │ │ id, capacity │ │ start, end : Instant │
│ calendar : Calendar │ │ equipment:Set │ │ overlaps(other) : bool │
└────────┬────────┘ │ calendar:Calendar│ │ durationMinutes() │
│ └──────────────────┘ └─────────────────────────┘
┌──────────────────────────────────┐
│ Calendar │
├──────────────────────────────────┤
│ bookings : NavigableMap<Instant, Meeting> │
├──────────────────────────────────┤
│ isBusy(slot) : boolean │
│ add(m) / remove(m) │
│ between(from, to) : List<Meeting>│
└──────────────────────────────────┘
┌─────────────────────────────┐ ┌──────────────────────────┐
│ RoomFinder │ │ Notifier │
├─────────────────────────────┤ ├──────────────────────────┤
│ find(req, rooms) : Room? │ │ subscribe(listener) │
└─────────────────────────────┘ │ publish(event) │
▲ └──────────────────────────┘
│ ▲
┌────┴────────────┐ ┌─────┴─────────────────┐
│ FirstFitFinder │ │ EmailListener │
│ SmallestFitFinder│ │ SlackListener │
└─────────────────┘ │ CalendarSyncListener │
└───────────────────────┘

Three patterns are doing the load-bearing work:

  • Strategy pattern on RoomFinder — first-fit, smallest-fit, equipment-first. The scheduler does not switch on policy.
  • Observer pattern on Notifier — email, Slack, calendar-sync subscribers attach without the scheduler knowing they exist.
  • Composite on RecurrenceRule (implicit) — a meeting’s occurrences are expanded lazily by the rule on each query; the rule is the only place that knows about DST and month boundaries.

What is not in the diagram and that is deliberate:

  • No RecurringMeeting subclass. Recurrence is a property of the meeting, not a kind of meeting. A flat Meeting with an optional RecurrenceRule keeps the type system honest.
  • No User.meetings list. Membership flows through User.calendar.bookings. One place owns the interval data.

Sequence diagram (key flows)#

The schedule flow with room-finding and conflict-detection:

Organiser Scheduler UserRegistry RoomFinder Calendar(each user) Notifier
│ schedule(req)│ │ │ │ │
│─────────────►│ │ │ │ │
│ │ resolve(attendees) │ │ │
│ │───────────────►│ │ │ │
│ │ users │ │ │ │
│ │◄───────────────│ │ │ │
│ │ find(req) │ │ │ │
│ │───────────────────────────────►│ │ │
│ │ room │ │ │ │
│ │◄───────────────────────────────│ │ │
│ │ for each required user: isBusy(slot)? │ │
│ │────────────────────────────────────────────────►│ │
│ │ false │ │
│ │◄────────────────────────────────────────────────│ │
│ │ persist Meeting; calendar.add() for each + room │ │
│ │────────────────────────────────────────────────►│ │
│ │ publish(MeetingScheduled) │ │
│ │──────────────────────────────────────────────────────────────────────► │
│ meeting │ │ │ │ │
│◄─────────────│ │ │ │ │

If any required attendee is busy, the scheduler aborts before any state changes; the entire booking is atomic against the set of calendars. The contention discussion belongs to the trade-offs section.

The reschedule flow:

Organiser Scheduler OldSlot NewSlot Notifier
│ reschedule(id, newSlot) │ │ │
│────────────►│ │ │ │
│ │ load meeting │ │ │
│ │ check conflicts on newSlot │ │
│ │ for each calendar: remove(old); add(new) │
│ │ publish(MeetingRescheduled, old, new) │
│ │───────────────────────────────────────────────────►│

Rescheduling is “cancel + reschedule” as a single transaction; the published event carries both slots so listeners can render a correct diff.

Activity diagram (for non-trivial state)#

A Meeting lifecycle, where the state and the side-effects must agree:

┌──────────┐
│ start │
└────┬─────┘
┌──────────────┐
│ Drafted │ ◄── client validation; no calendar write yet
└──────┬───────┘
│ schedule()
┌──────────────────────────────────┐
│ check room + required attendees │
└──────┬──────────────┬────────────┘
│ conflict │ ok
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Rejected │ │ Scheduled │
└──────────────┘ └──────┬───────┘
┌──────────────┼──────────────┐
│ cancel() │ reschedule() │ tick(slot.end)
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌───────────┐
│ Cancelled │ │ Rescheduled│→ │ Completed │
└────────────┘ └────────────┘ └───────────┘

Rescheduled immediately collapses back into Scheduled on the new slot — it exists as a notification gate, not a stored state. Recurrence is a property of the meeting; each occurrence walks the same activity graph.

Java implementation#

A representative slice; the rest is mechanical.

public final class TimeSlot {
private final Instant start;
private final Instant end;
public TimeSlot(Instant start, Instant end) {
if (!start.isBefore(end)) throw new IllegalArgumentException("end must be after start");
this.start = start; this.end = end;
}
public Instant start() { return start; }
public Instant end() { return end; }
public boolean overlaps(TimeSlot other) {
return start.isBefore(other.end) && other.start.isBefore(end);
}
public ZonedDateTime startIn(ZoneId zone) { return start.atZone(zone); }
}
public final class Calendar {
// Keyed by slot start to keep neighbour lookups O(log n).
private final NavigableMap<Instant, Meeting> bookings = new TreeMap<>();
public synchronized boolean isBusy(TimeSlot slot) {
Map.Entry<Instant, Meeting> floor = bookings.floorEntry(slot.start());
if (floor != null && floor.getValue().slot().overlaps(slot)) return true;
Map.Entry<Instant, Meeting> ceil = bookings.ceilingEntry(slot.start());
return ceil != null && ceil.getValue().slot().overlaps(slot);
}
public synchronized void add(Meeting m) {
if (isBusy(m.slot())) throw new ConflictException(m.id());
bookings.put(m.slot().start(), m);
}
public synchronized void remove(Meeting m) { bookings.remove(m.slot().start()); }
public synchronized List<Meeting> between(Instant from, Instant to) {
return new ArrayList<>(bookings.subMap(from, to).values());
}
}
public final class MeetingRoom {
private final String id;
private final int capacity;
private final Set<Equipment> equipment;
private final Calendar calendar = new Calendar();
public MeetingRoom(String id, int capacity, Set<Equipment> equipment) {
this.id = id; this.capacity = capacity; this.equipment = equipment;
}
public boolean fits(BookingRequest req) {
return capacity >= req.attendeeCount() && equipment.containsAll(req.requiredEquipment());
}
public Calendar calendar() { return calendar; }
public String id() { return id; }
}
public interface RoomFinder {
Optional<MeetingRoom> find(BookingRequest req, Collection<MeetingRoom> pool);
}
public final class SmallestFitFinder implements RoomFinder {
public Optional<MeetingRoom> find(BookingRequest req, Collection<MeetingRoom> pool) {
return pool.stream()
.filter(r -> r.fits(req) && !r.calendar().isBusy(req.slot()))
.min(Comparator.comparingInt(MeetingRoom::capacity));
}
}
public final class Notifier {
private final List<EventListener> listeners = new CopyOnWriteArrayList<>();
public void subscribe(EventListener l) { listeners.add(l); }
public void publish(SchedulerEvent e) { for (EventListener l : listeners) l.on(e); }
}
public final class Meeting {
public enum Status { DRAFTED, SCHEDULED, CANCELLED, COMPLETED }
private final String id, title;
private final User organiser;
private TimeSlot slot;
private final Set<User> required, optional;
private final MeetingRoom room; // nullable
private final RecurrenceRule recurrence; // nullable
private Status status = Status.DRAFTED;
public Meeting(String id, String title, User organiser, TimeSlot slot,
Set<User> required, Set<User> optional,
MeetingRoom room, RecurrenceRule recurrence) {
this.id = id; this.title = title; this.organiser = organiser;
this.slot = slot; this.required = required; this.optional = optional;
this.room = room; this.recurrence = recurrence;
}
public TimeSlot slot() { return slot; }
public void rescheduleTo(TimeSlot newSlot) { this.slot = newSlot; }
public void markScheduled() { this.status = Status.SCHEDULED; }
public void markCancelled() { this.status = Status.CANCELLED; }
public String id() { return id; }
}
public final class MeetingScheduler {
private final RoomRegistry rooms;
private final RoomFinder finder;
private final Notifier notifier;
public MeetingScheduler(RoomRegistry rooms, RoomFinder finder, Notifier notifier) {
this.rooms = rooms; this.finder = finder; this.notifier = notifier;
}
public synchronized Meeting schedule(BookingRequest req) {
Optional<MeetingRoom> room = req.needsRoom()
? finder.find(req, rooms.all())
: Optional.empty();
if (req.needsRoom() && room.isEmpty()) throw new NoRoomAvailableException();
for (User u : req.required()) {
if (u.calendar().isBusy(req.slot())) throw new AttendeeBusyException(u.id());
}
Meeting m = new Meeting(UUID.randomUUID().toString(), req.title(),
req.organiser(), req.slot(), req.required(), req.optional(),
room.orElse(null), req.recurrence());
for (User u : req.required()) u.calendar().add(m);
for (User u : req.optional()) u.calendar().add(m);
room.ifPresent(r -> r.calendar().add(m));
m.markScheduled();
notifier.publish(new MeetingScheduled(m));
return m;
}
}

Notes the interviewer will look for:

  • Instant everywhere internally, ZoneId only at the edge. Storage is UTC; rendering converts. DST does not change Instant.now(). This is the one thing candidates get wrong.
  • NavigableMap<Instant, Meeting> for CalendarfloorEntry plus ceilingEntry is O(log n) and handles every overlap shape (A∩B, A⊂B, B⊂A, touching at an endpoint).
  • synchronized on the scheduler’s schedule method. Coarse correctness for the interview; the trade-offs section escalates to per-calendar locks or optimistic-concurrency on a follow-up.
  • CopyOnWriteArrayList for listeners. Reads dominate writes; the publish path is allocation-free.

Trade-offs and extensions#

Decisions explicitly made and what they cost:

DecisionWhyCost if requirements change
Coarse synchronized on MeetingScheduler.scheduleCorrectness first; an org of 10^4 users at 1 booking/sec is well within budget.At enterprise scale, partition the lock per room or per organiser, or move to optimistic concurrency on the Calendar aggregate.
NavigableMap keyed by slot startO(log n) overlap-check; the right data structure for sparse calendars.A dense room calendar with 10^4 bookings/year still fits comfortably; segment trees are overkill.
RoomFinder as a StrategySmallest-fit today, equipment-first tomorrow, ML-ranked next year — all without editing the scheduler.None — the right shape now.
Notifier with CopyOnWriteArrayListSubscribers attach without the scheduler knowing them. Read-heavy is the assumed pattern.If subscribers explode into the thousands, swap for a topic-based broker.
Recurrence as a rule, not a class hierarchyAvoids RecurringMeeting proliferation; rules compose.Complex recurrence (e.g. “third Thursday unless it’s a holiday”) needs richer rules; the type stays the same.
In-memory stateNo persistence requirement was given.Adding storage means a repository per aggregate (MeetingRepository, CalendarRepository); the interfaces are clean places to do it.

Likely follow-up extensions and the shape of the answer:

  • Half-hour buffers between meetings. A policy on Calendar.isBusy that pads slot by 30 minutes on both ends. The data does not change.
  • Partial accepts (“Alice can’t make it but the meeting can still happen”). A per-attendee response state (Pending → Accepted | Declined | Tentative); the meeting itself does not move.
  • Find-a-slot. A FreeSlotFinder that intersects attendees’ free intervals in a given window. O(n log n) via an event-sweep across calendar boundaries.
  • Multi-region rooms. Rooms gain a ZoneId; the finder filters by zone or by allowed travel-time from the organiser’s office.

Mock interview follow-ups#

Questions interviewers reach for and the briefest correct answer:

  • “How do you detect overlap?”start.isBefore(other.end) && other.start.isBefore(end). Half-open intervals: a meeting that ends at 10:00 does not conflict with one that starts at 10:00.
  • “Where does the time-zone conversion happen?” — At the presentation edge only. Everything inside the scheduler is Instant (UTC). DST is the zone’s problem, not the scheduler’s.
  • “What if two people book the last slot of a popular room at the same instant?” — Both calls contend on the room’s Calendar.add, which is synchronized; one wins, the other throws ConflictException. The losing call’s user-side adds are rolled back by the surrounding scheduler synchronized.
  • “How do you store a weekly recurring meeting?” — One Meeting row with a RecurrenceRule. Occurrences are computed lazily on read. Exceptions (one-off cancellations or moves) are stored as a small list of ExceptionDate → Override entries on the rule.
  • “How do you notify 200 attendees without blocking the booking call?”Notifier.publish hands events to listeners that themselves enqueue async work. The booking call stays under its latency budget; delivery is the listener’s job.
  • “What’s the state machine on a Meeting?”Drafted → Scheduled → (Cancelled | Completed), with Rescheduled as a transient notification event rather than a stored state. Show the activity diagram.
Search ESC

Keyboard shortcuts

Shortcuts are disabled while typing in inputs.