Skip to content

Low Level DesignFoundations

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.

Foundation29 min core · 34 min full depth

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.

The playbook in one picture

interviewer adds a requirement

Clarify
4 min

Use cases
3 min

Entities

Draw the slice

Code the core path

Walk the full diagram
3 min

Concurrency
8 min

Extensions
4 min

interviewer adds a requirement

Clarify
4 min

Use cases
3 min

Entities

Draw the slice

Code the core path

Walk the full diagram
3 min

Concurrency
8 min

Extensions
4 min

Scroll or pinch to zoom, drag to pan
  • Color key
  • Understand the problem
  • Design and code
  • Check the design
  • Make it robust

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.

A 42 minute interviewMinute marks show when each step should start
  1. Clarify4m0'
  2. Requirements3m4'
  3. Build it up20m7'
  4. Class diagram3m27'
  5. Concurrency8m30'
  6. Extensions4m38'

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.

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.

Interviewerminute 0

Design a meeting room booking system.

What they are testing

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
Say this out loud

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.

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

  1. 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.
  2. Find the free rooms for a time range and a head count, smallest first.
  3. Cancel a booking. Only its organizer may.
  4. 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.

Interviewerminute 7

Let’s start simple. Someone books Focus room from 9:00 to 9:30. What happens?

What they are testing

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.

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.

The model these answers add up to

BookingServiceFacade
  • book(roomId, organizerId, slot, attendees)
  • findFreeRooms(slot, attendees)
  • cancel(bookingId, employeeId)
RoomEntity
  • id
  • name
  • capacity: int
BookingEntity
  • id
  • roomId
  • slot: TimeSlot
  • organizerId
  • attendees: int
TimeSlotValue object
  • 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. TimeSlot is 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.

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.

Stage 1: the smallest model that books a room

1

*

1

*

slot

BookingService

-rooms

-bookingsByRoom

+book(roomId, organizerId, slot, attendees) : Booking

+findFreeRooms(slot, attendees) : List<Room>

+cancel(bookingId, employeeId)

Room

+id

+name

+capacity

Booking

+id

+roomId

+organizerId

+attendees

«value»

TimeSlot

+start

+end

+overlaps(TimeSlot) : bool

1

*

1

*

slot

BookingService

-rooms

-bookingsByRoom

+book(roomId, organizerId, slot, attendees) : Booking

+findFreeRooms(slot, attendees) : List<Room>

+cancel(bookingId, employeeId)

Room

+id

+name

+capacity

Booking

+id

+roomId

+organizerId

+attendees

«value»

TimeSlot

+start

+end

+overlaps(TimeSlot) : bool

Scroll or pinch to zoom, drag to pan
  • 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.

Interviewerminute 10

Looks reasonable. Write book for me.

What they are testing

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.

BookingService.javabooking_service.pyBookingService.csbooking_service.gobooking-service.ts
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
}
}

What 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 in cancel. When the interviewer asks “where is the rule that…?”, you point at one line.
  • Check everything, then change anything. book validates 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. book takes ids and a TimeSlot, not a Room object the caller might have built by hand. The service decides which room object is real.
  • The deliberate shortcut is named. isFree scans one room’s list, and its comment says what replaces it for a year of data.
Say this out loud

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.

Interviewerminute 20

The calendar team wants to send invites when a meeting is booked or cancelled. Where does that go?

What they are testing

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.

Stage 2: a listener for booking events

1

*

1

*

slot

tells

*

BookingService

-listeners: List<BookingListener>

+book(roomId, organizerId, slot, attendees) : Booking

+findFreeRooms(slot, attendees) : List<Room>

+cancel(bookingId, employeeId)

+addListener(BookingListener)

Room

+id

+name

+capacity

Booking

+id

+roomId

+organizerId

+attendees

«value»

TimeSlot

+start

+end

+overlaps(TimeSlot) : bool

«interface»

BookingListener

+onBooked(Booking)

+onCancelled(Booking)

InviteSender

+onBooked(Booking)

+onCancelled(Booking)

1

*

1

*

slot

tells

*

BookingService

-listeners: List<BookingListener>

+book(roomId, organizerId, slot, attendees) : Booking

+findFreeRooms(slot, attendees) : List<Room>

+cancel(bookingId, employeeId)

+addListener(BookingListener)

Room

+id

+name

+capacity

Booking

+id

+roomId

+organizerId

+attendees

«value»

TimeSlot

+start

+end

+overlaps(TimeSlot) : bool

«interface»

BookingListener

+onBooked(Booking)

+onCancelled(Booking)

InviteSender

+onBooked(Booking)

+onCancelled(Booking)

Scroll or pinch to zoom, drag to pan
  • 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:

The same requirements, pattern-stuffed

«singleton»

BookingServiceSingleton

+getInstance()

BookingFactory

+create(...) : Booking

BookingBuilder

+room()

+organizer()

+slot()

+build() : Booking

«interface»

BookingState

ActiveState

CancelledState

«interface»

RoomSelectionStrategy

SmallestFirst

«singleton»

BookingServiceSingleton

+getInstance()

BookingFactory

+create(...) : Booking

BookingBuilder

+room()

+organizer()

+slot()

+build() : Booking

«interface»

BookingState

ActiveState

CancelledState

«interface»

RoomSelectionStrategy

SmallestFirst

Scroll or pinch to zoom, drag to pan
  • 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.

Interviewerminute 27

Walk me through the whole design.

What they are testing

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.

Say this out loud

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.

Interviewerminute 30

Ana and Raj both click “book” on Focus room, 9:00 to 9:30, at the same moment. What happens?

What they are testing

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.

Sequence: two bookings race, and the room lock decides
Focus room lockBookingServiceRajAnaFocus room lockBookingServiceRajAnabook(Focus, 9:00 to 9:30)book(Focus, 9:00 to 9:30)Ana takes the lockisFree is true, store B1releaseB1, then listeners hear about itRaj takes the lockisFree is falsereleaserejected, Focus is taken thenFocus room lockBookingServiceRajAnaFocus room lockBookingServiceRajAnabook(Focus, 9:00 to 9:30)book(Focus, 9:00 to 9:30)Ana takes the lockisFree is true, store B1releaseB1, then listeners hear about itRaj takes the lockisFree is falsereleaserejected, Focus is taken then
Scroll or pinch to zoom, drag to pan
  • 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.

BookingService.javabooking_service.pyBookingService.csbooking_service.gobooking-service.ts
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
}
}

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 book checks 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.

Say this out loud

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 TimeSlot is 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.
Interviewerif time allows

How would you test this?

What they are testing

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.

Interviewerminute 38

Now add recurring meetings. And some rooms are VIP rooms that only directors can book.

What they are testing

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.

Say this out loud

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.

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.
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