Meeting Scheduler
Calendars, rooms, attendees, time-zone arithmetic. The interval-management problem with first-class side-effects.
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^4users,10^3rooms,10^6meetings/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
RecurringMeetingsubclass. Recurrence is a property of the meeting, not a kind of meeting. A flatMeetingwith an optionalRecurrenceRulekeeps the type system honest. - No
User.meetingslist. Membership flows throughUser.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:
Instanteverywhere internally,ZoneIdonly at the edge. Storage is UTC; rendering converts. DST does not changeInstant.now(). This is the one thing candidates get wrong.NavigableMap<Instant, Meeting>forCalendar—floorEntryplusceilingEntryis O(log n) and handles every overlap shape (A∩B,A⊂B,B⊂A, touching at an endpoint).synchronizedon the scheduler’sschedulemethod. Coarse correctness for the interview; the trade-offs section escalates to per-calendar locks or optimistic-concurrency on a follow-up.CopyOnWriteArrayListfor listeners. Reads dominate writes; the publish path is allocation-free.
Trade-offs and extensions#
Decisions explicitly made and what they cost:
| Decision | Why | Cost if requirements change |
|---|---|---|
Coarse synchronized on MeetingScheduler.schedule | Correctness 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 start | O(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 Strategy | Smallest-fit today, equipment-first tomorrow, ML-ranked next year — all without editing the scheduler. | None — the right shape now. |
Notifier with CopyOnWriteArrayList | Subscribers 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 hierarchy | Avoids 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 state | No 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.isBusythat padsslotby 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
FreeSlotFinderthat 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 issynchronized; one wins, the other throwsConflictException. The losing call’s user-side adds are rolled back by the surrounding schedulersynchronized. - “How do you store a weekly recurring meeting?” — One
Meetingrow with aRecurrenceRule. Occurrences are computed lazily on read. Exceptions (one-off cancellations or moves) are stored as a small list ofExceptionDate → Overrideentries on the rule. - “How do you notify 200 attendees without blocking the booking call?” —
Notifier.publishhands 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), withRescheduledas a transient notification event rather than a stored state. Show the activity diagram.
Related#
- Parking Lot — the sibling case study; same shape (multi-aggregate, Strategy + state).
- Strategy Pattern —
RoomFinderhere is the textbook use. - Observer Pattern —
Notifieris the textbook observer hub. - State Pattern —
Meeting.statusis a clean state machine. - Approaching the OOD Interview — the meta-script that produced this writeup’s structure.