How to approach an LLD interview
A 45 minute playbook for low level design interviews, with one small design grown from questions to classes to thread-safe code in five languages.
- Read first
- Object modeling
A low level design round gives you one sentence (“design a parking lot”) and about 45 minutes. At the end, the interviewer has to answer three questions about you. Can this person turn a vague request into a model? Can they write the code for the parts that matter? And does the design survive a new requirement, without being rebuilt? Everything in the round serves one of those three.
Most candidates who fail know enough design. They lose on order and time. They draw twenty classes
before asking what the system must do. They spend fifteen minutes on a Vehicle hierarchy. And they
reach minute 40 with no working method, and no answer to “what if two people do this at once?”.
This page gives you the order that avoids that. It says what to do in each part of the 45 minutes, what to write down, and what the interviewer is checking at each point. One small problem, a meeting room booker, grows from the first question to thread-safe code. So you see every step done once.
- Color key
- Understand the problem
- Design and code
- Check the design
- Make it robust
The playbook
Section titled “The playbook”Every worked problem on this site follows the same seven steps, in this order, with the same suggested minutes. The bar below is that split. The anchors jump to each step in the worked example further down.
Only if asked or time allows: Testing
| Step | Minutes | What you produce | What the interviewer checks |
|---|---|---|---|
| Clarifying questions | 0 to 4 | Four to six answers that change the design | That you scope before you draw |
| Requirements | 4 to 7 | Numbered use cases, plus the one rule that must always hold | That you can say what “done” means |
| Building the design | 7 to 27 | A small diagram, then code for the core paths, grown in stages | Modeling, naming, clean interfaces, working code |
| Class diagram | 27 to 30 | A walk through the full diagram, one sentence per class | That every class has a reason to exist |
| Concurrency and edge cases | 30 to 38 | The race named, a correct fix, and why it is correct | The senior bar, in most rounds |
| Testing | if asked | Tests named after the promises they protect | That “it works” means something checkable |
| Extensions | 38 to 42 | Where each new feature lands, ideally in one place | That the design absorbs change |
The last three minutes are yours: questions for the interviewer. Two things about the split are easy to get wrong.
Building the design gets almost half the time, and most of it is code. An LLD round is not a UML exam. The diagram for each stage takes two minutes. The rest of the twenty goes to writing the methods that carry the requirements, with real names and real checks.
The class diagram comes first in each stage, not once at the start. You draw the slice you are about to code, and code it. Then you extend the drawing when the next requirement arrives. So the full diagram at minute 27 is a walk-through of something already on the board, not a first draft. Drawing the complete design up front means guessing at requirements you have not heard yet. Then you redraw it when the guesses are wrong.
Time slips in predictable places. Say you are behind at minute 20. Stub the secondary use cases: write the signature, and one line saying what it does. And protect the concurrency step. A design with three methods coded and a correct answer to the race beats one with eight methods and no time left.
Worked example: meeting rooms
Section titled “Worked example: meeting rooms”The prompt is “Design a meeting room booking system.” It is small enough to finish in one sitting. And it has the shape almost every LLD problem has. There are a few entities. There is one rule that must never break (two meetings in one room at once). There is a search. And there is a race when two people book at the same moment. The same steps in a larger problem are shown in the parking lot and the vending machine.
Clarifying questions
Section titled “Clarifying questions”Design a meeting room booking system.
Whether you scope the problem in a few minutes, with questions that each change a class, a method or a lock. The red flag is drawing in the first minute, which reads as a memorized answer. So is asking ten generic questions (“how many users?”) that change nothing.
A good clarifying question has a visible consequence: if the answer flips, some part of the design changes. Write each answer down next to the question so both of you can point at it later. These five shape this design the most:
| Question | We assume | If the answer were different |
|---|---|---|
| Who books, and what do they ask for? | An employee books a named room for a time range, with a head count | If the system must pick the room, findFreeRooms becomes the main path |
| Can bookings touch end to start? | Yes. 10:00 to 11:00 and 11:00 to 12:00 both fit | Closed ranges would reject back-to-back meetings, a classic off-by-one |
| Who can cancel? | Only the organizer | An admin role adds one check, not a class |
| Do many people book at once? | Yes, from a web app with many requests in flight | One user at a time removes the whole concurrency step |
| Recurring meetings, notifications, several offices? | Invites go out through a hook; recurring and offices are out of v1 | Each is covered under extensions |
Before I draw anything, I want four answers. What does a booking request contain? Are back-to-back meetings allowed? Who can cancel? And how many people book at the same time? The last one decides how much of the time goes to concurrency.
Questions about scale (“how many rooms?”) matter less here than in a system design round. Ask one only when the number changes a data structure. A few hundred rooms means a list per room is fine. A year of bookings per room is when a sorted structure starts to pay.
Requirements
Section titled “Requirements”Turn the answers into a numbered list of use cases. Then add a short list of rules that must always hold. Use cases are verbs with their inputs and outcomes, written so you could test each one. The rest of the round is checked against this list. It’s what you point at when the interviewer asks “are we done?”.
Must do
- Book a room for a time range, an organizer and a head count. Reject it with a reason if the room is too small or already booked for any part of that range.
- Find the free rooms for a time range and a head count, smallest first.
- Cancel a booking. Only its organizer may.
- Tell interested parties (the invite service) when a booking is made or cancelled.
Must be true
- No two bookings for one room ever overlap, even with many requests at once. This is the one that matters most.
- Ranges are half-open, so back-to-back meetings both fit.
- A rejected booking leaves no trace.
Out of scope for v1: recurring meetings, several offices, sending the email itself (another team owns that), and persistence. Saying these out loud is part of the answer. It shows you know they exist, and chose not to spend the time.
Notice that the “must be true” list already names the hard part. The round will be decided on the overlap rule under concurrency. Naming it at minute 6 tells the interviewer you know that.
Building the design
Section titled “Building the design”Let’s start simple. Someone books Focus room from 9:00 to 9:30. What happens?
Whether you can get a small, working model on the board in a few minutes. And whether you put each rule on the class that owns the data it is about. The red flag is twelve boxes in the first five minutes, which reads as a memorized answer.
Find the entities
Section titled “Find the entities”Read the requirements, and pull out the nouns and verbs. Nouns are candidate classes or attributes. Verbs are candidate methods. That rule of thumb is a starting point, not an answer. Some nouns are actors outside the system. Some are noise. And the most important class is often a noun nobody wrote down. Object modeling covers the reasoning in depth. Try it here: sort each highlighted word, then check.
The requirement, as written down
for a . Each room has a and a . A records its , the and the time slot. Two bookings for one room must never . Employees can for free rooms that fit a group, and the organizer can a booking. The has three floors, and wants an whenever a booking changes.
Selectednone
Class
Attribute
Method
Leave out
Pick any highlighted word, then choose where it goes. The next unsorted word is selected for you.
The model these answers add up to
- book(roomId, organizerId, slot, attendees)
- findFreeRooms(slot, attendees)
- cancel(bookingId, employeeId)
- id
- name
- capacity: int
- id
- roomId
- slot: TimeSlot
- organizerId
- attendees: int
- start
- end
- overlaps(other)
Left out of the model: Employees, office, reception, email.
Three things to take from the sort:
- The facade is not in the text. No requirement says “booking service”. It appears because something has to see every booking for a room to enforce the overlap rule. Classes that own a rule across many objects are usually found by asking “who can check this?”, not by reading nouns.
- Small value objects carry rules.
TimeSlotis two fields and one method, and it is the most important class here. It holds the half-open rule. And it validates itself once, so no other class ever sees an end before a start. - Actors and noise stay out. Employees, reception and the office exist. But the use cases don’t need them as classes. Leaving them out is a decision. Say it, so it doesn’t look like a miss.
Draw the slice before you code it
Section titled “Draw the slice before you code it”Before writing code, draw only what the first use cases need. Use the five UML symbols that interviews actually rely on. A box with fields and methods. An arrow for “uses”. A filled diamond for “owns”. A hollow triangle for “implements”. And multiplicity where it matters. Colors on this site mark the role each class plays. The key is under the diagram.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
Two minutes of drawing settles the names, and who owns what, before any code depends on them. It also gives the interviewer something to object to cheaply. Moving a method between two boxes costs a few seconds. Moving it after the code is written costs minutes you don’t have.
Code the core paths
Section titled “Code the core paths”Looks reasonable. Write
bookfor me.
Whether the code matches the diagram, checks every rule before changing anything, and reports failures in a way a caller can use. The red flag is pseudocode with “check if available” as one line: that line is the problem.
Code the methods that carry the use cases, in the order of the list: book first, then
findFreeRooms, then cancel. Use real names and real checks, and a tiny main that runs the story
from the requirements. Code what is interesting. Stub what is plumbing (id generation, a room
registry) with one line.
import java.time.LocalDateTime;import java.util.ArrayList;import java.util.Comparator;import java.util.HashMap;import java.util.LinkedHashMap;import java.util.List;import java.util.Map;
// A half-open time range [start, end). A value object: it never changes and two slots with the// same times are equal, so any class can hold one without copying it.record TimeSlot(LocalDateTime start, LocalDateTime end) { TimeSlot { // Checked once, where it is made, so every TimeSlot in the system is a legal one. if (!start.isBefore(end)) throw new IllegalArgumentException("start must be before end"); }
// The rule the whole design rests on lives on the data it is about. // Half-open: a 10:00-11:00 meeting and an 11:00-12:00 meeting do not clash. boolean overlaps(TimeSlot other) { return start.isBefore(other.end) && other.start.isBefore(end); }}
// An entity: known by its id. Capacity is fixed in v1, so there are no setters to guard.record Room(String id, String name, int capacity) {}
// The organizer is only an id. An Employee class would hold nothing yet, so it waits until a// requirement gives it behavior.record Booking(String id, String roomId, String organizerId, TimeSlot slot, int attendees) {}
// One exception type with a readable reason: callers show it to the user as is.class BookingException extends RuntimeException { BookingException(String reason) { super(reason); }}
// The facade: the one class callers use. It owns the rule "no two bookings for one room overlap",// because it is the only place that sees every booking for a room.class BookingService { private final Map<String, Room> rooms = new LinkedHashMap<>(); private final Map<String, List<Booking>> byRoom = new HashMap<>(); private final Map<String, Booking> byId = new HashMap<>(); private int nextId = 1;
void addRoom(Room room) { rooms.put(room.id(), room); byRoom.put(room.id(), new ArrayList<>()); }
// Use case 1. Every check happens before anything is stored, so a rejected booking // leaves no trace. Booking book(String roomId, String organizerId, TimeSlot slot, int attendees) { Room room = rooms.get(roomId); if (room == null) throw new BookingException("no room " + roomId); if (attendees > room.capacity()) throw new BookingException(room.name() + " seats " + room.capacity()); if (!isFree(roomId, slot)) throw new BookingException(room.name() + " is taken then"); Booking booking = new Booking("B" + nextId++, roomId, organizerId, slot, attendees); byRoom.get(roomId).add(booking); byId.put(booking.id(), booking); return booking; }
// Use case 2. Smallest room first, so a three person chat doesn't take the boardroom. List<Room> findFreeRooms(TimeSlot slot, int attendees) { return rooms.values().stream() .filter(r -> r.capacity() >= attendees && isFree(r.id(), slot)) .sorted(Comparator.comparingInt(Room::capacity)) .toList(); }
// Use case 3. Only the organizer may cancel: a rule from the clarifying questions. void cancel(String bookingId, String employeeId) { Booking booking = byId.get(bookingId); if (booking == null) throw new BookingException("no booking " + bookingId); if (!booking.organizerId().equals(employeeId)) throw new BookingException("only the organizer can cancel"); byId.remove(bookingId); byRoom.get(booking.roomId()).remove(booking); }
// A scan of one room's bookings. A room holds a few dozen a week, so a list is enough; // a sorted tree is the answer if the interviewer asks about a year of data. private boolean isFree(String roomId, TimeSlot slot) { return byRoom.get(roomId).stream().noneMatch(b -> b.slot().overlaps(slot)); }}
class Main { public static void main(String[] args) { BookingService service = new BookingService(); service.addRoom(new Room("R1", "Boardroom", 12)); service.addRoom(new Room("R2", "Focus", 4));
LocalDateTime nine = LocalDateTime.of(2026, 3, 2, 9, 0); TimeSlot standup = new TimeSlot(nine, nine.plusMinutes(30)); Booking b = service.book("R2", "ana", standup, 3); System.out.println("Booked " + b.id());
try { service.book("R2", "raj", new TimeSlot(nine.plusMinutes(15), nine.plusHours(1)), 2); } catch (BookingException e) { System.out.println("Rejected: " + e.getMessage()); // Focus is taken then }
// Back to back is fine, because the slots are half-open. service.book("R2", "raj", new TimeSlot(nine.plusMinutes(30), nine.plusHours(1)), 2); System.out.println(service.findFreeRooms(standup, 3)); // only the Boardroom is left service.cancel(b.id(), "ana"); System.out.println(service.findFreeRooms(standup, 3).get(0).name()); // Focus }}from dataclasses import dataclassfrom datetime import datetime, timedelta
# A half-open time range [start, end). A value object: frozen, and equal when the times# are equal, so any class can hold one without copying it.@dataclass(frozen=True)class TimeSlot: start: datetime end: datetime
def __post_init__(self): # Checked once, where it is made, so every TimeSlot in the system is a legal one. if self.start >= self.end: raise ValueError("start must be before end")
def overlaps(self, other: "TimeSlot") -> bool: # The rule the whole design rests on lives on the data it is about. # Half-open: a 10:00-11:00 meeting and an 11:00-12:00 meeting do not clash. return self.start < other.end and other.start < self.end
@dataclass(frozen=True)class Room: # An entity: known by its id. Capacity is fixed in v1, so it is frozen. id: str name: str capacity: int
@dataclass(frozen=True)class Booking: # The organizer is only an id. An Employee class would hold nothing yet, so it waits # until a requirement gives it behavior. id: str room_id: str organizer_id: str slot: TimeSlot attendees: int
# One exception type with a readable reason: callers show it to the user as is.class BookingError(Exception): ...
# The facade: the one class callers use. It owns the rule "no two bookings for one room# overlap", because it is the only place that sees every booking for a room.class BookingService: def __init__(self) -> None: self._rooms: dict[str, Room] = {} self._by_room: dict[str, list[Booking]] = {} self._by_id: dict[str, Booking] = {} self._next_id = 1
def add_room(self, room: Room) -> None: self._rooms[room.id] = room self._by_room[room.id] = []
def book(self, room_id: str, organizer_id: str, slot: TimeSlot, attendees: int) -> Booking: # Use case 1. Every check happens before anything is stored, so a rejected booking # leaves no trace. room = self._rooms.get(room_id) if room is None: raise BookingError(f"no room {room_id}") if attendees > room.capacity: raise BookingError(f"{room.name} seats {room.capacity}") if not self._is_free(room_id, slot): raise BookingError(f"{room.name} is taken then") booking = Booking(f"B{self._next_id}", room_id, organizer_id, slot, attendees) self._next_id += 1 self._by_room[room_id].append(booking) self._by_id[booking.id] = booking return booking
def find_free_rooms(self, slot: TimeSlot, attendees: int) -> list[Room]: # Use case 2. Smallest room first, so a three person chat doesn't take the boardroom. fits = [r for r in self._rooms.values() if r.capacity >= attendees and self._is_free(r.id, slot)] return sorted(fits, key=lambda r: r.capacity)
def cancel(self, booking_id: str, employee_id: str) -> None: # Use case 3. Only the organizer may cancel: a rule from the clarifying questions. booking = self._by_id.get(booking_id) if booking is None: raise BookingError(f"no booking {booking_id}") if booking.organizer_id != employee_id: raise BookingError("only the organizer can cancel") del self._by_id[booking_id] self._by_room[booking.room_id].remove(booking)
def _is_free(self, room_id: str, slot: TimeSlot) -> bool: # A scan of one room's bookings. A room holds a few dozen a week, so a list is enough; # a sorted tree is the answer if the interviewer asks about a year of data. return not any(b.slot.overlaps(slot) for b in self._by_room[room_id])
if __name__ == "__main__": service = BookingService() service.add_room(Room("R1", "Boardroom", 12)) service.add_room(Room("R2", "Focus", 4))
nine = datetime(2026, 3, 2, 9, 0) standup = TimeSlot(nine, nine + timedelta(minutes=30)) b = service.book("R2", "ana", standup, 3) print("Booked", b.id)
try: service.book("R2", "raj", TimeSlot(nine + timedelta(minutes=15), nine + timedelta(hours=1)), 2) except BookingError as e: print("Rejected:", e) # Focus is taken then
# Back to back is fine, because the slots are half-open. service.book("R2", "raj", TimeSlot(nine + timedelta(minutes=30), nine + timedelta(hours=1)), 2) print([r.name for r in service.find_free_rooms(standup, 3)]) # only the Boardroom is left service.cancel(b.id, "ana") print(service.find_free_rooms(standup, 3)[0].name) # Focususing System;using System.Collections.Generic;using System.Linq;
// A half-open time range [Start, End). A value object: a record never changes and two slots// with the same times are equal, so any class can hold one without copying it.record TimeSlot{ public DateTime Start { get; } public DateTime End { get; }
public TimeSlot(DateTime start, DateTime end) { // Checked once, where it is made, so every TimeSlot in the system is a legal one. if (start >= end) throw new ArgumentException("start must be before end"); (Start, End) = (start, end); }
// The rule the whole design rests on lives on the data it is about. // Half-open: a 10:00-11:00 meeting and an 11:00-12:00 meeting do not clash. public bool Overlaps(TimeSlot other) => Start < other.End && other.Start < End;}
// An entity: known by its id. Capacity is fixed in v1, so there are no setters to guard.record Room(string Id, string Name, int Capacity);
// The organizer is only an id. An Employee class would hold nothing yet, so it waits until a// requirement gives it behavior.record Booking(string Id, string RoomId, string OrganizerId, TimeSlot Slot, int Attendees);
// One exception type with a readable reason: callers show it to the user as is.class BookingException : Exception{ public BookingException(string reason) : base(reason) { }}
// The facade: the one class callers use. It owns the rule "no two bookings for one room overlap",// because it is the only place that sees every booking for a room.class BookingService{ private readonly List<Room> _rooms = new(); // insertion order, so results are stable private readonly Dictionary<string, List<Booking>> _byRoom = new(); private readonly Dictionary<string, Booking> _byId = new(); private int _nextId = 1;
public void AddRoom(Room room) { _rooms.Add(room); _byRoom[room.Id] = new List<Booking>(); }
// Use case 1. Every check happens before anything is stored, so a rejected booking // leaves no trace. public Booking Book(string roomId, string organizerId, TimeSlot slot, int attendees) { var room = _rooms.FirstOrDefault(r => r.Id == roomId) ?? throw new BookingException($"no room {roomId}"); if (attendees > room.Capacity) throw new BookingException($"{room.Name} seats {room.Capacity}"); if (!IsFree(roomId, slot)) throw new BookingException($"{room.Name} is taken then"); var booking = new Booking($"B{_nextId++}", roomId, organizerId, slot, attendees); _byRoom[roomId].Add(booking); _byId[booking.Id] = booking; return booking; }
// Use case 2. Smallest room first, so a three person chat doesn't take the boardroom. public List<Room> FindFreeRooms(TimeSlot slot, int attendees) => _rooms.Where(r => r.Capacity >= attendees && IsFree(r.Id, slot)) .OrderBy(r => r.Capacity) .ToList();
// Use case 3. Only the organizer may cancel: a rule from the clarifying questions. public void Cancel(string bookingId, string employeeId) { if (!_byId.TryGetValue(bookingId, out var booking)) throw new BookingException($"no booking {bookingId}"); if (booking.OrganizerId != employeeId) throw new BookingException("only the organizer can cancel"); _byId.Remove(bookingId); _byRoom[booking.RoomId].Remove(booking); }
// A scan of one room's bookings. A room holds a few dozen a week, so a list is enough; // a sorted tree is the answer if the interviewer asks about a year of data. private bool IsFree(string roomId, TimeSlot slot) => !_byRoom[roomId].Any(b => b.Slot.Overlaps(slot));}
static class Program{ static void Main() { var service = new BookingService(); service.AddRoom(new Room("R1", "Boardroom", 12)); service.AddRoom(new Room("R2", "Focus", 4));
var nine = new DateTime(2026, 3, 2, 9, 0, 0); var standup = new TimeSlot(nine, nine.AddMinutes(30)); var b = service.Book("R2", "ana", standup, 3); Console.WriteLine($"Booked {b.Id}");
try { service.Book("R2", "raj", new TimeSlot(nine.AddMinutes(15), nine.AddHours(1)), 2); } catch (BookingException e) { Console.WriteLine($"Rejected: {e.Message}"); // Focus is taken then }
// Back to back is fine, because the slots are half-open. service.Book("R2", "raj", new TimeSlot(nine.AddMinutes(30), nine.AddHours(1)), 2); Console.WriteLine(string.Join(", ", service.FindFreeRooms(standup, 3).Select(r => r.Name))); // Boardroom service.Cancel(b.Id, "ana"); Console.WriteLine(service.FindFreeRooms(standup, 3)[0].Name); // Focus }}package main
import ( "errors" "fmt" "sort" "time")
// TimeSlot is a half-open time range [Start, End). A value: passed by copy and never changed,// so any type can hold one safely.type TimeSlot struct { Start, End time.Time}
// NewTimeSlot checks once, where a slot is made, so every TimeSlot in the system is a legal one.func NewTimeSlot(start, end time.Time) (TimeSlot, error) { if !start.Before(end) { return TimeSlot{}, errors.New("start must be before end") } return TimeSlot{start, end}, nil}
// Overlaps is the rule the whole design rests on, kept on the data it is about.// Half-open: a 10:00-11:00 meeting and an 11:00-12:00 meeting do not clash.func (s TimeSlot) Overlaps(o TimeSlot) bool { return s.Start.Before(o.End) && o.Start.Before(s.End)}
// Room is an entity: known by its ID. Capacity is fixed in v1.type Room struct { ID, Name string Capacity int}
// Booking holds the organizer as an ID only. An Employee type would hold nothing yet, so it// waits until a requirement gives it behavior.type Booking struct { ID, RoomID, OrganizerID string Slot TimeSlot Attendees int}
// BookingService is the facade: the one type callers use. It owns the rule "no two bookings// for one room overlap", because it is the only place that sees every booking for a room.type BookingService struct { rooms []Room // kept in insertion order so results are stable byRoom map[string][]Booking byID map[string]Booking nextID int}
func NewBookingService() *BookingService { return &BookingService{byRoom: map[string][]Booking{}, byID: map[string]Booking{}, nextID: 1}}
func (s *BookingService) AddRoom(r Room) { s.rooms = append(s.rooms, r) s.byRoom[r.ID] = nil}
// Book is use case 1. Every check happens before anything is stored, so a rejected booking// leaves no trace. Errors carry a readable reason that callers show as is.func (s *BookingService) Book(roomID, organizerID string, slot TimeSlot, attendees int) (Booking, error) { room, ok := s.room(roomID) if !ok { return Booking{}, fmt.Errorf("no room %s", roomID) } if attendees > room.Capacity { return Booking{}, fmt.Errorf("%s seats %d", room.Name, room.Capacity) } if !s.isFree(roomID, slot) { return Booking{}, fmt.Errorf("%s is taken then", room.Name) } b := Booking{fmt.Sprintf("B%d", s.nextID), roomID, organizerID, slot, attendees} s.nextID++ s.byRoom[roomID] = append(s.byRoom[roomID], b) s.byID[b.ID] = b return b, nil}
// FindFreeRooms is use case 2. Smallest room first, so a three person chat doesn't take the// boardroom.func (s *BookingService) FindFreeRooms(slot TimeSlot, attendees int) []Room { var fits []Room for _, r := range s.rooms { if r.Capacity >= attendees && s.isFree(r.ID, slot) { fits = append(fits, r) } } sort.SliceStable(fits, func(i, j int) bool { return fits[i].Capacity < fits[j].Capacity }) return fits}
// Cancel is use case 3. Only the organizer may cancel: a rule from the clarifying questions.func (s *BookingService) Cancel(bookingID, employeeID string) error { b, ok := s.byID[bookingID] if !ok { return fmt.Errorf("no booking %s", bookingID) } if b.OrganizerID != employeeID { return errors.New("only the organizer can cancel") } delete(s.byID, bookingID) list := s.byRoom[b.RoomID] for i := range list { if list[i].ID == bookingID { s.byRoom[b.RoomID] = append(list[:i], list[i+1:]...) break } } return nil}
func (s *BookingService) room(id string) (Room, bool) { for _, r := range s.rooms { if r.ID == id { return r, true } } return Room{}, false}
// isFree scans one room's bookings. A room holds a few dozen a week, so a slice is enough;// a sorted tree is the answer if the interviewer asks about a year of data.func (s *BookingService) isFree(roomID string, slot TimeSlot) bool { for _, b := range s.byRoom[roomID] { if b.Slot.Overlaps(slot) { return false } } return true}
func main() { s := NewBookingService() s.AddRoom(Room{"R1", "Boardroom", 12}) s.AddRoom(Room{"R2", "Focus", 4})
nine := time.Date(2026, 3, 2, 9, 0, 0, 0, time.UTC) standup, _ := NewTimeSlot(nine, nine.Add(30*time.Minute)) b, _ := s.Book("R2", "ana", standup, 3) fmt.Println("Booked", b.ID)
clash, _ := NewTimeSlot(nine.Add(15*time.Minute), nine.Add(time.Hour)) if _, err := s.Book("R2", "raj", clash, 2); err != nil { fmt.Println("Rejected:", err) // Focus is taken then }
// Back to back is fine, because the slots are half-open. after, _ := NewTimeSlot(nine.Add(30*time.Minute), nine.Add(time.Hour)) s.Book("R2", "raj", after, 2) fmt.Println(s.FindFreeRooms(standup, 3)) // only the Boardroom is left s.Cancel(b.ID, "ana") fmt.Println(s.FindFreeRooms(standup, 3)[0].Name) // Focus}// A half-open time range [start, end). A value object: readonly fields, so any class can hold// one without copying it.class TimeSlot { constructor( readonly start: Date, readonly end: Date, ) { // Checked once, where it is made, so every TimeSlot in the system is a legal one. if (start.getTime() >= end.getTime()) throw new Error("start must be before end"); }
// The rule the whole design rests on lives on the data it is about. // Half-open: a 10:00-11:00 meeting and an 11:00-12:00 meeting do not clash. overlaps(other: TimeSlot): boolean { return this.start < other.end && other.start < this.end; }}
// An entity: known by its id. Capacity is fixed in v1, so every field is readonly.interface Room { readonly id: string; readonly name: string; readonly capacity: number;}
// The organizer is only an id. An Employee class would hold nothing yet, so it waits until a// requirement gives it behavior.interface Booking { readonly id: string; readonly roomId: string; readonly organizerId: string; readonly slot: TimeSlot; readonly attendees: number;}
// One error type with a readable reason: callers show it to the user as is.class BookingError extends Error {}
// The facade: the one class callers use. It owns the rule "no two bookings for one room overlap",// because it is the only place that sees every booking for a room.class BookingService { private readonly rooms = new Map<string, Room>(); private readonly byRoom = new Map<string, Booking[]>(); private readonly byId = new Map<string, Booking>(); private nextId = 1;
addRoom(room: Room): void { this.rooms.set(room.id, room); this.byRoom.set(room.id, []); }
// Use case 1. Every check happens before anything is stored, so a rejected booking // leaves no trace. book(roomId: string, organizerId: string, slot: TimeSlot, attendees: number): Booking { const room = this.rooms.get(roomId); if (!room) throw new BookingError(`no room ${roomId}`); if (attendees > room.capacity) throw new BookingError(`${room.name} seats ${room.capacity}`); if (!this.isFree(roomId, slot)) throw new BookingError(`${room.name} is taken then`); const booking: Booking = { id: `B${this.nextId++}`, roomId, organizerId, slot, attendees }; this.byRoom.get(roomId)!.push(booking); this.byId.set(booking.id, booking); return booking; }
// Use case 2. Smallest room first, so a three person chat doesn't take the boardroom. findFreeRooms(slot: TimeSlot, attendees: number): Room[] { return [...this.rooms.values()] .filter((r) => r.capacity >= attendees && this.isFree(r.id, slot)) .sort((a, b) => a.capacity - b.capacity); }
// Use case 3. Only the organizer may cancel: a rule from the clarifying questions. cancel(bookingId: string, employeeId: string): void { const booking = this.byId.get(bookingId); if (!booking) throw new BookingError(`no booking ${bookingId}`); if (booking.organizerId !== employeeId) throw new BookingError("only the organizer can cancel"); this.byId.delete(bookingId); const list = this.byRoom.get(booking.roomId)!; list.splice(list.indexOf(booking), 1); }
// A scan of one room's bookings. A room holds a few dozen a week, so an array is enough; // a sorted tree is the answer if the interviewer asks about a year of data. private isFree(roomId: string, slot: TimeSlot): boolean { return !this.byRoom.get(roomId)!.some((b) => b.slot.overlaps(slot)); }}
const service = new BookingService();service.addRoom({ id: "R1", name: "Boardroom", capacity: 12 });service.addRoom({ id: "R2", name: "Focus", capacity: 4 });
const at = (h: number, m: number) => new Date(2026, 2, 2, h, m);const standup = new TimeSlot(at(9, 0), at(9, 30));const b = service.book("R2", "ana", standup, 3);console.log("Booked", b.id);
try { service.book("R2", "raj", new TimeSlot(at(9, 15), at(10, 0)), 2);} catch (e) { console.log("Rejected:", (e as Error).message); // Focus is taken then}
// Back to back is fine, because the slots are half-open.service.book("R2", "raj", new TimeSlot(at(9, 30), at(10, 0)), 2);console.log(service.findFreeRooms(standup, 3).map((r) => r.name)); // [ 'Boardroom' ]service.cancel(b.id, "ana");console.log(service.findFreeRooms(standup, 3)[0].name); // FocusWhat makes this code read well in an interview, beyond being correct:
- Every rule has one home. The overlap rule lives on
TimeSlot, capacity is checked where the room is known, organizer-only lives incancel. When the interviewer asks “where is the rule that…?”, you point at one line. - Check everything, then change anything.
bookvalidates the room, the size and the overlap before it stores the booking. A rejected request leaves no half-written state, which is also what makes the concurrency fix in a later step a small change. - Failures are part of the interface. “Room taken” is a normal outcome, with a reason the caller
can show. It’s not a crash, and not a silent
false. Go returns an error. The other four languages throw one domain exception type. - Inputs are small and typed.
booktakes ids and aTimeSlot, not aRoomobject the caller might have built by hand. The service decides which room object is real. - The deliberate shortcut is named.
isFreescans one room’s list, and its comment says what replaces it for a year of data.
I’m scanning each room’s bookings in a list because a room has a few dozen a week. If you want a year of history per room, I’d switch to a tree keyed by start time and only check the neighbors. The interface wouldn’t change.
Stage 2: invites, and where patterns fit
Section titled “Stage 2: invites, and where patterns fit”The calendar team wants to send invites when a meeting is booked or cancelled. Where does that go?
Whether you keep the booking logic ignorant of email. And whether you reach for a pattern
because a rule changes, not because the problem has a famous pattern. The red flag is calling an
email client from inside book. So is adding four patterns at once.
Invites are a reaction to a booking, and another team owns them. And the list of things that care about bookings will grow: reception’s digest, a room display, analytics. That is exactly what the Observer pattern is for. The service announces “a booking was made” to a list of listeners, and knows nothing about what they do.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
The code for this stage is shown with the concurrency step below. That’s because where the listener call sits, relative to the lock, turns out to matter.
A pattern earns its place when it protects something: a rule that will change, a variant that will be added, a team that must not edit your class. Observer passes that test here. The other usual candidates don’t, yet.
Go deeperSix tempting patterns, and which fit this problem1 min
| Pattern | Tempting because | Fits here? |
|---|---|---|
| Observer | Other teams react to bookings | Yes. A real, growing list of consumers that must not be coded into book |
| Strategy | “Smallest room first” is a rule | Not yet. One rule is a private method. It becomes a strategy the moment a second rule appears, such as “prefer the requester’s floor” |
| State | Bookings are made and cancelled | No. Two states and no behavior that differs by state. A cancelled booking is simply removed |
| Factory | Bookings get created | No. One constructor call with no choice to make |
| Singleton | “There is one booking service” | No. Create one and pass it in. A singleton hides the dependency and makes tests share state |
| Builder | book has four parameters |
No. Four required values with no optional ones is a plain constructor |
Compare the design above with what pattern-stuffing produces for the same requirements:
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
Eight classes, and none of them is TimeSlot, the one that holds the rule the problem is about.
The time went into structure that protects nothing, and the overlap check will end up as an
if buried in whichever class is closest.
Class diagram
Section titled “Class diagram”Walk me through the whole design.
Whether every class has a one-sentence job and a requirement behind it, and whether you can name the decision you’d defend most. The red flag is reading out field lists, or a class you can’t justify.
The stage 2 diagram is the full design. The walk-through takes about three minutes. It follows the
requirements, not the boxes. Say one sentence per class, about which use case or rule it serves.
Then name the one decision you’d defend most. For this design, that’s putting the overlap rule on
TimeSlot, and enforcing it in the only class that sees every booking for a room.
BookingService is the one entry point. It owns the rule that bookings for a room never overlap, because it’s the only class that sees them all. TimeSlot holds the half-open overlap check. Room and Booking are plain entities. Listeners hear about bookings, without the service knowing who they are. What’s left is making book safe when two requests arrive together.
Concurrency and edge cases
Section titled “Concurrency and edge cases”Ana and Raj both click “book” on Focus room, 9:00 to 9:30, at the same moment. What happens?
Whether you see the check-then-act gap in book. Whether you fix it with something obviously
correct first. And whether you then say what the fix costs, and when you’d change it. The red
flag is “I’d use a ConcurrentHashMap”. Every single map call is safe, and the booking is still
doubled.
book checks that the room is free, then stores the booking. Between those two steps, another
thread can run the same check, see the same free room, and store its own booking. Both callers get a
confirmation for one room. The fix is to make the check and the insert one step. No other booking
for that room can slip in between.
Thread safety basics covers the tools. This is how they
apply here.
- Color key
- Facade
| Option | How | Who waits | Use when |
|---|---|---|---|
| One lock on the service | Every method holds one lock | Everyone, for every room | A first answer, and fine for one office |
| A lock per room (our choice) | book and cancel lock only their room |
Only people booking the same room | Many rooms and many users; still easy to explain |
| The database decides | A unique or exclusion constraint on room and time range | Only true conflicts, inside the database | Several app servers; the in-memory lock can’t see the others |
Start by naming the simplest correct fix: one lock. Then move to a lock per room, with a reason. Booking room A should never wait for someone booking room B. The code below adds that lock, and the stage 2 listener.
import java.time.LocalDateTime;import java.util.ArrayList;import java.util.Comparator;import java.util.HashMap;import java.util.LinkedHashMap;import java.util.List;import java.util.Map;import java.util.concurrent.ConcurrentHashMap;import java.util.concurrent.CopyOnWriteArrayList;import java.util.concurrent.CountDownLatch;import java.util.concurrent.atomic.AtomicInteger;import java.util.concurrent.locks.ReentrantLock;import java.util.function.Consumer;
// Unchanged from the core version.record TimeSlot(LocalDateTime start, LocalDateTime end) { TimeSlot { if (!start.isBefore(end)) throw new IllegalArgumentException("start must be before end"); }
boolean overlaps(TimeSlot other) { return start.isBefore(other.end) && other.start.isBefore(end); }}
record Room(String id, String name, int capacity) {}
record Booking(String id, String roomId, String organizerId, TimeSlot slot, int attendees) {}
class BookingException extends RuntimeException { BookingException(String reason) { super(reason); }}
// New: whoever wants to hear about bookings. The service knows nothing about email or// calendars; an invite sender is one more listener, added without editing the service.interface BookingListener { void onBooked(Booking booking); void onCancelled(Booking booking);}
class BookingService { // Rooms are added at startup, before the service is shared, so these two maps are only // read afterwards. Each room's list is guarded by that room's lock. private final Map<String, Room> rooms = new LinkedHashMap<>(); private final Map<String, List<Booking>> byRoom = new HashMap<>(); // One lock per room: people booking different rooms never wait for each other. private final Map<String, ReentrantLock> locks = new HashMap<>(); private final Map<String, Booking> byId = new ConcurrentHashMap<>(); private final AtomicInteger nextId = new AtomicInteger(1); private final List<BookingListener> listeners = new CopyOnWriteArrayList<>();
void addRoom(Room room) { rooms.put(room.id(), room); byRoom.put(room.id(), new ArrayList<>()); locks.put(room.id(), new ReentrantLock()); }
void addListener(BookingListener listener) { listeners.add(listener); }
Booking book(String roomId, String organizerId, TimeSlot slot, int attendees) { Room room = rooms.get(roomId); if (room == null) throw new BookingException("no room " + roomId); // Capacity never changes, so this check needs no lock. if (attendees > room.capacity()) throw new BookingException(room.name() + " seats " + room.capacity());
Booking booking; ReentrantLock lock = locks.get(roomId); lock.lock(); try { // The whole fix: "is it free?" and "take it" happen under one lock, so no other // thread can book the same room in between. if (!isFree(roomId, slot)) throw new BookingException(room.name() + " is taken then"); booking = new Booking("B" + nextId.getAndIncrement(), roomId, organizerId, slot, attendees); byRoom.get(roomId).add(booking); byId.put(booking.id(), booking); } finally { lock.unlock(); } // Listeners run after the lock is released: a slow mail server must not hold up the room. tell(l -> l.onBooked(booking)); return booking; }
// The answer can be stale the moment it returns. That is fine: book() checks again under // the lock, so a stale search can only lead to a polite rejection, never a double booking. List<Room> findFreeRooms(TimeSlot slot, int attendees) { List<Room> fits = new ArrayList<>(); for (Room r : rooms.values()) { if (r.capacity() < attendees) continue; ReentrantLock lock = locks.get(r.id()); lock.lock(); try { if (isFree(r.id(), slot)) fits.add(r); } finally { lock.unlock(); } } fits.sort(Comparator.comparingInt(Room::capacity)); return fits; }
void cancel(String bookingId, String employeeId) { Booking booking = byId.get(bookingId); if (booking == null) throw new BookingException("no booking " + bookingId); if (!booking.organizerId().equals(employeeId)) throw new BookingException("only the organizer can cancel"); ReentrantLock lock = locks.get(booking.roomId()); lock.lock(); try { // Two cancels of the same booking race here; only the first one removes it. if (byId.remove(bookingId) == null) return; byRoom.get(booking.roomId()).remove(booking); } finally { lock.unlock(); } tell(l -> l.onCancelled(booking)); }
// Caller must hold the room's lock. private boolean isFree(String roomId, TimeSlot slot) { return byRoom.get(roomId).stream().noneMatch(b -> b.slot().overlaps(slot)); }
// One broken listener must not fail a booking that already happened. private void tell(Consumer<BookingListener> event) { for (BookingListener l : listeners) { try { event.accept(l); } catch (RuntimeException e) { System.err.println("listener failed: " + e.getMessage()); } } }}
class Main { public static void main(String[] args) throws InterruptedException { BookingService service = new BookingService(); service.addRoom(new Room("R2", "Focus", 4)); service.addListener(new BookingListener() { public void onBooked(Booking b) { System.out.println("Invite sent for " + b.id()); } public void onCancelled(Booking b) { System.out.println("Cancellation sent for " + b.id()); } });
// Eight people press "book" for the same slot at the same moment. LocalDateTime nine = LocalDateTime.of(2026, 3, 2, 9, 0); TimeSlot slot = new TimeSlot(nine, nine.plusMinutes(30)); CountDownLatch start = new CountDownLatch(1); AtomicInteger won = new AtomicInteger(); List<Thread> threads = new ArrayList<>(); for (int i = 0; i < 8; i++) { String who = "user" + i; Thread t = new Thread(() -> { try { start.await(); service.book("R2", who, slot, 2); won.incrementAndGet(); } catch (BookingException | InterruptedException ignored) { // Losing the race is a normal outcome. } }); t.start(); threads.add(t); } start.countDown(); for (Thread t : threads) t.join(); System.out.println(won.get() + " of 8 got the room"); // always 1 }}import threadingfrom dataclasses import dataclassfrom datetime import datetime, timedeltafrom typing import Callable, Protocol
# Unchanged from the core version.@dataclass(frozen=True)class TimeSlot: start: datetime end: datetime
def __post_init__(self): if self.start >= self.end: raise ValueError("start must be before end")
def overlaps(self, other: "TimeSlot") -> bool: return self.start < other.end and other.start < self.end
@dataclass(frozen=True)class Room: id: str name: str capacity: int
@dataclass(frozen=True)class Booking: id: str room_id: str organizer_id: str slot: TimeSlot attendees: int
class BookingError(Exception): pass
# New: whoever wants to hear about bookings. The service knows nothing about email or# calendars; an invite sender is one more listener, added without editing the service.class BookingListener(Protocol): def on_booked(self, booking: Booking) -> None: ... def on_cancelled(self, booking: Booking) -> None: ...
class BookingService: def __init__(self) -> None: # Rooms are added at startup, before the service is shared, so these dicts are only # read afterwards. Each room's list is guarded by that room's lock. self._rooms: dict[str, Room] = {} self._by_room: dict[str, list[Booking]] = {} # One lock per room: people booking different rooms never wait for each other. self._locks: dict[str, threading.Lock] = {} # Booking ids span rooms, so the id index gets its own small lock. # Lock order is always room lock, then index lock, so the two can't deadlock. self._index_lock = threading.Lock() self._by_id: dict[str, Booking] = {} self._next_id = 1 self._listeners: list[BookingListener] = []
def add_room(self, room: Room) -> None: self._rooms[room.id] = room self._by_room[room.id] = [] self._locks[room.id] = threading.Lock()
def add_listener(self, listener: BookingListener) -> None: self._listeners.append(listener)
def book(self, room_id: str, organizer_id: str, slot: TimeSlot, attendees: int) -> Booking: room = self._rooms.get(room_id) if room is None: raise BookingError(f"no room {room_id}") # Capacity never changes, so this check needs no lock. if attendees > room.capacity: raise BookingError(f"{room.name} seats {room.capacity}")
with self._locks[room_id]: # The whole fix: "is it free?" and "take it" happen under one lock, so no other # thread can book the same room in between. if not self._is_free(room_id, slot): raise BookingError(f"{room.name} is taken then") with self._index_lock: booking = Booking(f"B{self._next_id}", room_id, organizer_id, slot, attendees) self._next_id += 1 self._by_id[booking.id] = booking self._by_room[room_id].append(booking) # Listeners run after the lock is released: a slow mail server must not hold up the room. self._tell(lambda l: l.on_booked(booking)) return booking
def find_free_rooms(self, slot: TimeSlot, attendees: int) -> list[Room]: # The answer can be stale the moment it returns. That is fine: book() checks again under # the lock, so a stale search can only lead to a polite rejection, never a double booking. fits = [] for r in self._rooms.values(): if r.capacity < attendees: continue with self._locks[r.id]: if self._is_free(r.id, slot): fits.append(r) return sorted(fits, key=lambda r: r.capacity)
def cancel(self, booking_id: str, employee_id: str) -> None: with self._index_lock: booking = self._by_id.get(booking_id) if booking is None: raise BookingError(f"no booking {booking_id}") if booking.organizer_id != employee_id: raise BookingError("only the organizer can cancel") with self._locks[booking.room_id]: # Two cancels of the same booking race here; only the first one removes it. with self._index_lock: if self._by_id.pop(booking_id, None) is None: return self._by_room[booking.room_id].remove(booking) self._tell(lambda l: l.on_cancelled(booking))
def _is_free(self, room_id: str, slot: TimeSlot) -> bool: # Caller must hold the room's lock. return not any(b.slot.overlaps(slot) for b in self._by_room[room_id])
def _tell(self, event: Callable[[BookingListener], None]) -> None: # One broken listener must not fail a booking that already happened. for listener in list(self._listeners): try: event(listener) except Exception as e: print("listener failed:", e)
class InviteSender: def on_booked(self, booking: Booking) -> None: print("Invite sent for", booking.id)
def on_cancelled(self, booking: Booking) -> None: print("Cancellation sent for", booking.id)
if __name__ == "__main__": service = BookingService() service.add_room(Room("R2", "Focus", 4)) service.add_listener(InviteSender())
# Eight people press "book" for the same slot at the same moment. nine = datetime(2026, 3, 2, 9, 0) slot = TimeSlot(nine, nine + timedelta(minutes=30)) start = threading.Barrier(8) won = []
def attempt(who: str) -> None: start.wait() try: won.append(service.book("R2", who, slot, 2)) except BookingError: pass # losing the race is a normal outcome
threads = [threading.Thread(target=attempt, args=(f"user{i}",)) for i in range(8)] for t in threads: t.start() for t in threads: t.join() print(len(won), "of 8 got the room") # always 1using System;using System.Collections.Concurrent;using System.Collections.Generic;using System.Linq;using System.Threading;
// Unchanged from the core version.record TimeSlot{ public DateTime Start { get; } public DateTime End { get; }
public TimeSlot(DateTime start, DateTime end) { if (start >= end) throw new ArgumentException("start must be before end"); (Start, End) = (start, end); }
public bool Overlaps(TimeSlot other) => Start < other.End && other.Start < End;}
record Room(string Id, string Name, int Capacity);
record Booking(string Id, string RoomId, string OrganizerId, TimeSlot Slot, int Attendees);
class BookingException : Exception{ public BookingException(string reason) : base(reason) { }}
// New: whoever wants to hear about bookings. The service knows nothing about email or// calendars; an invite sender is one more listener, added without editing the service.interface IBookingListener{ void OnBooked(Booking booking); void OnCancelled(Booking booking);}
class BookingService{ // Rooms are added at startup, before the service is shared, so these are only read // afterwards. Each room's list is guarded by that room's lock object. private readonly List<Room> _rooms = new(); private readonly Dictionary<string, List<Booking>> _byRoom = new(); // One lock per room: people booking different rooms never wait for each other. private readonly Dictionary<string, object> _locks = new(); private readonly ConcurrentDictionary<string, Booking> _byId = new(); private int _nextId = 0; private readonly List<IBookingListener> _listeners = new();
public void AddRoom(Room room) { _rooms.Add(room); _byRoom[room.Id] = new List<Booking>(); _locks[room.Id] = new object(); }
public void AddListener(IBookingListener listener) => _listeners.Add(listener);
public Booking Book(string roomId, string organizerId, TimeSlot slot, int attendees) { var room = _rooms.FirstOrDefault(r => r.Id == roomId) ?? throw new BookingException($"no room {roomId}"); // Capacity never changes, so this check needs no lock. if (attendees > room.Capacity) throw new BookingException($"{room.Name} seats {room.Capacity}");
Booking booking; lock (_locks[roomId]) { // The whole fix: "is it free?" and "take it" happen under one lock, so no other // thread can book the same room in between. if (!IsFree(roomId, slot)) throw new BookingException($"{room.Name} is taken then"); booking = new Booking($"B{Interlocked.Increment(ref _nextId)}", roomId, organizerId, slot, attendees); _byRoom[roomId].Add(booking); _byId[booking.Id] = booking; } // Listeners run after the lock is released: a slow mail server must not hold up the room. Tell(l => l.OnBooked(booking)); return booking; }
// The answer can be stale the moment it returns. That is fine: Book checks again under // the lock, so a stale search can only lead to a polite rejection, never a double booking. public List<Room> FindFreeRooms(TimeSlot slot, int attendees) { var fits = new List<Room>(); foreach (var r in _rooms.Where(r => r.Capacity >= attendees)) { lock (_locks[r.Id]) { if (IsFree(r.Id, slot)) fits.Add(r); } } return fits.OrderBy(r => r.Capacity).ToList(); }
public void Cancel(string bookingId, string employeeId) { if (!_byId.TryGetValue(bookingId, out var booking)) throw new BookingException($"no booking {bookingId}"); if (booking.OrganizerId != employeeId) throw new BookingException("only the organizer can cancel"); lock (_locks[booking.RoomId]) { // Two cancels of the same booking race here; only the first one removes it. if (!_byId.TryRemove(bookingId, out _)) return; _byRoom[booking.RoomId].Remove(booking); } Tell(l => l.OnCancelled(booking)); }
// Caller must hold the room's lock. private bool IsFree(string roomId, TimeSlot slot) => !_byRoom[roomId].Any(b => b.Slot.Overlaps(slot));
// One broken listener must not fail a booking that already happened. private void Tell(Action<IBookingListener> evt) { foreach (var l in _listeners) { try { evt(l); } catch (Exception e) { Console.Error.WriteLine($"listener failed: {e.Message}"); } } }}
class InviteSender : IBookingListener{ public void OnBooked(Booking b) => Console.WriteLine($"Invite sent for {b.Id}"); public void OnCancelled(Booking b) => Console.WriteLine($"Cancellation sent for {b.Id}");}
static class Program{ static void Main() { var service = new BookingService(); service.AddRoom(new Room("R2", "Focus", 4)); service.AddListener(new InviteSender());
// Eight people press "book" for the same slot at the same moment. var nine = new DateTime(2026, 3, 2, 9, 0, 0); var slot = new TimeSlot(nine, nine.AddMinutes(30)); var start = new Barrier(8); int won = 0; var threads = Enumerable.Range(0, 8).Select(i => new Thread(() => { start.SignalAndWait(); try { service.Book("R2", $"user{i}", slot, 2); Interlocked.Increment(ref won); } catch (BookingException) { // Losing the race is a normal outcome. } })).ToList(); threads.ForEach(t => t.Start()); threads.ForEach(t => t.Join()); Console.WriteLine($"{won} of 8 got the room"); // always 1 }}package main
import ( "errors" "fmt" "sort" "sync" "time")
// Unchanged from the core version (NewTimeSlot is left out to keep the change visible).type TimeSlot struct { Start, End time.Time}
func (s TimeSlot) Overlaps(o TimeSlot) bool { return s.Start.Before(o.End) && o.Start.Before(s.End)}
type Room struct { ID, Name string Capacity int}
type Booking struct { ID, RoomID, OrganizerID string Slot TimeSlot Attendees int}
// BookingListener is new: whoever wants to hear about bookings. The service knows nothing// about email or calendars; an invite sender is one more listener, added without editing it.type BookingListener interface { OnBooked(Booking) OnCancelled(Booking)}
// roomState keeps a room's bookings next to the lock that guards them, so the pairing is// visible in the type. One lock per room: people booking different rooms never wait.type roomState struct { room Room mu sync.Mutex bookings []Booking}
type BookingService struct { // Rooms are added at startup, before the service is shared, so this is only read afterwards. rooms []*roomState // Booking ids span rooms, so the id index gets its own small lock. // Lock order is always room lock, then index lock, so the two can't deadlock. indexMu sync.Mutex byID map[string]Booking nextID int listeners []BookingListener}
func NewBookingService() *BookingService { return &BookingService{byID: map[string]Booking{}, nextID: 1}}
func (s *BookingService) AddRoom(r Room) { s.rooms = append(s.rooms, &roomState{room: r}) }func (s *BookingService) AddListener(l BookingListener) { s.listeners = append(s.listeners, l) }
func (s *BookingService) Book(roomID, organizerID string, slot TimeSlot, attendees int) (Booking, error) { rs := s.state(roomID) if rs == nil { return Booking{}, fmt.Errorf("no room %s", roomID) } // Capacity never changes, so this check needs no lock. if attendees > rs.room.Capacity { return Booking{}, fmt.Errorf("%s seats %d", rs.room.Name, rs.room.Capacity) }
rs.mu.Lock() // The whole fix: "is it free?" and "take it" happen under one lock, so no other // goroutine can book the same room in between. if !rs.isFree(slot) { rs.mu.Unlock() return Booking{}, fmt.Errorf("%s is taken then", rs.room.Name) } s.indexMu.Lock() b := Booking{fmt.Sprintf("B%d", s.nextID), roomID, organizerID, slot, attendees} s.nextID++ s.byID[b.ID] = b s.indexMu.Unlock() rs.bookings = append(rs.bookings, b) rs.mu.Unlock()
// Listeners run after the lock is released: a slow mail server must not hold up the room. s.tell(func(l BookingListener) { l.OnBooked(b) }) return b, nil}
// FindFreeRooms can be stale the moment it returns. That is fine: Book checks again under the// lock, so a stale search can only lead to a polite rejection, never a double booking.func (s *BookingService) FindFreeRooms(slot TimeSlot, attendees int) []Room { var fits []Room for _, rs := range s.rooms { if rs.room.Capacity < attendees { continue } rs.mu.Lock() free := rs.isFree(slot) rs.mu.Unlock() if free { fits = append(fits, rs.room) } } sort.SliceStable(fits, func(i, j int) bool { return fits[i].Capacity < fits[j].Capacity }) return fits}
func (s *BookingService) Cancel(bookingID, employeeID string) error { s.indexMu.Lock() b, ok := s.byID[bookingID] s.indexMu.Unlock() if !ok { return fmt.Errorf("no booking %s", bookingID) } if b.OrganizerID != employeeID { return errors.New("only the organizer can cancel") } rs := s.state(b.RoomID) rs.mu.Lock() // Two cancels of the same booking race here; only the first one removes it. s.indexMu.Lock() _, still := s.byID[bookingID] delete(s.byID, bookingID) s.indexMu.Unlock() if still { for i := range rs.bookings { if rs.bookings[i].ID == bookingID { rs.bookings = append(rs.bookings[:i], rs.bookings[i+1:]...) break } } } rs.mu.Unlock() if still { s.tell(func(l BookingListener) { l.OnCancelled(b) }) } return nil}
func (s *BookingService) state(roomID string) *roomState { for _, rs := range s.rooms { if rs.room.ID == roomID { return rs } } return nil}
// isFree must be called with rs.mu held.func (rs *roomState) isFree(slot TimeSlot) bool { for _, b := range rs.bookings { if b.Slot.Overlaps(slot) { return false } } return true}
// tell isolates listeners: one that panics must not fail a booking that already happened.func (s *BookingService) tell(event func(BookingListener)) { for _, l := range s.listeners { func() { defer func() { if r := recover(); r != nil { fmt.Println("listener failed:", r) } }() event(l) }() }}
type inviteSender struct{}
func (inviteSender) OnBooked(b Booking) { fmt.Println("Invite sent for", b.ID) }func (inviteSender) OnCancelled(b Booking) { fmt.Println("Cancellation sent for", b.ID) }
func main() { s := NewBookingService() s.AddRoom(Room{"R2", "Focus", 4}) s.AddListener(inviteSender{})
// Eight people press "book" for the same slot at the same moment. nine := time.Date(2026, 3, 2, 9, 0, 0, 0, time.UTC) slot := TimeSlot{nine, nine.Add(30 * time.Minute)} var wg sync.WaitGroup var mu sync.Mutex won := 0 start := make(chan struct{}) for i := 0; i < 8; i++ { wg.Add(1) go func(who string) { defer wg.Done() <-start if _, err := s.Book("R2", who, slot, 2); err == nil { mu.Lock() won++ mu.Unlock() } }(fmt.Sprintf("user%d", i)) } close(start) wg.Wait() fmt.Println(won, "of 8 got the room") // always 1, and clean under go run -race}// Unchanged from the core version.class TimeSlot { constructor( readonly start: Date, readonly end: Date, ) { if (start.getTime() >= end.getTime()) throw new Error("start must be before end"); }
overlaps(other: TimeSlot): boolean { return this.start < other.end && other.start < this.end; }}
interface Room { readonly id: string; readonly name: string; readonly capacity: number;}
interface Booking { readonly id: string; readonly roomId: string; readonly organizerId: string; readonly slot: TimeSlot; readonly attendees: number;}
class BookingError extends Error {}
// New: whoever wants to hear about bookings. The service knows nothing about email or// calendars; an invite sender is one more listener, added without editing the service.interface BookingListener { onBooked(booking: Booking): void; onCancelled(booking: Booking): void;}
class BookingService { private readonly rooms = new Map<string, Room>(); private readonly byRoom = new Map<string, Booking[]>(); private readonly byId = new Map<string, Booking>(); private readonly listeners: BookingListener[] = []; private nextId = 1;
addRoom(room: Room): void { this.rooms.set(room.id, room); this.byRoom.set(room.id, []); }
addListener(listener: BookingListener): void { this.listeners.push(listener); }
// Node runs this on one thread, and book() never awaits between "is it free?" and // "take it", so no other request can run in between: the check and the insert are atomic. // If the check ever moves to a database call with an await, that guarantee is gone, and the // rule has to move into the database (a unique or exclusion constraint) or a per-room queue. book(roomId: string, organizerId: string, slot: TimeSlot, attendees: number): Booking { const room = this.rooms.get(roomId); if (!room) throw new BookingError(`no room ${roomId}`); if (attendees > room.capacity) throw new BookingError(`${room.name} seats ${room.capacity}`); if (!this.isFree(roomId, slot)) throw new BookingError(`${room.name} is taken then`); const booking: Booking = { id: `B${this.nextId++}`, roomId, organizerId, slot, attendees }; this.byRoom.get(roomId)!.push(booking); this.byId.set(booking.id, booking); // Listeners run after the booking is stored, and a failing one can't undo it. this.tell((l) => l.onBooked(booking)); return booking; }
findFreeRooms(slot: TimeSlot, attendees: number): Room[] { return [...this.rooms.values()] .filter((r) => r.capacity >= attendees && this.isFree(r.id, slot)) .sort((a, b) => a.capacity - b.capacity); }
cancel(bookingId: string, employeeId: string): void { const booking = this.byId.get(bookingId); if (!booking) throw new BookingError(`no booking ${bookingId}`); if (booking.organizerId !== employeeId) throw new BookingError("only the organizer can cancel"); this.byId.delete(bookingId); const list = this.byRoom.get(booking.roomId)!; list.splice(list.indexOf(booking), 1); this.tell((l) => l.onCancelled(booking)); }
private isFree(roomId: string, slot: TimeSlot): boolean { return !this.byRoom.get(roomId)!.some((b) => b.slot.overlaps(slot)); }
// One broken listener must not fail a booking that already happened. private tell(event: (l: BookingListener) => void): void { for (const l of this.listeners) { try { event(l); } catch (e) { console.error("listener failed:", (e as Error).message); } } }}
const service = new BookingService();service.addRoom({ id: "R2", name: "Focus", capacity: 4 });service.addListener({ onBooked: (b) => console.log("Invite sent for", b.id), onCancelled: (b) => console.log("Cancellation sent for", b.id),});
// Eight requests for the same slot arrive together. Each is an async handler, as in a web server.const slot = new TimeSlot(new Date(2026, 2, 2, 9, 0), new Date(2026, 2, 2, 9, 30));const attempt = async (who: string): Promise<boolean> => { await Promise.resolve(); // let all eight start before any of them books try { service.book("R2", who, slot, 2); return true; } catch { return false; // losing the race is a normal outcome }};Promise.all([...Array(8).keys()].map((i) => attempt(`user${i}`))).then((results) => { console.log(results.filter(Boolean).length, "of 8 got the room"); // always 1});Three details in that code carry the answer:
- The check and the insert sit under the same lock. That is the whole fix. Everything else is about not making it slower or less safe than it needs to be.
- Listeners are called after the lock is released. An invite service that takes two seconds must not hold the room locked for two seconds. A listener that calls back into the service must not deadlock. And one failing listener is caught, so it can’t undo a booking that happened.
- Search reads under each room’s lock and may still be stale. That is acceptable because
bookchecks again under the lock. A stale search can only lead to a polite rejection, never a double booking.
Go deeperHow each language holds the lock1 min
Lock order is fixed. Python and Go hold two locks: a room’s, then the id index. They are always taken in the same order. So two threads can’t wait on each other forever.
Each language reaches the guarantee its own way. Java uses a ReentrantLock per room. C# uses a
lock on a per-room object. Go uses a sync.Mutex inside a per-room struct. Python uses a
threading.Lock. TypeScript in one Node process has no race at all, because book never awaits
between the check and the insert. The comment in that tab says what changes when the check moves to
a database call.
The bug is check-then-act in book. I’ll put the check and the insert under one lock per room, so bookings for different rooms never wait on each other. Listeners run after the lock is released. With several app servers I’d move the rule into the database as a constraint on room and time range.
Some edge cases are worth one sentence each:
- A zero-length or reversed slot is rejected when the
TimeSlotis made. - Back-to-back meetings pass, because ranges are half-open.
- Two cancels of the same booking race, and only the first one removes it.
- Times are compared in one time zone. The conversion happens at the edge of the system.
Testing
Section titled “Testing”How would you test this?
Whether each test protects a named promise from the requirements, and whether you can test a race without sleeping and hoping. The red flag is “unit tests for every class”, which names no promise at all.
Name each test after the promise it protects, taken straight from the requirements list. An overlap by even a minute is rejected. Back-to-back bookings both pass. And many threads booking one slot produce exactly one booking.
Go deeperSeven tests, one per promise1 min
- A booking that overlaps an existing one, by even a minute, is rejected.
- Back-to-back bookings (one ends at 9:30, the next starts at 9:30) are both accepted.
- A group larger than the room is rejected, and nothing is stored.
- Free-room search returns the smallest fitting room first and skips booked ones.
- Only the organizer can cancel, and a cancelled slot can be booked again.
- A listener that throws doesn’t fail the booking.
- Many threads booking the same slot produce exactly one booking.
The race test is the one that matters most. The main in the code above is a small version of it.
Eight threads wait on a barrier, so they start together. All book the same slot. The test checks
that exactly one succeeds. A race test can pass by luck, so pair it with the reasoning about the
lock. Inject the clock wherever “now” matters (a rule like “no bookings in the past”). Then a test
doesn’t depend on when it runs. If the round has five spare minutes, saying this list is worth more
than writing one of the tests.
Alternatives and extensions
Section titled “Alternatives and extensions”Now add recurring meetings. And some rooms are VIP rooms that only directors can book.
Whether each feature lands in one place, and whether you can say which earlier decision made
that possible. The red flag is a VipRoom extends Room subclass, or a pile of new ifs in
book: the design didn’t absorb the change.
The extension round tests the design, not your typing. For each request, say where it lands and
what does not change. A good design answers most of them with “a new class, or one new method,
and book stays the same”.
Go deeperWhere six common extensions land2 min
| Extension | What changes | Touches book? |
|---|---|---|
| Recurring meetings | A Recurrence value object that expands into slots, and a bookSeries that checks every slot under the room lock before storing any |
No; a sibling method reuses its checks |
| VIP rooms | A booking policy (who may book which room), checked before the overlap check | One call to the policy |
| Maintenance blocks | A booking with a system organizer, so the overlap rule covers it for free | No |
| Several offices | An Office owning its rooms; search takes an office |
No |
| Room choice by floor | The second rule that makes room choice a Strategy | No; search takes the strategy |
| Persistence | A repository behind an interface; the overlap rule moves into a database constraint | Constructor and the check |
VIP rooms are where composition vs inheritance
comes up, in nearly every LLD round. A VipRoom subclass looks natural. Then the next rule arrives:
“rooms with video kits can only be booked by staff who completed training”. Now you need
VipVideoRoom. Instead, give a room a list of booking rules, checked by the service. It takes every
such rule, without a new class per combination.
I wouldn’t subclass Room for VIP. I’d give the service a booking policy that answers “may this person book this room?”, and VIP is one rule in it. Recurring meetings expand into slots and go through the same book path under the room lock, all or nothing, so the overlap rule still holds.
Tradeoffs
Section titled “Tradeoffs”Where candidates lose the round
Section titled “Where candidates lose the round”Commonly taught wrong
Section titled “Commonly taught wrong”Where it shows up
Section titled “Where it shows up”Every worked problem on this site follows this playbook, with its own clarifying table, requirements, stages, full class diagram, concurrency step and extensions. Good ones to study next, and the step each shows best:
- Parking lot: clarifying questions that decide the hard part (how many gates), and a race fixed first with one lock, then per spot.
- Vending machine: a pattern (State) introduced only once
stage 1’s
ifs show why it is needed. - Library management: a policy object instead of member subclasses, the same composition argument as VIP rooms here.
- Movie ticket booking: two people click the same seat. It’s the same check-then-act race as two people booking one room, with holds that expire on top.
- Hotel booking: bookings over date ranges, like here. One clarifying question (does the guest pick a room or a room type?) changes the whole model.
- LRU cache: here the data structure, not the class model, is the hard part. So one stage gets coded in full, and checked line by line.
Interview questions
Section titled “Interview questions”Interviewer asksYou have 45 minutes. How do you split them?
About four minutes of clarifying questions. Three to write the use cases and the one rule that must hold. Twenty growing the design in stages, with code. Three to walk the full diagram. Eight on concurrency and edge cases. Four on extensions. And a few for my questions. If I fall behind, I stub secondary use cases, and protect the concurrency step.
Interviewer asksWhy not draw the whole class diagram before writing code?
Because I’d be guessing at requirements I haven’t heard. I draw the slice I’m about to code, code it, and extend the drawing when a new requirement arrives. Each class then has a reason behind it, and a new requirement is a step forward instead of a redraw. If you’d like the full picture first, I’ll sketch it and mark what’s speculative.
Interviewer asksHow do you decide whether to use a design pattern?
I look for a rule that will change, or a variant that will be added. And I want to be able to name the second case. One room-choice rule is a private method. The second rule makes it a strategy. If I can’t name what a pattern protects, it doesn’t go in.
Interviewer asksYou're at minute 35 and haven't touched concurrency. What do you do?
Stop extending, and say so: “Before more features, there’s a race in book.” Name the check-then-act gap. Put the check and the insert under one lock. And say how I’d narrow it to a lock per room. Two minutes of a correct answer here is worth more than another feature.
Go deeperMid vs senior, step by step2 min
| Step | Mid-level answer | Senior answer adds |
|---|---|---|
| Clarifying | Asks about inputs, roles and scope | Asks the question that exposes the hard part (how many writers at once) and the boundary rule (half-open ranges) |
| Requirements | Lists the use cases | Names the one invariant everything else protects |
| Modeling | Finds the main entities, puts methods on sensible classes | Finds the value object that holds the rule, keeps actors out, explains why each class exists |
| Code | Working book, findFreeRooms, cancel |
Check-everything-then-change, typed inputs, failures as part of the interface, a named shortcut with its replacement |
| Patterns | Uses one where it obviously fits | Also says which tempting patterns don’t fit and when they would |
| Concurrency | Spots the race when asked, adds a lock | Raises it unprompted, picks the lock’s scope with a reason, moves side effects out of the lock, knows when the database must decide |
| Extensions | Adds features with a little rework | Says where each lands and which earlier decision made that cheap |
| Decision | We chose | We gave up |
|---|---|---|
| First four minutes | Questions that each change a class, method or lock | An early start on drawing |
| Requirements | Numbered use cases plus the one invariant | Nothing; it takes three minutes |
| Finding classes | Nouns as candidates, kept only with data or a rule | The comfort of a long class list |
| Diagram | Drawn per stage, just before its code | A complete picture at minute 5 |
| Code | Core paths first, plumbing stubbed with a comment | Covering every use case in full |
| Patterns | Only where a rule changes (Observer here) | Showing off the pattern catalog |
| Concurrency | Check and insert under one lock per room | The simplicity of no locks |
| Extensions | Composition and policies, not subclasses | Hierarchies that look natural at first |
Further reading
Section titled “Further reading”- Object modeling, for finding entities and assigning responsibilities in depth
- UML for interviews, for the diagram symbols used above
- Thread safety basics, for locks, atomics and the check-then-act gap
- Composition vs inheritance, for the VIP rooms argument
- Single responsibility principle and open/closed principle, the two principles the extension round tests most
- Observer pattern and Strategy pattern
- Brian Goetz et al., Java Concurrency in Practice (2006), chapter 2 on check-then-act and compound actions
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.