SDE-2 LLD Handbook
Low-level design from objects to SDE-2 interviews in 11 modules and 17 worked problems, practice first: every lesson opens with a question to try, every diagram steps through, every snippet of Java was compiled and run, and a practice tracker brings each problem back for spaced review.
Loading your progress.
How this handbook teaches
The goal is not to memorize 23 patterns or 17 solutions. The goal is to look at a new prompt, find the objects and the rules they protect, notice where change will come from, and write clean code that runs, inside a time box. So the handbook asks you to think before it explains.
- Try before you read. Every lesson opens with a short question. Answer it on paper first; getting it wrong is what makes the explanation stick.
- Pressure first, pattern second. Each pattern starts from the code smell it fixes and ends with when it is the wrong tool. Only the 14 patterns that LLD rounds actually use get a full lesson; the rest get one line each in the smell drills.
- Analogy, then mechanism, then trade-off. Depth sits in folded "Go deeper" sections.
- Step through every diagram. Diagrams with a step bar reveal one part at a time. Predict the next part before pressing Next.
- Real, tested code. Every Java file in this handbook was compiled with Java 17 and run; the output blocks are the real output. Code marked with a red dot is the version to avoid; green is the fix.
- Production notes. Green "In production" boxes show where the idea lives in the JDK, Spring, and payment systems, so the handbook serves your day job too.
- Interview signals. Purple boxes say what interviewers listen for at SDE-2 and what lifts an answer above the bar.
- Live tools. A pattern picker, an LRU simulator, a round timer, a mock rubric, and a machine coding starter project.
How to study one module
- Clear the revision queue above at the start of each session: redo those problems from a blank page.
- For each lesson: answer the "Try it first" question, read the short part, step through the figure, then read the code and type it out once. Typing, not pasting, is how the shapes get into your hands for the machine coding round.
- Say it out loud: what problem the idea solves, and when it is the wrong choice.
- Do the self-check quiz at the end, then tick lessons as studied.
- Tick the mastery checklist only when each item is true with your notes closed.
How to practice a problem
- Pick the round format and start the timer on the interview playbook: 45 to 60 minutes for an LLD discussion (requirements, class diagram, key flows, key code), 90 to 120 minutes for machine coding (code that runs).
- Then open the problem and compare section by section. Answer each guided deep dive before revealing it.
- Set an honest status at the top of the problem page. The handbook schedules a review.
- Redo it from memory when it comes due, faster each time. Then try the practice prompts in the tracker that use the same ideas.
Statuses and the revision queue
| Status | Meaning | Next review |
|---|---|---|
| Not started | Not attempted | Not scheduled |
| Could not solve it | You needed the solution for the core design | 2 days |
| Solved with hints | You got there with a few peeks | 4 days |
| Solved independently | Working design and code inside the time box, trade-offs explained | 10 days |
| Needs revision | Did it before, but a redo went badly | 1 day |
| Can teach it | Solved it twice weeks apart, and can answer every follow-up | 30 days |
When a problem comes up for review, redo it without notes, then set the new status or press "Reviewed, same status" to restart its timer.
Where your progress is saved
Lesson ticks, mastery checks, readiness checks, and problem statuses save automatically to your account, privately: nobody else who opens this page sees them, and they follow you to other devices. The indicator in the top bar shows where the latest change went. If the page cannot reach your account (for example in a private window), it saves in this browser instead, and the tracker has a backup and restore box.
Scope
This handbook covers what SDE-2 LLD and machine coding rounds test, in the depth they test it: object-oriented modeling, UML that interviewers read, SOLID, 14 design patterns, state machines, concurrency inside one process, the tables and REST APIs that LLD rounds ask for, scoping a one-line prompt, and the craft of writing a runnable, extensible solution in 90 minutes. Code is Java 17; everything also runs on Java 21.
It deliberately leaves out: distributed systems (the HLD handbook covers them), framework internals beyond where a pattern appears, and the rarely used Gang of Four patterns beyond a one-line description. If an interviewer asks about one of those, the smell drills page gives you the sentence you need.
Syllabus and prerequisite map
Eleven modules in three phases, two pages of drills (smells and scoping), then seventeen worked problems at three levels. Each phase uses the one before it; the problems use everything.
The map
Priority overview
| Module | Why it matters | Interview weight | Time |
|---|---|---|---|
| 1 Objects and responsibilities | Encapsulation and composition are what every design is judged on | Every round | 2 h |
| 2 Relationships and UML | Draw and read class, sequence, and state diagrams quickly | Every LLD round | 1.5 h |
| 3 SOLID and principles | The vocabulary for defending a design choice; dependency injection | High | 2 h |
| 4 Creational patterns | Factory and builder appear constantly; singleton questions are a trap | Medium | 1.5 h |
| 5 Structural patterns | Decorator and adapter in follow-ups; composite for trees | Medium | 2 h |
| 6 Strategy, template, chain | The most used pattern family in LLD answers | Very high | 2 h |
| 7 Observer, command, state | State machines appear in most problems | Very high | 2.5 h |
| Smell drills | Recognizing which pattern a smell needs, under time pressure | High | 1.5 h |
| 8 Requirements to classes | The method you use in every LLD round | Very high | 2.5 h |
| 9 Concurrency for LLD | The clearest SDE-2 differentiator | Very high | 3 h |
| 10 Machine coding craft | Runnable, clean code in 90 minutes | Very high for machine coding | 2.5 h |
| 11 Tables and APIs | Schema, constraints, safe SQL writes, and REST endpoints asked after the class diagram | High at product companies | 2 h |
| Scoping drills | One-line prompts ("Design Uber") need a scope in five minutes | High | 1 h |
Total: about 26 hours of study, plus about 35 hours for the 17 problems (60 to 120 minutes to attempt each, then 30 to 45 minutes to compare and note what you missed).
| Level | Problems | Attempt when |
|---|---|---|
| Starter | P1, P2, P3, P4, P13 | After phase 2 |
| Core | P5 to P10, P14, P15 | After Module 9; this is the SDE-2 bar |
| Stretch | P11, P12, P16, P17 | Once core problems are solved independently |
Short on time
With two weeks or less before an interview, take the fast track and come back for the rest later:
- Module 2 (diagrams), Module 6, Module 7: the patterns that carry most answers.
- Module 8 and Module 10: the method and the coding craft.
- Module 9: at least locks,
ConcurrentHashMap, and lock ordering. - Problems P1, P3, P6, P9, P10, P15, timed.
- The interview playbook and three scoping drills, with two full mocks scored on the rubric.
- Module 11 if your target companies ask for tables or API design in LLD rounds.
How each module is organized
- Lessons, each opening with a "Try it first" question, then the pressure the idea relieves, an analogy, the mechanism, code (red dot: avoid; green dot: prefer), and the trade-offs.
- Figures that step through one part at a time.
- Go deeper folds for details you need only sometimes.
- In production notes and Interview signal notes.
- Common mistakes, a self-check quiz, and a mastery checklist.
Problems follow one format: try it first; requirements with assumed answers and invariants; entities and responsibilities; class diagram; key flows; tested code with its real output; concurrency; guided deep dives; a minute-by-minute machine coding plan; what a strong answer shows; and follow-up questions.
Module 1: Objects and responsibilities
Low-level design decides which objects exist, what each one knows, and what each one does, so the code is correct today and cheap to change tomorrow. This module builds the object thinking every later module uses: encapsulation that protects rules, interfaces that hide choices, composition over inheritance, polymorphism instead of type switches, and value objects that cannot reach a bad state.
- A
BankAccounthas a publicsetBalance(double)method. Name two things that can go wrong.Reveal
Any caller can set any balance, so the rule "no overdraft" and the audit trail can be bypassed. And
doublecannot represent most decimal amounts exactly, so money drifts by fractions of a paisa. - Interface or abstract class for "things that can receive a notification"?
Reveal
An interface. It names a capability that unrelated classes can have, and a class can implement many interfaces. Use an abstract class when closely related types share state and code.
- Why do experienced engineers say "prefer composition over inheritance"?
Reveal
A subclass depends on the parent's internals, so parent changes break it; combinations of features multiply subclasses; and Java allows only one parent. Composition lets you combine and swap parts, even at runtime.
- Two
Moneyobjects both hold 10.00 INR. Shoulda.equals(b)be true? Shoulda == b?Reveal
equalsshould be true because a value object is defined by its values.==compares references and is usually false. ImplementequalsandhashCodetogether, or use arecord. - The same
if (vehicle instanceof Car) ... else if (vehicle instanceof Bike) ...appears in three services. What is the fix?Reveal
Move the varying behavior into the types (
vehicle.hourlyRate()), or into a strategy chosen once. Then a new vehicle type is one new class instead of three edits.
1.1What LLD is and how it is judged
Two solutions to the same parking lot problem both pass the demo. List four things that would make an interviewer prefer one of them.
Reveal the answer
- Change cost: adding a vehicle type or a pricing rule touches one place, not five.
- Clear responsibilities: each class has one job and a name that says it; no
ParkingLotManagerthat does everything. - Correctness at the edges: invalid input, a full lot, a ticket paid twice, two cars racing for the last spot.
- Readability and testability: small methods, dependencies passed in, behavior you can test without a database.
Low-level design turns requirements into classes, interfaces, relationships, and method signatures. High-level design decides services, data stores, and how they talk over the network.
HLD is the city map: districts, roads, water mains. LLD is the floor plan of one building: rooms, doors, and wiring. A great map with a bad floor plan still gives you a building nobody can live in.
Too little design gives tangled code. Too much gives speculative interfaces nobody needs. Aim for a design where the next likely change touches one class.
Interviews test LLD in two formats, and this handbook prepares you for both:
| LLD discussion round | Machine coding round | |
|---|---|---|
| Length | 45-60 minutes | 90-120 minutes |
| You produce | Requirements, class diagram, key flows, some code for the core classes | A working, runnable program with a demo or tests |
| Judged on | Modeling, responsibilities, patterns where they fit, extensibility, concurrency answers | Working code first, then modularity, extensibility, edge cases, readability |
| Typical prompts | Parking lot, elevator, movie booking, Splitwise | Splitwise, snake and ladder, rate limiter, in-memory queue, cache |
Four qualities sit behind every rubric:
- Correct: the rules hold in every path, including errors and concurrent callers.
- Clear: names and responsibilities that a reader understands without you explaining.
- Changeable: the likely next requirement fits by adding a class, not by editing ten.
- Simple: no pattern or interface that does not pay for itself today or for the next obvious change.
Say the change you are designing for out loud: "Pricing rules are the most likely thing to change, so pricing sits behind an interface." Interviewers score the reasoning, not the number of patterns.
1.2Objects: state, behavior, identity
List what a ParkingTicket object should know and what it should do.
Reveal the answer
It knows its ID, vehicle number, spot, entry time, and exit time. It does the things that need only that data: close itself on exit (once, and never before entry) and report how long the vehicle stayed. Fee calculation could live here too, but it usually depends on pricing rules that change, so it goes to a pricing strategy that reads the duration.
A class is a blueprint; an object is one instance of it at runtime. Every object has three things:
- State: the values of its fields right now.
- Behavior: its methods, which read and change that state.
- Identity: it is distinguishable from other objects, even ones with equal state. Two tickets for the same car at the same time are still two tickets.
Objects with identity that matters over time are entities (an order, an account, a ticket). Objects defined only by their values are value objects (money, a date range, a coordinate); lesson 1.7 covers them.
class ParkingTicket {
// what it knows (state)
private final String id; // identity: two tickets with equal fields are still different tickets
private final String vehicleNumber;
private final String spotId;
private final Instant entryTime;
private Instant exitTime; // null while the vehicle is inside
ParkingTicket(String id, String vehicleNumber, String spotId, Instant entryTime) {
this.id = id;
this.vehicleNumber = vehicleNumber;
this.spotId = spotId;
this.entryTime = entryTime;
}
// what it does (behavior that uses its own data)
void close(Instant at) {
if (exitTime != null) throw new IllegalStateException("ticket " + id + " already closed");
if (at.isBefore(entryTime)) throw new IllegalArgumentException("exit before entry");
exitTime = at;
}
Duration parkedFor(Instant now) {
return Duration.between(entryTime, exitTime != null ? exitTime : now);
}
boolean isOpen() { return exitTime == null; }
String id() { return id; }
}
The rule of thumb that drives most LLD decisions: put behavior with the data it needs. The ticket checks "closed twice" and "exit before entry" because it owns the times. If a service did these checks instead, every service that closes tickets would need to repeat them.
An object is a small set of data plus the rules that keep that data valid. If a class only has getters and setters, its rules live somewhere else, usually duplicated.
1.3Encapsulation: protect invariants
A Wallet class has public BigDecimal balance; and services do w.balance = w.balance.subtract(x). Name three bugs this design invites.
Reveal the answer
- Nothing stops a negative balance; every service must remember the check.
- Nothing stops a negative or zero
x, which turns a debit into a credit. - History or audit entries can be skipped, because writing the balance and writing history are separate steps that each caller must do.
- Two threads can read the same balance and both subtract (a lost update, Module 9).
An invariant is a rule that must always hold for an object, such as "balance is never negative" or "a closed ticket has an exit time". Encapsulation keeps the data private and exposes only methods that preserve the invariants. It is not "add a getter and setter for every field": a public setter is a public field with extra typing.
class WalletBad {
public BigDecimal balance = BigDecimal.ZERO; // anyone can write anything
public List<String> history = new ArrayList<>(); // anyone can clear it
}
class PaymentServiceBad {
void pay(WalletBad w, BigDecimal amount) {
// the rule "no negative balance" lives here, and in every other service that touches balance
if (w.balance.compareTo(amount) >= 0) {
w.balance = w.balance.subtract(amount);
w.history.add("debit " + amount);
}
}
}
class Wallet {
private BigDecimal balance = BigDecimal.ZERO;
private final List<String> history = new ArrayList<>();
void credit(BigDecimal amount) {
requirePositive(amount);
balance = balance.add(amount);
history.add("credit " + amount);
}
void debit(BigDecimal amount) {
requirePositive(amount);
if (balance.compareTo(amount) < 0) {
throw new IllegalStateException("insufficient balance: " + balance + " < " + amount);
}
balance = balance.subtract(amount);
history.add("debit " + amount);
}
BigDecimal balance() { return balance; } // BigDecimal is immutable: safe to return
List<String> history() { return Collections.unmodifiableList(history); } // read-only view, not the list itself
private static void requirePositive(BigDecimal amount) {
if (amount == null || amount.signum() <= 0) throw new IllegalArgumentException("amount must be positive");
}
}
Code that ranEncapsulation.java: the rest of the file, including the main method behind the output below
class Demo {
public static void main(String[] args) {
Wallet w = new Wallet();
w.credit(new BigDecimal("500.00"));
w.debit(new BigDecimal("120.50"));
try { w.debit(new BigDecimal("1000")); throw new AssertionError(); } catch (IllegalStateException e) { System.out.println(e.getMessage()); }
try { w.history().clear(); throw new AssertionError(); } catch (UnsupportedOperationException e) { System.out.println("history is read-only"); }
System.out.println("balance " + w.balance() + ", history " + w.history());
WalletBad b = new WalletBad();
b.balance = new BigDecimal("-999"); // compiles, and the object cannot stop it
new PaymentServiceBad().pay(b, BigDecimal.ONE);
assert b.balance.signum() < 0;
}
}
insufficient balance: 379.50 < 1000
history is read-only
balance 379.50, history [credit 500.00, debit 120.50]
What changed:
- Methods are named by intent (
credit,debit), not by field (setBalance). Each one checks its rule and updates state and history together. - Getters do not leak mutable internals.
history()returns a read-only view. Returning the list itself would let callers clear it. - Invalid states are impossible from outside. No caller, present or future, can make the balance negative.
Defaults that pay off in every round: private fields, final where possible, no setters unless a real use case needs one, intent-named methods that validate, and read-only views or copies of collections.
Go deeperTell, don't ask
"Ask" code pulls data out of an object and decides outside it: if (w.balance().compareTo(x) >= 0) w.setBalance(...). "Tell" code asks the object to do the work: w.debit(x). Tell-style keeps the decision next to the data, so the rule exists once. When you see a chain of getters followed by a decision, consider moving that decision into the object that owns the data.
1.4Abstraction: program to an interface
OrderService.placeOrder contains new RazorpayGateway().charge(...). What does this cost when the business adds a second payment provider, and when you write a unit test?
Reveal the answer
Adding a provider means editing OrderService and adding a branch. A unit test cannot run without calling the real provider, because the service builds it internally. The fix: OrderService depends on a PaymentGateway interface and receives an implementation through its constructor.
An interface states what a component does; a class decides how. Code that depends on interfaces can work with any implementation: the real provider, a second provider, or a fake in tests.
interface PaymentGateway {
PaymentResult charge(String orderId, BigDecimal amount);
}
record PaymentResult(boolean success, String reference) {}
class RazorpayGateway implements PaymentGateway {
public PaymentResult charge(String orderId, BigDecimal amount) {
// call the provider's API here
return new PaymentResult(true, "rzp_" + orderId);
}
}
class StripeGateway implements PaymentGateway {
public PaymentResult charge(String orderId, BigDecimal amount) {
return new PaymentResult(true, "pi_" + orderId);
}
}
class OrderService {
private final PaymentGateway gateway; // depends on the contract, not on a vendor
OrderService(PaymentGateway gateway) { // the caller (or Spring) decides which one
this.gateway = gateway;
}
String placeOrder(String orderId, BigDecimal amount) {
PaymentResult r = gateway.charge(orderId, amount);
if (!r.success()) throw new IllegalStateException("payment failed for " + orderId);
return r.reference();
}
}
class FakeGateway implements PaymentGateway {
final List<String> charged = new ArrayList<>();
public PaymentResult charge(String orderId, BigDecimal amount) {
charged.add(orderId + ":" + amount);
return new PaymentResult(true, "fake_" + orderId);
}
}
// in a unit test: no network, and you can assert what was charged
// FakeGateway fake = new FakeGateway();
// new OrderService(fake).placeOrder("o1", new BigDecimal("99.00"));
// assertEquals(List.of("o1:99.00"), fake.charged);
Passing the dependency in through the constructor is dependency injection. Spring does it for you with @Service classes and constructor parameters, which is why Spring code is easy to test when it depends on interfaces.
Interface or abstract class?
| Interface | Abstract class | |
|---|---|---|
| Purpose | A capability or contract | A partial implementation for closely related types |
| State | No instance fields (constants only) | Fields, constructors |
| Code | default and static methods since Java 8 |
Any methods, including final template methods |
| How many per class | Many | One parent only |
| Use when | Unrelated classes share a role (Notifier, PricingStrategy) |
Subtypes share real state and steps (AbstractExporter with a fixed export() skeleton) |
Default to interfaces. Add an abstract class beneath an interface only when several implementations repeat the same fields and code.
Do not create an interface for every class "just in case". An interface earns its place when there are two implementations today, a clear second one coming, or a need to fake it in tests.
1.5Inheritance vs composition
ReportExporter has subclasses CsvExporter and PdfExporter. Now each format needs optional encryption and optional compression. How many classes does an inheritance-only design need, and what is the alternative?
Reveal the answer
One class per combination: 2 formats x (encrypted, compressed, both) = 6 subclasses on top of the 2 formats, and a new option doubles the count again. The alternative is composition: Encrypting and Compressing wrappers that hold any exporter and add their step. Four classes cover every combination, chosen at runtime. That wrapper is the decorator pattern (Module 5).
Inheritance (extends) says "is a" and reuses the parent's code. Composition says "has a": an object holds other objects and delegates work to them. Inheritance is the tighter of the two, and the coupling shows up in three ways:
- Fragile base class. A subclass depends on how the parent is implemented, not just on what it promises. Parent changes can silently break it.
- Combination explosion. Independent variations multiply subclasses, as in the exporter example.
- Single slot. A Java class has one parent, so using it for code reuse spends it.
The classic proof of the fragile base class comes from Effective Java. A set that counts additions overrides both add and addAll:
// Count how many elements were ever added (Effective Java, Item 18)
class InstrumentedHashSet<E> extends HashSet<E> {
int addCount = 0;
@Override public boolean add(E e) {
addCount++;
return super.add(e);
}
@Override public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c); // HashSet.addAll calls add() for each element: counted twice
}
}
HashSet.addAll happens to call add for each element, an implementation detail the subclass cannot see. The composed version holds a Set and forwards to it, so the inner set's internal calls never reach our counter:
class InstrumentedSet<E> {
private final Set<E> inner; // has-a: any Set implementation works
private int addCount = 0;
InstrumentedSet(Set<E> inner) { this.inner = inner; }
boolean add(E e) {
addCount++;
return inner.add(e);
}
boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return inner.addAll(c); // inner calls its own add(), not ours: counted once
}
int addCount() { return addCount; }
boolean contains(Object o) { return inner.contains(o); }
}
Code that ranComposition.java: the rest of the file, including the main method behind the output below
class Demo {
public static void main(String[] args) {
InstrumentedHashSet<String> a = new InstrumentedHashSet<>();
a.addAll(List.of("x", "y", "z"));
InstrumentedSet<String> b = new InstrumentedSet<>(new HashSet<>());
b.addAll(List.of("x", "y", "z"));
System.out.println("inheritance: addCount = " + a.addCount + " (expected 3)");
System.out.println("composition: addCount = " + b.addCount());
assert a.addCount == 6 && b.addCount() == 3;
}
}
inheritance: addCount = 6 (expected 3)
composition: addCount = 3
When inheritance is the right tool:
- A true is-a relationship where every subclass can stand in for the parent everywhere (Module 3, Liskov).
- A parent designed for extension: documented hooks, a fixed skeleton (template method, Module 6), usually
abstract. - Types that share an interface and real state, such as
abstract class AccountwithSavingsAccountandCurrentAccount.
Ask "is every B always an A, in every place an A is used?" If yes, inheritance is fine. If you mainly want to reuse some code, compose instead.
1.6Polymorphism replaces conditionals
The fee calculator below switches on vehicle type, and so do the spot finder and the report. What happens when electric cars arrive with their own rate and spot rules?
Reveal the answer
Every switch on vehicle type must change, in every class that has one. Miss one and you get a runtime IllegalArgumentException or, worse, a silent wrong default. With polymorphism, ElectricCar is one new class that answers hourlyRate() and spotSize() itself.
enum VehicleType { CAR, BIKE, TRUCK }
class FeeCalculatorBad {
BigDecimal hourlyRate(VehicleType type) {
switch (type) { // the same switch also lives in SpotFinder and Report
case CAR: return new BigDecimal("40");
case BIKE: return new BigDecimal("20");
case TRUCK: return new BigDecimal("100");
default: throw new IllegalArgumentException("unknown type " + type);
}
}
}
Polymorphism means one call, many behaviors: vehicle.hourlyRate() runs the method of the object's runtime class (dynamic dispatch). Behavior that varies by type moves into the types, and callers stop caring which type they have.
interface Vehicle {
BigDecimal hourlyRate();
SpotSize spotSize();
}
enum SpotSize { SMALL, MEDIUM, LARGE }
class Car implements Vehicle {
public BigDecimal hourlyRate() { return new BigDecimal("40"); }
public SpotSize spotSize() { return SpotSize.MEDIUM; }
}
class Bike implements Vehicle {
public BigDecimal hourlyRate() { return new BigDecimal("20"); }
public SpotSize spotSize() { return SpotSize.SMALL; }
}
// a new type is a new class; no existing switch changes
class ElectricCar implements Vehicle {
public BigDecimal hourlyRate() { return new BigDecimal("30"); }
public SpotSize spotSize() { return SpotSize.MEDIUM; }
}
class FeeCalculator {
BigDecimal fee(Vehicle v, long hours) {
return v.hourlyRate().multiply(BigDecimal.valueOf(hours)); // dynamic dispatch picks the right rate
}
}
Polymorphism is not the only fix. When the varying behavior is a policy that changes independently of the type (weekend pricing, festival discounts), put it in a strategy object instead (Module 6). Rule of thumb: what a thing is belongs in its class; how the business treats it belongs in a strategy.
Overriding vs overloading
Overriding (same signature in a subclass) is resolved at runtime by the object's class. Overloading (same name, different parameter types) is resolved at compile time by the declared type of the argument. This catches people in interviews:
class Printer {
String describe(Object o) { return "object"; }
String describe(String s) { return "string"; }
}
Printer p = new Printer();
Object o = "hello"; // static type Object, runtime type String
System.out.println(p.describe(o)); // prints "object": overloads are chosen at compile time
System.out.println(p.describe("hello")); // prints "string"
Always put @Override on overriding methods. If the signature does not actually override (a typo, a different parameter type), the compiler then refuses it instead of quietly creating an overload.
Go deeperClosed sets of types: sealed interfaces
When the set of types is closed and owned by you (a payment result is Success, Declined, or Pending), Java 17's sealed interfaces with records model it well, and Java 21's pattern-matching switch checks that every case is handled. That is the opposite trade-off: adding an operation is easy (one new switch), adding a type touches every switch, but the compiler finds them all. Use polymorphism when new types are the likely change, and a sealed hierarchy with exhaustive switches when new operations are.
1.7Value objects and immutability
Why is double wrong for money? And what goes wrong if a mutable object is used as a HashMap key and then changed?
Reveal the answer
double is binary floating point, so most decimal fractions are approximations: 0.1 + 0.2 prints 0.30000000000000004, and errors accumulate across many operations. A mutated key keeps sitting in the bucket chosen by its old hash code, so get with the same object returns null and the entry is effectively lost.
A value object is defined entirely by its values: two Money objects with 10.00 INR are interchangeable. Good value objects are:
- Immutable: no setters; operations return new objects. They are safe to share, cache, use as map keys, and pass between threads.
- Valid at construction: the constructor rejects bad input, so an invalid instance can never exist.
- Equal by value:
equalsandhashCodeare based on the fields. A Javarecordgenerates both, plus accessors.
record Money(BigDecimal amount, Currency currency) {
Money { // compact constructor: validate and normalize once
Objects.requireNonNull(amount);
Objects.requireNonNull(currency);
// INR has 2 fraction digits; 10.005 is rejected instead of silently rounded
amount = amount.setScale(currency.getDefaultFractionDigits(), RoundingMode.UNNECESSARY);
}
static Money of(String amount, String currency) {
return new Money(new BigDecimal(amount), Currency.getInstance(currency));
}
Money plus(Money other) {
requireSameCurrency(other);
return new Money(amount.add(other.amount), currency); // a new object; this one never changes
}
Money minus(Money other) {
requireSameCurrency(other);
return new Money(amount.subtract(other.amount), currency);
}
boolean isNegative() { return amount.signum() < 0; }
private void requireSameCurrency(Money other) {
if (!currency.equals(other.currency)) throw new IllegalArgumentException("currency mismatch");
}
}
Money a = Money.of("10.5", "INR"); // stored as 10.50
Money b = Money.of("10.50", "INR");
System.out.println(a.equals(b) + " " + (a.hashCode() == b.hashCode())); // true true
System.out.println(new BigDecimal("10.5").equals(new BigDecimal("10.50"))); // false: scale differs
System.out.println(0.1 + 0.2); // 0.30000000000000004
Code that ranValueObjects.java: the rest of the file, including the main method behind the output below
class MutablePoint {
int x, y;
MutablePoint(int x, int y) { this.x = x; this.y = y; }
@Override public boolean equals(Object o) {
return o instanceof MutablePoint p && p.x == x && p.y == y;
}
@Override public int hashCode() { return Objects.hash(x, y); }
}
class Demo {
public static void main(String[] args) {
// ... (code shown above) ...
assert a.equals(b);
try { Money.of("10.005", "INR"); throw new AssertionError(); } catch (ArithmeticException e) { System.out.println("10.005 INR rejected"); }
System.out.println(a.plus(b) + " " + a.minus(b).minus(b).isNegative());
Map<MutablePoint, String> owners = new HashMap<>();
MutablePoint p = new MutablePoint(1, 2);
owners.put(p, "alice");
p.x = 5; // mutate a key after insertion
System.out.println(owners.get(p)); // null: the entry sits in the bucket for the old hash
System.out.println(owners.containsKey(new MutablePoint(1, 2))); // false: equals now fails
assert owners.get(p) == null;
}
}
true true
false
0.30000000000000004
10.005 INR rejected
Money[amount=21.00, currency=INR] true
null
false
Two details in that code matter in fintech interviews:
BigDecimal.equalscompares scale: 10.5 and 10.50 are not equal, butcompareToreturns 0. Normalizing the scale in the constructor makes record equality behave. Many payment systems avoid the issue by storing amounts as alongcount of minor units (paise or cents).- Rounding is a business decision.
RoundingMode.UNNECESSARYmakes an impossible amount fail loudly. When rounding is needed (splitting 100 into 3), choose the mode deliberately and decide where the leftover paisa goes (Problem 7).
And the reason keys must be immutable:
class MutablePoint {
int x, y;
MutablePoint(int x, int y) { this.x = x; this.y = y; }
@Override public boolean equals(Object o) {
return o instanceof MutablePoint p && p.x == x && p.y == y;
}
@Override public int hashCode() { return Objects.hash(x, y); }
}
Map<MutablePoint, String> owners = new HashMap<>();
MutablePoint p = new MutablePoint(1, 2);
owners.put(p, "alice");
p.x = 5; // mutate a key after insertion
System.out.println(owners.get(p)); // null: the entry sits in the bucket for the old hash
System.out.println(owners.containsKey(new MutablePoint(1, 2))); // false: equals now fails
The equals and hashCode contract: objects that are equal must have equal hash codes. Override both or neither, base them on the same immutable fields, and prefer records for value objects.
1.8Common mistakes
Common mistakes
- Getters and setters for every field. The object has no rules, so the rules end up duplicated in services.
- A god class (
ParkingLotManager,BookingServicewith 40 methods) that knows every entity's data and does all the work. - Inheritance for code reuse, leading to fragile parents and subclass explosions.
doublefor money, or a mutableMoneyclass.- Returning internal collections from getters, so callers can change an object behind its back.
instanceofchains or type switches spread across services instead of polymorphism or a strategy.- Missing
@Override, so a typo creates an overload that never runs. - An interface for every class, including ones with one implementation and no test need.
1.9Self-check
- What is an invariant? Give one for a
Seatin a movie booking system.Reveal
A rule that must always hold for an object. For a seat: "a seat that is BOOKED has exactly one booking ID", or "a seat cannot move from AVAILABLE to BOOKED without being HELD".
- Why does returning
Collections.unmodifiableList(items)protect a class better than returningitems?Reveal
Callers get a read-only view, so they cannot add, remove, or clear elements behind the object's back. Changes still go through methods that enforce the rules.
- Your
OrderServicebuilds its ownEmailSender. Why does that hurt testing, and what is the fix?Reveal
A test cannot replace the sender, so it either sends real email or cannot run. Depend on a
Notifierinterface passed into the constructor, and pass a fake in tests. - Give a case where inheritance is the right choice.
Reveal
A real is-a hierarchy with substitutable subtypes and shared state, such as
abstract class AccountwithSavingsAccountandCurrentAccount, or a base class with a fixed algorithm skeleton and documented hooks (template method). new BigDecimal("2.0").equals(new BigDecimal("2.00"))returns what, and why does it matter?Reveal
false, becauseequalsincludes scale. Amounts that are numerically equal can fail equality and map lookups. Normalize scale or compare withcompareTo, or store minor units as along.
Mastery checklist
Tick an item only when it is true with your notes closed.
Module 2: Relationships and UML
A class diagram is how you think out loud in an LLD round. This module teaches just enough UML to draw one quickly and correctly: the class box, the six relationships and how each becomes Java, multiplicities, and the two dynamic diagrams worth drawing, sequence and state.
- An arrow with a hollow triangle at one end. Solid line vs dashed line: what is the difference?
Reveal
Solid with a hollow triangle is inheritance (
extends). Dashed with a hollow triangle is realization (implements). The triangle always points at the parent or interface. - Order and OrderLine, or Team and Player: which pair is composition?
Reveal
Order and OrderLine. A line is created by its order, belongs to exactly one order, and dies with it. A player exists on their own and can join another team, so Team and Player is aggregation (or plain association).
- On a line between Customer and Order, where do you write
0..*to say "a customer has any number of orders"?Reveal
Next to Order, at the far end from Customer. You read multiplicity from one class across the line to the number written at the other class.
OrderService.place(Cart cart)uses the cart only inside that method. What relationship is that?Reveal
A dependency: a dashed arrow from OrderService to Cart. Association would mean OrderService keeps a Cart in a field.
- Which three diagrams are worth drawing in a 45-minute LLD round?
Reveal
A class diagram (always), a sequence diagram for the trickiest flow, and a state diagram when an entity has a lifecycle (order, booking, elevator).
2.1How much UML you need
You have 45 minutes for "design a movie ticket booking system". Which diagrams would you draw, and which would you skip?
Reveal the answer
Draw a class diagram with the core entities and their relationships, a state diagram for the seat (available, held, booked), and a sequence diagram for "hold seats, then pay, then confirm" if there is time. Skip use case diagrams, activity diagrams, package diagrams, and deployment diagrams: they cost minutes and the interviewer rarely asks.
Diagrams are a thinking tool first. They let you and the interviewer agree on the model before you write code that is expensive to change.
An architect sketches the floor plan before pouring concrete. The sketch is cheap to erase; the walls are not.
Every box takes time. Draw the classes the design argument needs and the relationships that carry meaning; leave out getters, setters, and trivial fields.
What to draw in each round:
| Round | Class diagram | Sequence diagram | State diagram |
|---|---|---|---|
| LLD discussion (45-60 min) | Always, 6-12 classes | One, for the flow with the most moving parts | When an entity has a lifecycle |
| Machine coding (90-120 min) | A rough sketch on paper or in comments, 5 minutes | Rarely | As a transition table in code |
| Design doc at work | Yes, for the core model | For cross-component flows | For anything with statuses |
The precision that matters is the precision that changes the design: which class owns which, how many of each, and which side knows about the other. Exact UML syntax for everything else is optional.
2.2Reading a class box
Write the class box for a ParkingSpot that knows its ID, size, and parked vehicle, and can check fit, occupy, and release.
Reveal the answer
Three compartments: the name ParkingSpot; fields - id: String, - size: SpotSize, - vehicle: Vehicle; methods + canFit(v: Vehicle): boolean, + occupy(v: Vehicle): void, + release(): void. Fields are private, the methods callers need are public.
visibility name: Type and methods as visibility name(params): Return.Conventions worth knowing:
- Stereotypes go above the name in double angle brackets:
<<interface>>,<<abstract>>,<<enum>>(formal UML prints each pair as one guillemet character). On a whiteboard, writing "interface" above the name is enough. - Abstract classes and methods are italic. On a whiteboard, write "abstract".
- Enums list their constants in the field compartment.
- Omit getters, setters, constructors, and
toString. Show the methods that carry the design's rules.
2.3The six relationships
Classify each pair: (a) SavingsAccount and Account; (b) Circle and Shape (an interface); (c) Order and OrderLine; (d) Team and Player; (e) Invoice holding a Customer in a field; (f) InvoicePrinter.print(Invoice).
Reveal the answer
(a) Inheritance. (b) Realization. (c) Composition. (d) Aggregation. (e) Association. (f) Dependency. The figure below draws each one.
| Relationship | Meaning | Lifetime | In Java |
|---|---|---|---|
| Inheritance | is a | Subtype is permanently tied to its parent | extends |
| Realization | fulfils a contract | Same | implements |
| Composition | owns, exclusively | Part is created by the whole and dies with it | private final field holding parts the whole creates |
| Aggregation | has, but does not own | Part exists independently, may be shared | Field holding objects passed in |
| Association | knows | Long-lived reference | A field |
| Dependency | uses | Only during a call | Parameter, local variable, return type, static call |
Two distinctions trip people up:
- Association vs dependency is about time. If the reference is stored in a field, it is an association. If it only appears in a method signature or body, it is a dependency.
- Composition vs aggregation is about ownership. If the whole creates the part, nobody else holds it, and deleting the whole deletes the part, it is composition. If parts are passed in and can outlive or be shared, it is aggregation.
The UML 2.5 specification itself says the precise meaning of aggregation varies by application area. Many teams draw only association and composition. In an interview, use the hollow diamond when "has but does not own" is the point you are making, and plain association otherwise.
2.4Multiplicity and navigability
Should Order hold its list of Payment objects, should Payment hold its Order, or both? What does "both" cost?
Reveal the answer
It depends on who needs to reach whom. Usually a payment records the orderId it pays for, and the order looks payments up through a repository when needed. Both directions as object references means every change must update both sides consistently, or the model contradicts itself. Prefer one direction, and reference by ID across independent entities.
Multiplicity says how many instances take part, written at the far end of the line:
| Notation | Meaning |
|---|---|
1 |
exactly one |
0..1 |
optional, at most one |
* or 0..* |
any number, including zero |
1..* |
at least one |
2..4 |
a range |
Navigability is shown with an open arrowhead: the arrow points to the class that is known. Customer --> Order means a customer can reach its orders; an order does not hold its customer object. Choose the direction from the use cases: "list a customer's orders" needs Customer -> Order or a repository query.
Two modeling rules that save trouble:
- Prefer one-way references. Two-way references must be kept in sync by every method that changes them.
- Reference by ID across independent entities. A
PaymentstoresorderId, not anOrderobject. Inside one whole (an order and its lines), hold objects. This is the same boundary a database or a microservice split would draw later.
2.5From diagram to code
How does composition look different from aggregation in Java, given that both are just fields?
Reveal the answer
Composition: the whole creates the parts itself (lines.add(new OrderLine(...))), keeps them private, and never hands out the mutable collection. Aggregation: the parts are passed in from outside (team.add(player)), and the same object can be held by other wholes.
Every relationship becomes a specific code shape. Interviewers often check that your diagram and your code agree.
// Composition: the order creates and owns its lines; a line never exists without its order
class Order {
private final String id;
private final List<OrderLine> lines = new ArrayList<>();
Order(String id) { this.id = id; }
void addLine(String sku, int qty) {
lines.add(new OrderLine(sku, qty)); // created here, never handed in from outside
}
List<OrderLine> lines() { return Collections.unmodifiableList(lines); }
}
record OrderLine(String sku, int qty) {}
// Aggregation: the team holds players that exist on their own and may join other teams
class Team {
private final List<Player> players = new ArrayList<>();
void add(Player p) { players.add(p); } // received, not created
int size() { return players.size(); }
}
class Player {
final String name;
Player(String name) { this.name = name; }
}
// Association: a long-lived reference held in a field
class Invoice {
private final Customer billedTo; // Invoice --> Customer
Invoice(Customer billedTo) { this.billedTo = billedTo; }
String customerName() { return billedTo.name(); }
}
record Customer(String name) {}
// Dependency: used briefly, as a parameter or local variable, then forgotten
class InvoicePrinter {
String print(Invoice invoice) { // InvoicePrinter ..> Invoice
return "Invoice for " + invoice.customerName();
}
}
// Inheritance (generalization) and realization (implements)
interface Shape { double area(); } // Circle ..|> Shape (realization)
abstract class Account { // SavingsAccount --|> Account (generalization)
protected long balancePaise;
}
class SavingsAccount extends Account {}
class Circle implements Shape {
private final double r;
Circle(double r) { this.r = r; }
public double area() { return Math.PI * r * r; }
}
When you draw a filled diamond, say why: "The booking owns its booked seats records, so they go when the booking is deleted." Saying the lifetime rule out loud shows the symbol was a decision, not decoration.
2.6Sequence diagrams
Sketch the calls when a car enters a parking lot and gets a ticket. Which object asks which?
Reveal the answer
The gate calls ParkingLot.park(vehicle). The lot asks a SpotAllocator for a free spot that fits, occupies that spot, creates a ticket with the entry time, saves it, and returns it to the gate. The figure below steps through it.
A sequence diagram shows one scenario over time: participants across the top, time flowing down, one arrow per call.
Conventions:
- Solid arrow with a filled head: a call. Dashed arrow: a return value.
- Self-call: an arrow that loops back to the same lifeline.
- Notes or frames mark loops (
loop), options (opt), and alternatives (alt). A note is enough on a whiteboard. - Keep it to one scenario and 5-10 messages. A sequence diagram that shows every branch is unreadable.
A sequence diagram is the quickest way to check your class diagram: every arrow needs a method on the receiving class, and every object on top needs a way to reach the next one.
2.7State diagrams
List the states of an e-commerce order and one transition that must be rejected.
Reveal the answer
Created, Paid, Shipped, Delivered, Cancelled, Returned. Reject cancelling a shipped order (in this model it must be delivered and then returned), and reject shipping an order that was never paid.
A state diagram shows the states one object can be in and the events that move it between them. Write transitions as event [guard] / action: the event triggers, the guard must be true, the action runs.
The diagram is also the spec for the code. Module 7 turns diagrams like this into an enum with a transition table, or into the State pattern when each state carries real behavior.
Draw a state diagram whenever an entity has a status field. Most LLD bugs in vending machines, bookings, elevators, and orders are transitions nobody drew.
2.8Common mistakes
Common mistakes
- Drawing every getter and setter, so the boxes hide the design.
- Triangles pointing the wrong way. The triangle points at the parent or the interface.
- Composition everywhere. Filled diamonds claim ownership and shared lifetime; use them only when that is true.
- Missing multiplicities on the relationships that matter (one ticket per vehicle, many seats per show).
- Two-way object references by default, which then drift out of sync.
- A diagram that disagrees with the code, such as an association in the picture and a method parameter in the code.
- Spending 15 minutes on notation in a round that rewards the model.
2.9Self-check
- Draw (in words) the relationship between
LibraryandBookif a book can move between library branches.Reveal
Aggregation or association from
LibrarytoBookwith multiplicity1to0..*: the library holds books, but books exist independently and can move. Not composition, because a book outlives a branch. - What does a dashed line with an open arrowhead mean?
Reveal
A dependency: the source uses the target temporarily (parameter, local variable, return type, or a static call).
- Why reference by ID across independent entities?
Reveal
It keeps entities loosely coupled, avoids loading big object graphs, avoids two-way sync bugs, and matches how data is stored and how services would be split later.
- Your class diagram shows
Elevatorwith aList<Request>field. Is that association, aggregation, or composition, and what would you ask to decide?Reveal
Ask whether requests exist outside the elevator. If the dispatcher creates a request and could move it to another elevator, it is aggregation (or association). If the elevator creates its own internal stop list, composition.
- What does the transition label
pay [amount == total] / issueReceiptmean?Reveal
On the
payevent, if the guardamount == totalholds, take the transition and run the actionissueReceipt.
Mastery checklist
Tick an item only when it is true with your notes closed.
Module 3: SOLID and design principles
Principles are how you justify a design in one sentence. This module teaches the two forces underneath all of them, cohesion and coupling, then SOLID with one before-and-after example each, then the everyday rules (DRY, KISS, YAGNI, Law of Demeter) and when following a principle too hard makes code worse.
- A class
InvoiceServicecomputes GST, renders a PDF, and saves to the database. Which principle does it strain, and why does it matter?Reveal
Single responsibility. Three groups (finance, design, platform) each have a reason to change it, so their changes collide in one file and one test suite.
- Adding a new discount type means editing a long
if-elseinDiscountService. Which principle is that, and what is the usual fix?Reveal
Open/closed. Put each discount behind a
DiscountPolicyinterface and pick the policy from a map built at startup, so a new type is a new class plus one line of wiring. FixedDepositAccount extends Accountbut itswithdrawthrows. Which principle breaks?Reveal
Liskov substitution. Code written against
Accountexpectswithdrawto work and breaks when handed a fixed deposit.- What is the difference between dependency inversion and dependency injection?
Reveal
Inversion is a design rule: high-level policy owns the interface, and details implement it. Injection is a technique: pass dependencies in (usually through the constructor) instead of creating them. Spring is a container that does the injection for you.
- Is copying the same three lines in two places always a DRY violation?
Reveal
No. DRY is about one piece of knowledge having one home. Two pieces of code that look alike but change for different reasons should stay separate.
3.1Cohesion and coupling
Without looking anything up: what do "high cohesion" and "low coupling" mean, and why do people always say them together?
Reveal the answer
Cohesion is how strongly the things inside one class belong together; high cohesion means everything in it serves one purpose. Coupling is how much one class depends on others; low coupling means few, narrow connections. Together they make a change local: the code that must change sits in one place, and few other places notice.
Group what changes together (high cohesion) and separate what changes for different reasons (low coupling). Every principle below is a specific way to do one of the two.
A well-run kitchen has a pastry station, a grill station, and a dish station. A new dessert changes one station. A kitchen where every cook does a bit of everything makes every menu change a meeting.
Splitting too far creates classes so small that following one feature means opening ten files. Stop when each class has one clear reason to change.
How to measure coupling in practice: count how many classes a typical change touches, and how many imports and constructor parameters a class has. More than five or six collaborators in a constructor is usually a cohesion problem in that class.
3.2Single responsibility
List who would ask for changes to the InvoiceService below, and propose a split.
Reveal the answer
Finance asks for tax rule changes, design asks for layout changes, and the platform team asks for storage changes. Split into TaxCalculator, InvoiceRenderer, and InvoiceRepository, and keep a thin InvoiceService that coordinates them.
A class should have one reason to change. Robert Martin's sharper version: a module should be responsible to one actor, one group of people who request its changes. "One responsibility" does not mean "one method"; it means one owner.
class InvoiceService {
private final Map<String, String> db = new HashMap<>();
BigDecimal total(List<LineItem> items) { // changes when finance changes tax rules
BigDecimal sub = items.stream().map(LineItem::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
return sub.multiply(new BigDecimal("1.18")).setScale(2, RoundingMode.HALF_UP);
}
String render(String id, List<LineItem> items) { // changes when the layout changes
return "INVOICE " + id + "\n" + items.size() + " items, total " + total(items);
}
void save(String id, List<LineItem> items) { // changes when storage changes
db.put(id, render(id, items));
}
}
class TaxCalculator { // owner: finance rules
private final BigDecimal rate;
TaxCalculator(BigDecimal rate) { this.rate = rate; }
BigDecimal total(List<LineItem> items) {
BigDecimal sub = items.stream().map(LineItem::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
return sub.add(sub.multiply(rate)).setScale(2, RoundingMode.HALF_UP);
}
}
class InvoiceRenderer { // owner: presentation
String render(String id, List<LineItem> items, BigDecimal total) {
return "INVOICE " + id + "\n" + items.size() + " items, total " + total;
}
}
interface InvoiceRepository { // owner: storage
void save(String id, String document);
}
class InvoiceService2 { // coordinates; holds no rules of its own
private final TaxCalculator tax;
private final InvoiceRenderer renderer;
private final InvoiceRepository repo;
InvoiceService2(TaxCalculator tax, InvoiceRenderer renderer, InvoiceRepository repo) {
this.tax = tax; this.renderer = renderer; this.repo = repo;
}
String issue(String id, List<LineItem> items) {
String doc = renderer.render(id, items, tax.total(items));
repo.save(id, doc);
return doc;
}
}
Code that ranSrp.java: the rest of the file, including the main method behind the output below
record LineItem(String name, BigDecimal price, int qty) {
BigDecimal amount() { return price.multiply(BigDecimal.valueOf(qty)); }
}
// ... (code shown above) ...
class Demo {
public static void main(String[] args) {
List<LineItem> items = List.of(new LineItem("pen", new BigDecimal("10.00"), 3), new LineItem("book", new BigDecimal("250.00"), 1));
List<String> saved = new ArrayList<>();
String doc = new InvoiceService2(new TaxCalculator(new BigDecimal("0.18")), new InvoiceRenderer(), (id, d) -> saved.add(id)).issue("INV-7", items);
System.out.println(doc);
assert doc.endsWith("330.40") && saved.equals(List.of("INV-7"));
InvoiceService bad = new InvoiceService();
assert bad.total(items).equals(new BigDecimal("330.40"));
bad.save("x", items);
}
}
INVOICE INV-7
2 items, total 330.40
The test: describe the class in one sentence without "and". "Calculates invoice totals with tax" passes. "Calculates totals and renders PDFs and saves invoices" fails.
3.3Open/closed
Marketing adds a STUDENT customer type with 15% off. With the if-else below, which files change and which tests rerun? Sketch a design where nothing existing changes.
Reveal the answer
DiscountServiceBad changes, and every test of it should rerun because any branch might break. Instead, give each discount its own class behind a DiscountPolicy interface, and have the service look the policy up in a map built at startup. STUDENT is then one line of wiring (or one new class if the rule is new).
Software entities should be open for extension, closed for modification. Adding behavior should mean adding code, not editing code that already works.
class DiscountServiceBad {
BigDecimal discount(String customerType, BigDecimal amount) {
if (customerType.equals("REGULAR")) return BigDecimal.ZERO;
if (customerType.equals("PREMIUM")) return amount.multiply(new BigDecimal("0.10"));
if (customerType.equals("EMPLOYEE")) return amount.multiply(new BigDecimal("0.30"));
// every new customer type edits this method, and its tests
throw new IllegalArgumentException(customerType);
}
}
interface DiscountPolicy {
BigDecimal discount(BigDecimal amount);
}
class NoDiscount implements DiscountPolicy {
public BigDecimal discount(BigDecimal amount) { return BigDecimal.ZERO; }
}
class PercentOff implements DiscountPolicy {
private final BigDecimal percent;
PercentOff(String percent) { this.percent = new BigDecimal(percent); }
public BigDecimal discount(BigDecimal amount) {
return amount.multiply(percent).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
}
}
class DiscountService {
private final Map<String, DiscountPolicy> policies; // built once, at startup
DiscountService(Map<String, DiscountPolicy> policies) { this.policies = policies; }
BigDecimal discount(String customerType, BigDecimal amount) {
DiscountPolicy p = policies.get(customerType);
if (p == null) throw new IllegalArgumentException("no policy for " + customerType);
return p.discount(amount);
}
}
DiscountService service = new DiscountService(Map.of(
"REGULAR", new NoDiscount(),
"PREMIUM", new PercentOff("10"),
"EMPLOYEE", new PercentOff("30"),
"STUDENT", new PercentOff("15"))); // new type: one line of wiring, no edits to the service
Code that ranOcp.java: the rest of the file, including the main method behind the output below
class Demo {
public static void main(String[] args) {
// ... (code shown above) ...
BigDecimal d = service.discount("STUDENT", new BigDecimal("200.00"));
System.out.println("student discount on 200.00 = " + d);
assert d.compareTo(new BigDecimal("30.00")) == 0;
assert new DiscountServiceBad().discount("PREMIUM", new BigDecimal("100")).compareTo(BigDecimal.TEN) == 0;
}
}
student discount on 200.00 = 30.00
The pattern behind this fix is strategy plus a factory (Module 6). The switch does not disappear; it moves to one place where the objects are created, which is where variation belongs.
OCP is about the changes you expect. You cannot close code against every change, and trying produces interfaces nobody implements twice. Apply it where a rule has already changed once or clearly will (pricing, discounts, payment methods, notification channels).
3.4Liskov substitution
BillPayer.payFrom(List<Account>) loops over accounts and withdraws from the first with enough balance. Someone adds FixedDepositAccount extends Account whose withdraw throws. What happens, and is the bug in BillPayer or in the hierarchy?
Reveal the answer
The first time a fixed deposit has enough balance, payFrom throws. The bug is in the hierarchy: FixedDepositAccount claims to be an Account but cannot keep Account's promise that withdraw works when the balance allows. Withdrawal is a capability only some accounts have, so it belongs in a separate interface.
Objects of a subtype must be usable anywhere the parent type is expected, without surprises (Barbara Liskov, 1987). In practice a subclass must not:
- Demand more from callers (stronger preconditions, such as "amount must be under 1,000").
- Promise less (weaker postconditions, such as "balance may not decrease").
- Throw exceptions the parent's contract does not allow, or break the parent's invariants.
class Account {
protected long balance;
Account(long balance) { this.balance = balance; }
void withdraw(long amount) {
if (amount > balance) throw new IllegalStateException("insufficient funds");
balance -= amount;
}
long balance() { return balance; }
}
class FixedDepositAccount extends Account {
FixedDepositAccount(long balance) { super(balance); }
@Override void withdraw(long amount) {
throw new UnsupportedOperationException("no withdrawals before maturity"); // breaks the parent's promise
}
}
class BillPayer {
void payFrom(List<Account> accounts, long amount) {
for (Account a : accounts) {
if (a.balance() >= amount) { a.withdraw(amount); return; } // explodes on a fixed deposit
}
}
}
interface Withdrawable { // a capability, not every account has it
long balance();
void withdraw(long amount);
}
abstract class BankAccount {
protected long balance;
BankAccount(long balance) { this.balance = balance; }
public long balance() { return balance; }
}
class SavingsAccount extends BankAccount implements Withdrawable {
SavingsAccount(long balance) { super(balance); }
public void withdraw(long amount) {
if (amount > balance) throw new IllegalStateException("insufficient funds");
balance -= amount;
}
}
class FixedDeposit extends BankAccount { // simply has no withdraw method
FixedDeposit(long balance) { super(balance); }
}
class BillPayer2 {
void payFrom(List<? extends Withdrawable> accounts, long amount) { // the type says what it needs
for (Withdrawable a : accounts) {
if (a.balance() >= amount) { a.withdraw(amount); return; }
}
throw new IllegalStateException("no account can pay " + amount);
}
}
Code that ranLsp.java: the rest of the file, including the main method behind the output below
class Demo {
public static void main(String[] args) {
try {
new BillPayer().payFrom(List.of(new FixedDepositAccount(10_000), new Account(5_000)), 1_000);
throw new AssertionError();
} catch (UnsupportedOperationException e) {
System.out.println("bad design: " + e.getMessage());
}
SavingsAccount s = new SavingsAccount(5_000);
new BillPayer2().payFrom(List.of(s), 1_000);
System.out.println("good design: savings balance " + s.balance());
assert s.balance() == 4_000;
}
}
bad design: no withdrawals before maturity
good design: savings balance 4000
The textbook example is Square extends Rectangle: setting a square's width must also change its height, which breaks code that sets width and height independently. The lesson is the same: "a square is a rectangle" is true in geometry and false for a mutable Rectangle class. Is-a must hold for behavior, not just for the English sentence.
Any override that throws UnsupportedOperationException is a Liskov smell. Say it in the interview and propose a role interface for the capability.
3.5Interface segregation
A PaymentProvider interface has charge, refund, createSubscription, and cancelSubscription. Cash on delivery supports only charge. What goes wrong?
Reveal the answer
Cash on delivery must implement three methods it cannot support, so it throws from them, which is a Liskov violation waiting to happen. Every client also depends on methods it never calls, so a change to subscriptions recompiles and retests refund code. Split into role interfaces: Chargeable, Refundable, SubscriptionCapable.
Clients should not be forced to depend on methods they do not use. Prefer several small role interfaces over one large one.
interface PaymentProvider {
String charge(String customer, long amountPaise);
void refund(String chargeId, long amountPaise);
String createSubscription(String customer, String plan);
void cancelSubscription(String subscriptionId);
}
class CashOnDelivery implements PaymentProvider {
public String charge(String customer, long amountPaise) { return "cod-" + customer; }
public void refund(String chargeId, long amountPaise) { throw new UnsupportedOperationException(); }
public String createSubscription(String c, String p) { throw new UnsupportedOperationException(); }
public void cancelSubscription(String s) { throw new UnsupportedOperationException(); }
}
interface Chargeable { String charge(String customer, long amountPaise); }
interface Refundable { void refund(String chargeId, long amountPaise); }
interface SubscriptionCapable {
String createSubscription(String customer, String plan);
void cancelSubscription(String subscriptionId);
}
class CardProvider implements Chargeable, Refundable, SubscriptionCapable {
public String charge(String customer, long amountPaise) { return "card-" + customer; }
public void refund(String chargeId, long amountPaise) { /* call the card network */ }
public String createSubscription(String customer, String plan) { return "sub-" + plan; }
public void cancelSubscription(String subscriptionId) { }
}
class CashOnDelivery2 implements Chargeable { // implements only what it can do
public String charge(String customer, long amountPaise) { return "cod-" + customer; }
}
class RefundService {
private final Refundable provider; // depends only on the role it uses
RefundService(Refundable provider) { this.provider = provider; }
void refund(String chargeId, long amount) { provider.refund(chargeId, amount); }
}
The bonus is visible in RefundService: it accepts a Refundable, so passing cash on delivery is now a compile error instead of a runtime exception.
3.6Dependency inversion and injection
OrderNotifier creates a SmtpEmailClient inside itself. List two problems, and redraw the dependency so the business rule does not depend on SMTP.
Reveal the answer
You cannot test OrderNotifier without sending email, and switching to SMS or a different provider means editing the business class. Redraw: OrderNotifier depends on a MessageSender interface that it owns; EmailSender and SmsSender implement it; the sender is passed into the constructor.
High-level modules should not depend on low-level modules; both should depend on abstractions. And the abstraction belongs to the high-level side: the business rule says what it needs (send(to, text)), and the vendor code adapts to it.
class SmtpEmailClient {
void sendMail(String to, String subject, String body) { /* SMTP details */ }
}
class OrderNotifierBad {
private final SmtpEmailClient smtp = new SmtpEmailClient(); // policy depends on a detail
void orderShipped(String email, String orderId) {
smtp.sendMail(email, "Shipped", "Order " + orderId + " is on its way");
}
}
// The high-level module owns the abstraction it needs...
interface MessageSender {
void send(String recipient, String text);
}
class OrderNotifier {
private final MessageSender sender;
OrderNotifier(MessageSender sender) { this.sender = sender; } // injected
void orderShipped(String recipient, String orderId) {
sender.send(recipient, "Order " + orderId + " is on its way");
}
}
// ...and the details implement it
class EmailSender implements MessageSender {
private final SmtpEmailClient smtp = new SmtpEmailClient();
public void send(String recipient, String text) { smtp.sendMail(recipient, "Update", text); }
}
class SmsSender implements MessageSender {
public void send(String recipient, String text) { /* call the SMS gateway */ }
}
Three related terms, often mixed up:
| Term | What it is |
|---|---|
| Dependency inversion (DIP) | A design rule: policy owns abstractions, details implement them |
| Dependency injection (DI) | A technique: pass dependencies in, usually through the constructor |
| Inversion of control (IoC) container | A framework that creates objects and injects dependencies, such as Spring |
Constructor injection is the default in Spring: a class with one constructor gets its parameters injected without any annotation, the fields can be final, and tests can pass fakes directly:
List<String> sent = new ArrayList<>();
OrderNotifier notifier = new OrderNotifier((to, text) -> sent.add(to + ": " + text)); // a lambda as a fake
notifier.orderShipped("asha@example.com", "o-42");
// sent == ["asha@example.com: Order o-42 is on its way"]
3.7DRY, KISS, YAGNI, and the Law of Demeter
shipping.rate(order) calls order.getCustomer().address().city(). What does ShippingCalculator now depend on, and what would you change?
Reveal the answer
It depends on Order, Customer, and Address and on how they are connected. Any restructuring (customers with several addresses, a separate shipping address on the order) breaks it. Ask the order for what you need: order.shippingCity(), so one place knows the path.
Four everyday rules, each with its trap:
| Rule | Means | Trap |
|---|---|---|
| DRY (don't repeat yourself) | Each piece of knowledge has one authoritative home | Merging code that only looks alike but changes for different reasons, creating coupling |
| KISS (keep it simple) | Choose the simplest design that meets today's requirements | Confusing simple with easy: a 300-line method is easy to write, not simple to read |
| YAGNI (you aren't gonna need it) | Do not build for imagined requirements | Ignoring requirements the interviewer clearly signaled ("we may add more vehicle types") |
| Law of Demeter | Talk only to your direct collaborators, not their internals | Adding pass-through methods for everything; fluent builders and streams are not violations |
record Address(String city, String pincode) {}
record Customer(String name, Address address) {}
class OrderBad {
private final Customer customer;
OrderBad(Customer customer) { this.customer = customer; }
Customer getCustomer() { return customer; }
}
class ShippingBad {
double rate(OrderBad order) {
// reaches through three objects: knows Order, Customer, and Address internals
return order.getCustomer().address().city().equals("Bengaluru") ? 40 : 80;
}
}
class Order {
private final Customer customer;
Order(Customer customer) { this.customer = customer; }
String shippingCity() { return customer.address().city(); } // one place knows the path
}
class Shipping {
double rate(Order order) {
return order.shippingCity().equals("Bengaluru") ? 40 : 80;
}
}
Principles are tools for arguing about change cost, not rules to satisfy. In an interview, name a principle when it explains a decision you made ("pricing is behind an interface because of open/closed"), not as a checklist recital.
3.8Common mistakes
Common mistakes
- Reciting SOLID without examples. Interviewers ask "show me where in your design".
- Splitting every class into one-method classes in the name of single responsibility.
- Interfaces for everything in the name of open/closed and dependency inversion, with one implementation each.
- Inheritance that throws
UnsupportedOperationException, a Liskov violation. - Fat interfaces that force empty or throwing implementations.
- Creating dependencies with
newinside business classes, which blocks testing and swapping. - DRY applied to look-alike code with different owners, producing a shared helper everyone is afraid to change.
- Getter chains that couple a class to the structure of objects it does not own.
3.9Self-check
- State the single responsibility principle in terms of actors, and give a test you can apply to a class.
Reveal
A class should answer to one actor, one group that requests its changes. Test: describe it in one sentence without "and", and list who would ask for changes; more than one group means split.
- Give three rules a subclass must follow to respect Liskov substitution.
Reveal
Do not strengthen preconditions, do not weaken postconditions, preserve the parent's invariants, and do not throw exceptions the parent's contract does not allow.
- In the dependency inversion principle, who owns the interface, and why does that matter?
Reveal
The high-level policy owns it. The interface then describes what the business needs, so swapping vendors means writing a new implementation, never changing the policy.
- Why is constructor injection preferred over field injection in Spring?
Reveal
Dependencies are explicit in the signature, fields can be
final, the object is never half-built, and tests can construct it with fakes without a container. - When is duplicated code acceptable?
Reveal
When the two copies represent different knowledge that changes for different reasons. Merging them would couple two owners. Duplicated knowledge, such as one tax rule written twice, is never acceptable.
Mastery checklist
Tick an item only when it is true with your notes closed.
Module 4: Creational patterns
Creational patterns answer three questions about making objects: which class to create, how to build a complex object so it is always valid, and how many instances should exist. This module covers the three you will use constantly (factory, builder, singleton), when dependency injection replaces them, and how to recognize the two you only need to name (abstract factory, prototype).
new EmailNotifier(),new SmsNotifier(), andnew PushNotifier()appear behind anif-elsein twelve places. What is the cost?Reveal
Twelve places know the concrete classes. A new channel means twelve edits, and construction details (credentials, retries) are repeated. A factory keeps that knowledge in one place.
- A constructor takes
(String, long, String, String, boolean, String). Name two problems.Reveal
Call sites are unreadable (
null, truemeans what?), and arguments of the same type are easy to swap without a compile error. A builder names each value. - Why is a plain lazy singleton (
if (instance == null) instance = new X()) unsafe with threads?Reveal
Two threads can both see
nullbefore either writes, so two instances are created. Fix it with an enum, the holder idiom, or double-checked locking withvolatile. - Spring beans are singletons by default. Do you need the singleton pattern in a Spring app?
Reveal
Rarely. The container creates one instance and injects it, which gives one instance without global static access and keeps it replaceable in tests.
- What does a static factory method like
Money.of("10", "INR")give you that a constructor cannot?Reveal
A descriptive name, the freedom to return a cached instance or a subtype, and the ability to validate or normalize input before choosing what to create.
4.1Why creation needs a pattern
List three decisions hidden in the line Notifier n = new SmsNotifier(twilioKey, 3, Duration.ofSeconds(2)); that a caller should probably not be making.
Reveal the answer
Which implementation to use (SMS, and which vendor), how it is configured (the key, three retries, a two-second timeout), and whether a new instance is needed at all or a shared one would do. Each is a reason for that line to change, and none of them is the caller's business.
new couples the caller to one concrete class and to its construction details. Creational patterns move that knowledge to one place.
A restaurant guest orders "the soup of the day"; the kitchen decides which soup and how to make it. If guests had to name the recipe and the stove setting, every menu change would need new guests.
Each pattern adds a class or two and some indirection. Use one when creation varies, is complex, or must be controlled; plain new is fine for simple value objects.
| Question | Pattern | Typical LLD use |
|---|---|---|
| Which class? | Factory | Vehicle by type, notifier by channel, split strategy by type |
| How to build a valid complex object? | Builder | Requests and configs with many optional fields |
| How many instances? | Singleton, or one instance injected by DI | Logger, ID generator, configuration |
| A matching family of objects? | Abstract factory | AWS vs in-memory infrastructure, UI themes |
| A copy of a configured object? | Prototype | Game levels, templates, copy constructors |
4.2Factory
Sketch how NotificationService.notify(channel, to, text) can send through email, SMS, or push without naming any of those classes, and how a new channel is added.
Reveal the answer
The service asks a factory for a Notifier by channel and calls send on it. The factory holds a map from channel to a supplier (constructor reference). A new channel is a new Notifier class plus one register line where the factory is wired. The service does not change.
A factory creates objects for callers so they depend only on an interface. Three forms, from simplest:
1. Simple factory: one method with the only switch on the type in the codebase. Fine when the set of types is small and owned by you.
class SimpleNotifierFactory {
static Notifier create(Channel channel) {
return switch (channel) { // the only switch on Channel in the codebase
case EMAIL -> new EmailNotifier();
case SMS -> new SmsNotifier();
case PUSH -> new PushNotifier();
};
}
}
2. Registry factory: a map from key to Supplier. Adding a type is registration, not an edit, and tests can register fakes. This is the form to use in machine coding rounds.
class NotifierFactory {
private final Map<Channel, Supplier<Notifier>> makers = new EnumMap<>(Channel.class);
NotifierFactory register(Channel channel, Supplier<Notifier> maker) {
makers.put(channel, maker);
return this;
}
Notifier create(Channel channel) {
Supplier<Notifier> maker = makers.get(channel);
if (maker == null) throw new IllegalArgumentException("no notifier for " + channel);
return maker.get();
}
}
class NotificationService {
private final NotifierFactory factory;
NotificationService(NotifierFactory factory) { this.factory = factory; }
String notify(Channel channel, String to, String text) {
return factory.create(channel).send(to, text); // never names a concrete class
}
}
NotifierFactory factory = new NotifierFactory()
.register(Channel.EMAIL, EmailNotifier::new)
.register(Channel.SMS, SmsNotifier::new)
.register(Channel.PUSH, PushNotifier::new);
NotificationService service = new NotificationService(factory);
Code that ranFactory.java: the rest of the file, including the main method behind the output below
interface Notifier {
String send(String to, String text);
}
class EmailNotifier implements Notifier {
public String send(String to, String text) { return "email to " + to; }
}
class SmsNotifier implements Notifier {
public String send(String to, String text) { return "sms to " + to; }
}
class PushNotifier implements Notifier {
public String send(String to, String text) { return "push to " + to; }
}
enum Channel { EMAIL, SMS, PUSH }
// ... (code shown above) ...
final class Temperature {
private final double celsius;
private Temperature(double celsius) { this.celsius = celsius; }
// named factory methods say what the double means; a constructor cannot
static Temperature ofCelsius(double c) { return new Temperature(c); }
static Temperature ofFahrenheit(double f) { return new Temperature((f - 32) * 5 / 9); }
double celsius() { return celsius; }
}
class Demo {
public static void main(String[] args) {
// ... (code shown above) ...
for (Channel c : List.of(Channel.EMAIL, Channel.PUSH)) System.out.println(service.notify(c, "u1", "hi"));
assert SimpleNotifierFactory.create(Channel.SMS) instanceof SmsNotifier;
assert Math.abs(Temperature.ofFahrenheit(212).celsius() - 100) < 1e-9;
}
}
email to u1
push to u1
3. Factory method (the original GoF form): a superclass declares abstract Notifier createNotifier(), and each subclass decides which concrete class to create. It appears in frameworks; in application code, the registry form is usually simpler.
Static factory methods (List.of, Optional.empty, Money.of, Duration.ofSeconds) are a lightweight cousin. They have names, can return cached or subtype instances, and can validate first:
final class Temperature {
private final double celsius;
private Temperature(double celsius) { this.celsius = celsius; }
// named factory methods say what the double means; a constructor cannot
static Temperature ofCelsius(double c) { return new Temperature(c); }
static Temperature ofFahrenheit(double f) { return new Temperature((f - 32) * 5 / 9); }
double celsius() { return celsius; }
}
- Intent
- Create objects without the caller naming the concrete class.
- Use when
- The class to create depends on input or configuration, or construction is complex and repeated.
- Shape
create(key)backed by aswitchor aMap<Key, Supplier<T>>; callers receive the interface.- Real world
Spring BeanFactory,java.util.Calendar.getInstance(), JDBCDriverManager.getConnection,Executors.newFixedThreadPool.- Avoid when
- There is one implementation and no sign of a second. Use
new. - Interview signal
- Pair it with strategy: "the factory picks the split strategy from the expense type, so adding a split type is one class and one registration."
4.3Builder
A PaymentRequest has three required fields (order ID, amount, currency) and several optional ones (description, manual capture, idempotency key, metadata). What is wrong with (a) one constructor per combination, and (b) a no-argument constructor plus setters?
Reveal the answer
(a) Telescoping constructors: call sites full of null and true that nobody can read, and same-typed arguments are easy to swap silently. (b) Setters leave the object mutable and possibly half-built: nothing stops a request with manual capture and no idempotency key from being used, and nothing makes it safe to share.
class PaymentRequestOld {
PaymentRequestOld(String orderId, long amountPaise, String currency) { this(orderId, amountPaise, currency, null, false); }
PaymentRequestOld(String orderId, long amountPaise, String currency, String description, boolean capture) { /* ... */ }
}
// new PaymentRequestOld("o1", 49900, "INR", null, true); // what are null and true?
A builder collects values through named methods and creates the object in one step, after validating the whole combination:
final class PaymentRequest {
private final String orderId; // required
private final long amountPaise; // required
private final String currency; // required
private final String description; // optional
private final boolean autoCapture; // optional, default true
private final String idempotencyKey; // optional
private final Map<String, String> metadata;
private PaymentRequest(Builder b) {
this.orderId = b.orderId;
this.amountPaise = b.amountPaise;
this.currency = b.currency;
this.description = b.description;
this.autoCapture = b.autoCapture;
this.idempotencyKey = b.idempotencyKey;
this.metadata = Collections.unmodifiableMap(new LinkedHashMap<>(b.metadata)); // defensive copy
}
static Builder builder(String orderId, long amountPaise, String currency) { // required fields up front
return new Builder(orderId, amountPaise, currency);
}
static final class Builder {
private final String orderId;
private final long amountPaise;
private final String currency;
private String description;
private boolean autoCapture = true;
private String idempotencyKey;
private final Map<String, String> metadata = new LinkedHashMap<>();
private Builder(String orderId, long amountPaise, String currency) {
this.orderId = Objects.requireNonNull(orderId);
this.amountPaise = amountPaise;
this.currency = Objects.requireNonNull(currency);
}
Builder description(String d) { this.description = d; return this; }
Builder manualCapture() { this.autoCapture = false; return this; }
Builder idempotencyKey(String k) { this.idempotencyKey = k; return this; }
Builder metadata(String key, String value) { this.metadata.put(key, value); return this; }
PaymentRequest build() { // validate the whole object once, here
if (amountPaise <= 0) throw new IllegalStateException("amount must be positive");
if (!autoCapture && idempotencyKey == null)
throw new IllegalStateException("manual capture needs an idempotency key");
return new PaymentRequest(this);
}
}
@Override public String toString() {
return orderId + " " + amountPaise + " " + currency + " capture=" + (autoCapture ? "auto" : "manual") + " " + metadata;
}
}
PaymentRequest req = PaymentRequest.builder("o-42", 49_900, "INR")
.description("Annual plan")
.manualCapture()
.idempotencyKey("o-42-attempt-1")
.metadata("source", "app")
.build();
Code that ranBuilder.java: the rest of the file, including the main method behind the output below
class Demo {
public static void main(String[] args) {
// ... (code shown above) ...
System.out.println(req);
try { PaymentRequest.builder("o-43", 100, "INR").manualCapture().build(); throw new AssertionError(); }
catch (IllegalStateException e) { System.out.println("rejected: " + e.getMessage()); }
new PaymentRequestOld("o1", 1, "INR");
}
}
o-42 49900 INR capture=manual {source=app}
rejected: manual capture needs an idempotency key
Details that separate a good builder from a long one:
- Required fields go in
builder(...), so they cannot be forgotten. build()validates combinations that no single setter can check.- The product is immutable, with defensive copies of collections.
- In real projects, Lombok's
@Buildergenerates this; know how to write it by hand for interviews, and know that Lombok does not add validation unless you write it.
- Intent
- Build a complex, valid, immutable object step by step.
- Use when
- Four or more parameters, several optional, or rules that span fields.
- Shape
- Static nested
Builderwith fluent methods; private constructor that takes the builder;build()validates. - Real world
StringBuilder,HttpRequest.newBuilder(),Stream.builder(), Lombok@Builder, SpringUriComponentsBuilder.- Avoid when
- Two or three required fields. A constructor or a record is clearer.
- Interview signal
- Mention validation in
build()and immutability. That is what makes it more than a long chain of setters.
4.4Singleton, and why DI usually wins
Write the shortest thread-safe lazy singleton you know. Then argue why a Spring application rarely needs one.
Reveal the answer
The holder idiom (a private static nested class holding a static final instance) or an enum with one constant. Both rely on the JVM's thread-safe class initialization. In Spring, a bean is a singleton by default within the container: you get one instance, created by the container and injected where needed, so callers do not reach for global static state and tests can inject a different instance.
A singleton guarantees one instance and global access to it. The guarantee is easy to get wrong:
class LazyConfigBroken {
private static LazyConfigBroken instance;
static LazyConfigBroken getInstance() {
if (instance == null) { // two threads can both see null here
instance = new LazyConfigBroken(); // and both create an instance
}
return instance;
}
}
Three correct versions:
enum IdGenerator {
INSTANCE; // created once by the JVM, thread-safely
private final AtomicLong next = new AtomicLong(1);
long nextId() { return next.getAndIncrement(); }
}
class AppConfig {
private AppConfig() { /* load files, expensive */ }
private static class Holder { // loaded only on first use of getInstance()
static final AppConfig INSTANCE = new AppConfig();
}
static AppConfig getInstance() { return Holder.INSTANCE; } // class init is thread-safe by the JVM spec
}
class Registry {
private static volatile Registry instance; // volatile is required: without it a thread can see a half-built object
static Registry getInstance() {
Registry r = instance;
if (r == null) {
synchronized (Registry.class) {
r = instance;
if (r == null) instance = r = new Registry();
}
}
return r;
}
}
Code that ranSingleton.java: the rest of the file, including the main method behind the output below
class Demo {
public static void main(String[] args) throws Exception {
Set<Long> ids = ConcurrentHashMap.newKeySet();
ExecutorService pool = Executors.newFixedThreadPool(8);
for (int t = 0; t < 8; t++) pool.submit(() -> { for (int i = 0; i < 10_000; i++) ids.add(IdGenerator.INSTANCE.nextId()); });
pool.shutdown();
pool.awaitTermination(10, TimeUnit.SECONDS);
System.out.println("unique ids from 8 threads: " + ids.size());
assert ids.size() == 80_000;
assert AppConfig.getInstance() == AppConfig.getInstance() && Registry.getInstance() == Registry.getInstance();
assert LazyConfigBroken.getInstance() != null;
}
}
unique ids from 8 threads: 80000
The problem with singletons is not the instance count; it is global access. Code that calls Config.getInstance() hides a dependency, cannot easily run with a different config in a test, and shares state between tests. Dependency injection keeps "one instance" and drops "global access":
| Singleton pattern | One instance via DI (Spring default) | |
|---|---|---|
| Who creates it | The class itself | The container, at startup |
| How code gets it | X.getInstance() anywhere |
Constructor parameter |
| Dependencies visible | No | Yes, in the signature |
| Replace in tests | Hard (static state) | Pass a fake |
In machine coding, a singleton for an ID generator or an in-memory store is acceptable and quick. Say the trade-off: "I'd inject this in a real service; here a singleton keeps the driver simple." That shows you know both.
4.5Choosing a creational pattern
For each, name the pattern: (a) tests should run against in-memory storage and queue, production against AWS, and the two must never be mixed; (b) a game level is expensive to set up and you need many slightly different copies; (c) a Report takes 9 parameters.
Reveal the answer
(a) Abstract factory: one factory creates a matching family (blob store and job queue). (b) Prototype: copy a configured instance (with a copy constructor) and adjust it. (c) Builder.
The two patterns you mostly need to recognize:
Abstract factory creates a family of related objects that must be used together, and switching the family is one decision:
interface BlobStore { void put(String key, byte[] data); }
interface JobQueue { void enqueue(String job); }
interface InfraFactory { // creates a family of objects that must match
BlobStore blobStore();
JobQueue jobQueue();
}
class AwsInfra implements InfraFactory {
public BlobStore blobStore() { return (k, d) -> { /* S3 PutObject */ }; }
public JobQueue jobQueue() { return job -> { /* SQS SendMessage */ }; }
}
class InMemoryInfra implements InfraFactory { // for tests and local runs
final Map<String, byte[]> blobs = new HashMap<>();
final Queue<String> jobs = new ArrayDeque<>();
public BlobStore blobStore() { return blobs::put; }
public JobQueue jobQueue() { return jobs::add; }
}
class ReportJob {
private final BlobStore store;
private final JobQueue queue;
ReportJob(InfraFactory infra) { // one switch point: pass a different factory
this.store = infra.blobStore();
this.queue = infra.jobQueue();
}
void run(String id) {
store.put("reports/" + id, new byte[] {1, 2, 3});
queue.enqueue("email-report:" + id);
}
}
Prototype creates new objects by copying an existing configured one. In Java, prefer a copy constructor or a copy() method over Cloneable, whose clone() is shallow by default and easy to get wrong with mutable fields.
| Smell | Pattern |
|---|---|
new ConcreteType behind an if-else in many places |
Factory |
| Constructor with many parameters, several optional | Builder |
| Objects of different families mixed by mistake | Abstract factory |
| Expensive setup copied by hand | Prototype (copy constructor) |
| Exactly one instance needed | DI with one instance; enum singleton if no container |
4.6Common mistakes
Common mistakes
- A factory for a class with one implementation, adding indirection without value.
- Factories that also hold business rules: the factory decides what to create, not how to price or validate.
- Builders that skip validation, so invalid objects are built anyway.
- Mutable products from builders, or products that share the builder's collections.
- Lazy singletons without synchronization, or double-checked locking without
volatile. - Singletons with mutable global state that tests share and leak between each other.
- Calling
getInstance()deep in business code instead of injecting the dependency.
4.7Self-check
- Write (in words) a registry factory for split strategies in an expense-sharing app.
Reveal
A
Map<SplitType, Supplier<SplitStrategy>>filled at startup (EQUAL -> EqualSplit::new, and so on);create(type)looks up the supplier and throws on an unknown type. The expense service callsfactory.create(type).split(...). - What exactly does
volatilefix in double-checked locking?Reveal
Without it, the write of the reference can become visible before the constructor's writes, so another thread can see a non-null but half-initialized object.
volatileadds the happens-before ordering that prevents this. - Why is the enum singleton considered the most robust form?
Reveal
The JVM creates the constant exactly once and thread-safely; serialization returns the same constant; and reflection cannot create a second enum instance.
- When would you not use a builder even though a class has five fields?
Reveal
When all five are required and of clearly different types, a constructor or a record is shorter and just as safe. Builders pay off with optional fields and cross-field rules.
- How does an abstract factory differ from a factory?
Reveal
A factory creates one kind of product chosen by a key. An abstract factory creates several kinds of products that must match (a family), and switching families is one decision.
Mastery checklist
Tick an item only when it is true with your notes closed.
Module 5: Structural patterns
Structural patterns arrange objects so they fit together without editing them. Four of them wrap one object in another (adapter, decorator, proxy, facade), and they look alike in a class diagram, so this module teaches each with a real use case, then a side-by-side comparison so you can name the right one. Composite rounds it off for trees.
- A vendor SDK takes amounts in rupees as a
doubleand returns error codes as integers. Your code uses paise aslongand readable reasons. Which pattern sits between them?Reveal
An adapter. It implements your interface and translates units, names, and error codes at the boundary, so vendor quirks never leak into business code.
- You want logging, retries, and metrics around a payment gateway, in any combination, without editing the gateway. Which pattern?
Reveal
Decorator: each concern is a wrapper with the same interface that adds its step and delegates. Wrappers stack in any order.
- Spring's
@Transactionalon a method called from another method of the same class does nothing. Why?Reveal
Spring applies
@Transactionalthrough a proxy around the bean. A call onthisinside the class goes straight to the object, not through the proxy, so no transaction starts. - What is the difference between a facade and a god class?
Reveal
A facade offers a simple entry point and coordinates subsystems that keep their own rules. A god class holds the rules of many subsystems itself.
- A product bundle can contain products and other bundles, and both have a price. Which pattern?
Reveal
Composite:
ProductandBundleshare an interface withprice(), and a bundle's price sums its children.
5.1Adapter
Your checkout uses PaymentGateway.charge(orderId, amountPaise). Stripe's SDK wants a parameter map and returns an intent with a status string; a legacy bank API wants rupees as a double and returns integer codes. Where should each translation live?
Reveal the answer
In one adapter per vendor, each implementing PaymentGateway. StripeAdapter builds the parameter map and maps the status; BankAdapter converts paise to rupees and maps code 51 to "insufficient funds". Checkout code never sees a vendor type, so switching or adding vendors leaves it untouched.
An adapter makes an existing class usable through an interface it was not written for, by translating calls.
A travel plug adapter. Neither the socket nor the laptop changes; the adapter in between makes them fit.
One more class per external dependency, and translation code you must test. It pays for itself the first time a vendor changes its API.
interface PaymentGateway {
PaymentResult charge(String orderId, long amountPaise);
}
record PaymentResult(boolean success, String reference, String failureReason) {}
// third-party code we cannot change
class StripeClient {
StripeIntent createPaymentIntent(Map<String, Object> params) {
return new StripeIntent("pi_" + params.get("metadata[order_id]"), "succeeded");
}
}
record StripeIntent(String id, String status) {}
class LegacyBankApi {
/** returns 0 for success, 51 for insufficient funds, other codes for errors */
int debit(String accountRef, double rupees) { return rupees > 10_000 ? 51 : 0; }
}
class StripeAdapter implements PaymentGateway {
private final StripeClient client;
StripeAdapter(StripeClient client) { this.client = client; }
public PaymentResult charge(String orderId, long amountPaise) {
Map<String, Object> p = new HashMap<>();
p.put("amount", amountPaise); // Stripe also uses the smallest currency unit
p.put("currency", "inr");
p.put("metadata[order_id]", orderId);
StripeIntent intent = client.createPaymentIntent(p);
boolean ok = intent.status().equals("succeeded");
return new PaymentResult(ok, intent.id(), ok ? null : intent.status());
}
}
class BankAdapter implements PaymentGateway {
private final LegacyBankApi bank;
private final String accountRef;
BankAdapter(LegacyBankApi bank, String accountRef) { this.bank = bank; this.accountRef = accountRef; }
public PaymentResult charge(String orderId, long amountPaise) {
int code = bank.debit(accountRef, amountPaise / 100.0); // translate units at the boundary only
return switch (code) {
case 0 -> new PaymentResult(true, "bank-" + orderId, null);
case 51 -> new PaymentResult(false, null, "INSUFFICIENT_FUNDS");
default -> new PaymentResult(false, null, "BANK_ERROR_" + code);
};
}
}
Code that ranAdapter.java: the rest of the file, including the main method behind the output below
// our contract, owned by our code
// ... (code shown above) ...
// ... (code shown above) ...
class Demo {
public static void main(String[] args) {
PaymentGateway stripe = new StripeAdapter(new StripeClient());
PaymentGateway bank = new BankAdapter(new LegacyBankApi(), "ACC-1");
System.out.println(stripe.charge("o1", 49_900));
System.out.println(bank.charge("o2", 2_000_000));
assert !bank.charge("o3", 2_000_000).success() && bank.charge("o4", 100).success();
}
}
PaymentResult[success=true, reference=pi_o1, failureReason=null]
PaymentResult[success=false, reference=null, failureReason=INSUFFICIENT_FUNDS]
- Intent
- Let a class work through an interface it does not implement, by translating calls.
- Use when
- Integrating third-party SDKs, legacy systems, or any API whose shape you do not control.
- Shape
- Adapter implements the target interface and holds the adaptee (object adapter); it converts arguments, results, units, and errors.
- Real world
InputStreamReaderadapts anInputStreamto aReader;Arrays.asListadapts an array to aList; Spring MVC'sHandlerAdapter.- Avoid when
- You own both sides. Change the interface instead of adding a translator.
- Interview signal
- Say "translate at the boundary": units, error codes, and naming are converted once, in the adapter.
5.2Decorator
You need retries and logging around a flaky gateway. Does it matter whether logging wraps retries or retries wrap logging? What would each log?
Reveal the answer
It matters. Logging outside retries logs once per business call: one "charge" line and the final outcome. Logging inside retries logs every attempt, including the failures that were retried. Both are useful for different purposes; the order of wrapping is a design decision.
A decorator implements the same interface as the object it wraps, adds behavior before or after delegating, and can be stacked:
class RetryingGateway implements PaymentGateway {
private final PaymentGateway inner;
private final int maxAttempts;
RetryingGateway(PaymentGateway inner, int maxAttempts) { this.inner = inner; this.maxAttempts = maxAttempts; }
public String charge(String orderId, long amountPaise) {
for (int attempt = 1; ; attempt++) {
try {
return inner.charge(orderId, amountPaise);
} catch (TransientFailure e) {
if (attempt == maxAttempts) throw e;
// real code: back off with jitter, and only retry if the provider is idempotent per orderId
}
}
}
}
class LoggingGateway implements PaymentGateway {
private final PaymentGateway inner;
private final List<String> log;
LoggingGateway(PaymentGateway inner, List<String> log) { this.inner = inner; this.log = log; }
public String charge(String orderId, long amountPaise) {
log.add("charge " + orderId + " " + amountPaise);
try {
String ref = inner.charge(orderId, amountPaise);
log.add("ok " + ref);
return ref;
} catch (RuntimeException e) {
log.add("failed " + e.getMessage());
throw e;
}
}
}
PaymentGateway gateway = new LoggingGateway(new RetryingGateway(new FlakyGateway(2), 3), log);
gateway.charge("o-9", 25_000);
Code that ranDecorator.java: the rest of the file, including the main method behind the output below
interface PaymentGateway {
String charge(String orderId, long amountPaise);
}
class TransientFailure extends RuntimeException {
TransientFailure(String m) { super(m); }
}
class FlakyGateway implements PaymentGateway { // fails the first `failures` calls
private int failures;
FlakyGateway(int failures) { this.failures = failures; }
public String charge(String orderId, long amountPaise) {
if (failures-- > 0) throw new TransientFailure("timeout");
return "ref-" + orderId;
}
}
// ... (code shown above) ...
class Demo {
public static void main(String[] args) {
List<String> log = new ArrayList<>();
// ... (code shown above) ...
log.forEach(System.out::println);
assert log.equals(List.of("charge o-9 25000", "ok ref-o-9"));
List<String> log2 = new ArrayList<>();
PaymentGateway order2 = new RetryingGateway(new LoggingGateway(new FlakyGateway(2), log2), 3);
order2.charge("o-10", 1);
System.out.println("logging inside retry sees every attempt: " + log2);
}
}
charge o-9 25000
ok ref-o-9
logging inside retry sees every attempt: [charge o-10 1, failed timeout, charge o-10 1, failed timeout, charge o-10 1, ok ref-o-10]
Why decorators beat subclasses here: each concern is one class, combinations happen at runtime, and the wrapped class never changes (open/closed). The same idea is all over the JDK: new BufferedReader(new InputStreamReader(new FileInputStream(path))) stacks three behaviors on a byte stream, and Collections.unmodifiableList(list) decorates a list with read-only behavior.
Retrying a payment is only safe if the provider treats repeated requests for the same order as one charge (an idempotency key). A retry decorator around a non-idempotent call can charge a customer twice.
5.3Proxy
Exchange rates come from a slow remote API and change every few minutes. Callers use a RateService interface. How do you add caching without changing callers or the remote client?
Reveal the answer
A caching proxy that implements RateService, holds the real service, and returns a cached value while it is fresh. On a miss or an expired entry, it calls the real service and stores the result. Callers still depend on RateService, and the remote client is untouched.
A proxy has the same interface as the real object and controls access to it: it may delay creating it, cache its answers, check permissions, or call it over the network. Unlike a decorator, a proxy may decide not to call the real object at all.
interface RateService {
BigDecimal rate(String from, String to);
}
class RemoteRateService implements RateService { // slow: a network call
int calls = 0;
public BigDecimal rate(String from, String to) {
calls++;
return new BigDecimal("83.10");
}
}
class CachingRateProxy implements RateService {
private record Entry(BigDecimal value, Instant expiresAt) {}
private final RateService real;
private final Duration ttl;
private final Clock clock;
private final Map<String, Entry> cache = new ConcurrentHashMap<>(); // proxies are shared: make the cache thread-safe
CachingRateProxy(RateService real, Duration ttl, Clock clock) { this.real = real; this.ttl = ttl; this.clock = clock; }
public BigDecimal rate(String from, String to) {
String key = from + "->" + to;
Entry e = cache.get(key);
if (e != null && clock.instant().isBefore(e.expiresAt())) return e.value(); // real object not called at all
BigDecimal v = real.rate(from, to);
cache.put(key, new Entry(v, clock.instant().plus(ttl)));
return v;
}
}
| Kind | Controls | Example |
|---|---|---|
| Virtual | When the real object is created | Hibernate lazy-loading proxies |
| Caching | Whether the real object is called | @Cacheable, the rate proxy above |
| Protection | Who may call it | A permission check before a sensitive method |
| Remote | Where it runs | gRPC and RMI client stubs |
How Spring uses proxies, and the self-call trap
Spring implements @Transactional, @Cacheable, and @Async by wrapping your bean in a proxy. Only calls that go through the proxy get the behavior. A JDK dynamic proxy shows the mechanism:
interface OrderOps {
void placeOrder(String id);
void audit(String id);
}
class OrderOpsImpl implements OrderOps {
public void placeOrder(String id) {
audit(id); // a call on `this`: it never passes through the proxy
}
public void audit(String id) { }
}
class Tracing {
@SuppressWarnings("unchecked")
static <T> T wrap(T target, Class<T> type, List<String> seen) {
InvocationHandler h = (proxy, method, args) -> {
seen.add("proxy saw " + method.getName()); // where Spring would begin a transaction
return method.invoke(target, args);
};
return (T) Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[] {type}, h);
}
}
List<String> seen = new ArrayList<>();
OrderOps ops = Tracing.wrap(new OrderOpsImpl(), OrderOps.class, seen);
ops.placeOrder("o-1");
System.out.println(seen); // [proxy saw placeOrder]: audit() ran, but the proxy never saw it
this.audit() goes straight to the object, so any proxy-based annotation on audit is skipped.This is a favorite SDE-2 follow-up. The fixes: move the annotated method to another bean, call it through the injected proxy, or use AspectJ weaving. Saying why it happens (calls on this bypass the proxy) is what earns the credit.
5.4Facade
Placing an order means reserving stock, pricing, charging, and notifying. If payment fails, the reservation must be released. Where should that sequence and the undo step live?
Reveal the answer
In one checkout facade with a placeOrder method. It calls each subsystem in order and releases the reservation if a later step fails. Controllers and other callers make one call and do not need to know the sequence.
A facade gives clients one simple entry point to a set of collaborating objects:
class CheckoutFacade {
private final Inventory inventory;
private final Pricing pricing;
private final Payments payments;
private final Notifications notifications;
CheckoutFacade(Inventory i, Pricing p, Payments pay, Notifications n) {
this.inventory = i; this.pricing = p; this.payments = pay; this.notifications = n;
}
/** One call for clients; the order of steps and the compensation live here. */
String placeOrder(String orderId, String sku, int qty) {
String reservation = inventory.reserve(sku, qty);
String paymentRef;
try {
long amount = pricing.total(sku, qty);
paymentRef = payments.charge(orderId, amount);
} catch (RuntimeException e) {
inventory.release(reservation, sku, qty); // undo the earlier step
throw e;
}
try {
notifications.orderPlaced(orderId); // best effort: a failed email must not undo a paid order
} catch (RuntimeException e) {
// log and retry later; the order stands
}
return paymentRef;
}
}
Code that ranFacade.java: the rest of the file, including the main method behind the output below
class Inventory {
final Map<String, Integer> stock;
Inventory(Map<String, Integer> stock) { this.stock = stock; }
String reserve(String sku, int qty) {
if (stock.getOrDefault(sku, 0) < qty) throw new IllegalStateException("out of stock: " + sku);
stock.merge(sku, -qty, Integer::sum);
return "res-" + sku;
}
void release(String reservationId, String sku, int qty) { stock.merge(sku, qty, Integer::sum); }
}
class Pricing { long total(String sku, int qty) { return 49_900L * qty; } }
class Payments {
boolean failNext;
String charge(String orderId, long amount) {
if (failNext) throw new IllegalStateException("card declined");
return "pay-" + orderId;
}
}
class Notifications { final List<String> sent = new ArrayList<>(); void orderPlaced(String id) { sent.add(id); } }
// ... (code shown above) ...
class Demo {
public static void main(String[] args) {
Inventory inv = new Inventory(new java.util.HashMap<>(Map.of("BOOK", 3)));
Payments pay = new Payments();
CheckoutFacade checkout = new CheckoutFacade(inv, new Pricing(), pay, new Notifications());
System.out.println(checkout.placeOrder("o1", "BOOK", 2) + ", stock left " + inv.stock.get("BOOK"));
pay.failNext = true;
try { checkout.placeOrder("o2", "BOOK", 1); throw new AssertionError(); } catch (IllegalStateException e) {
System.out.println("declined; stock restored to " + inv.stock.get("BOOK"));
}
assert inv.stock.get("BOOK") == 1;
}
}
pay-o1, stock left 1
declined; stock restored to 1
A facade coordinates; it does not absorb the subsystems' rules. Pricing rules stay in Pricing, stock rules in Inventory. If the facade starts computing discounts or checking stock levels itself, it is turning into a god class.
In a Spring service, the @Service class your controller calls is usually a facade over repositories and clients. The compensation pattern above is the in-process version of a saga: in a distributed system each step would be a separate service, and the undo would be an event or a call to that service.
5.5Composite
Model a file system where you can ask any file or directory for its size, and a directory's size is the sum of everything inside it, at any depth.
Reveal the answer
An FsNode interface with size(). FileNode returns its own size. Directory holds a list of FsNode children and returns the sum of their sizes, which recurses naturally. Callers treat a single file and a whole tree the same way.
A composite lets clients treat a single item and a group of items uniformly, because both implement the same interface and groups delegate to their children.
interface FsNode {
String name();
long size(); // same question for a file and a whole tree
void print(String indent, StringBuilder out);
}
record FileNode(String name, long size) implements FsNode {
public void print(String indent, StringBuilder out) {
out.append(indent).append(name).append(" (").append(size).append(" B)\n");
}
}
class Directory implements FsNode {
private final String name;
private final List<FsNode> children = new ArrayList<>();
Directory(String name) { this.name = name; }
Directory add(FsNode child) { children.add(child); return this; }
List<FsNode> children() { return Collections.unmodifiableList(children); }
public String name() { return name; }
public long size() { // delegates to children, recursively
long total = 0;
for (FsNode c : children) total += c.size();
return total;
}
public void print(String indent, StringBuilder out) {
out.append(indent).append(name).append("/ (").append(size()).append(" B)\n");
for (FsNode c : children) c.print(indent + " ", out);
}
}
Directory root = new Directory("project")
.add(new FileNode("README.md", 1_200))
.add(new Directory("src")
.add(new FileNode("Main.java", 4_096))
.add(new FileNode("Util.java", 2_048)));
StringBuilder out = new StringBuilder();
root.print("", out);
Code that ranComposite.java: the rest of the file, including the main method behind the output below
class Demo {
public static void main(String[] args) {
// ... (code shown above) ...
System.out.print(out);
assert root.size() == 7_344;
}
}
project/ (7344 B)
README.md (1200 B)
src/ (6144 B)
Main.java (4096 B)
Util.java (2048 B)
Use it for trees where operations apply to both leaves and groups: file systems, nested menus, organization charts, product bundles, and expression trees (Add(Num(2), Mul(Num(3), Num(4)))).
5.6Telling the wrappers apart
For each, name the pattern: (a) wrap a list so writes throw; (b) a stub that sends method calls over gRPC; (c) one bookTrip() call that books a flight, a hotel, and a cab; (d) make a vendor's XML client look like your JSON interface.
Reveal the answer
(a) Decorator (it adds a behavior, read-only, with the same interface); some call it a protection proxy, and either answer with the reason is fine. (b) Remote proxy. (c) Facade. (d) Adapter.
| Adapter | Decorator | Proxy | Facade | |
|---|---|---|---|---|
| Interface vs wrapped object | Different: converts | Same | Same | New and simpler |
| Purpose | Make it fit | Add behavior | Control access | Simplify use |
| Calls the wrapped object | Always, translated | Always, plus extra work | Maybe not (cache, lazy, denied) | Several objects, in sequence |
| Who creates the wrapped object | The caller passes it in | The caller passes it in | Often the proxy itself (lazy) | Injected subsystems |
| Example | InputStreamReader |
BufferedInputStream |
Spring @Transactional |
CheckoutFacade |
Name the pattern by its intent, not its shape. "It wraps the gateway to add retries" is a decorator; "it wraps the gateway to translate the vendor's API" is an adapter, even though both diagrams show a class holding another.
5.7Common mistakes
Common mistakes
- Vendor types leaking past the adapter, such as returning the SDK's response object to business code.
- Decorators that change the contract, such as swallowing exceptions the interface promises to throw.
- Ignoring wrap order: retry inside logging and logging inside retry produce different logs and metrics.
- A retry decorator on a non-idempotent call, which can duplicate charges or messages.
- Expecting
@Transactionalor@Cacheableto work on self-calls. - A facade that holds business rules, becoming the god class it was meant to hide.
- Composite trees with type checks (
if (node instanceof Directory)) in client code, which defeats the uniform interface.
5.8Self-check
- Why should an adapter convert error codes into your own exception or result types?
Reveal
So business code handles one vocabulary of failures regardless of vendor. Switching vendors or adding a second one then changes only adapters.
- Give two differences between a proxy and a decorator.
Reveal
A proxy controls access and may not call the real object (cache hit, lazy creation, permission denied); a decorator always delegates and adds behavior. A proxy often creates or locates the real object itself; a decorator receives it from the caller.
- Why does
java.iouse decorators instead of subclasses for buffering, decompression, and character decoding?Reveal
Those concerns combine freely. Subclasses would need one class per combination; decorators stack any combination at runtime from a few classes.
- Your facade's
placeOrdercharges the card and then the notification step throws. Should the payment be refunded?Reveal
Usually not: a notification failure should not undo a successful order. Make notification asynchronous or best-effort with a retry, and compensate only for steps the order truly depends on.
- What makes the composite pattern support unlimited nesting?
Reveal
The group holds children typed as the shared interface, so a child can itself be a group. Operations recurse through the same method.
Mastery checklist
Tick an item only when it is true with your notes closed.
Module 6: Behavior that varies: strategy, template method, chain of responsibility
Most LLD problems have one rule that varies: how a fee is computed, how a spot is chosen, how an expense is split, which checks a request must pass. Strategy, template method, and chain of responsibility are three ways to make that variation pluggable. This module teaches each, the strategy plus factory combination that removes type switches for good, and how to choose between the three.
- The shipping fee depends on a policy that marketing changes every quarter. Where should that rule live?
Reveal
Behind a
ShippingStrategyinterface, with one class (or lambda) per policy. Checkout holds a strategy and asks it for the fee, so a new policy never edits checkout. - A
switch (paymentMethod)that picks a fee formula appears in three places. What combination of patterns removes it?Reveal
Strategy plus factory: one fee strategy per method, and one map from method to strategy built at startup. The three switches become one lookup.
- Three importers share the same steps (parse, validate, save) and differ in parsing. Which pattern keeps the steps in one place?
Reveal
Template method: an abstract base class with a
finalrun()that calls the steps, and subclasses that fill inparseandsave. Composition with strategies is the alternative. - A withdrawal must pass KYC, limit, and fraud checks; the first failure stops it. Which pattern?
Reveal
Chain of responsibility (or its list form, a pipeline): each check either rejects or passes the request on.
- Can a Java lambda be a strategy?
Reveal
Yes. Any single-method interface is a functional interface, so
p -> 0can implementShippingStrategy. Use a class when the strategy has fields, several methods, or deserves a name in the design.
6.1Strategy
Shipping costs can be a flat rate, depend on weight, or be free above an order value with one of the other rules below it. Sketch a design where checkout does not change when marketing invents a fourth rule.
Reveal the answer
A ShippingStrategy interface with costPaise(parcel). FlatRate, ByWeight, and FreeAbove implement it; FreeAbove holds another strategy for orders below the threshold. Checkout receives one strategy and calls it. A fourth rule is a fourth class.
Put each variant of an algorithm in its own class behind one interface, and let the context hold one of them. The context's code never branches on which one it has.
A navigation app asks you to choose fastest, shortest, or no tolls. The app does the same thing either way (draw the route); only the routing rule is swapped.
One interface and one class per variant. For two variants that will never grow, an if is clearer.
interface ShippingStrategy {
long costPaise(Parcel p);
}
class FlatRate implements ShippingStrategy {
private final long ratePaise;
FlatRate(long ratePaise) { this.ratePaise = ratePaise; }
public long costPaise(Parcel p) { return ratePaise; }
}
class ByWeight implements ShippingStrategy {
public long costPaise(Parcel p) {
long kg = (long) Math.ceil(p.weightKg());
return 4_000 + Math.max(0, kg - 1) * 2_000; // 40 rupees for the first kg, 20 for each extra
}
}
class FreeAbove implements ShippingStrategy { // wraps another strategy: free above a threshold
private final long thresholdPaise;
private final ShippingStrategy otherwise;
FreeAbove(long thresholdPaise, ShippingStrategy otherwise) { this.thresholdPaise = thresholdPaise; this.otherwise = otherwise; }
public long costPaise(Parcel p) {
return p.orderValuePaise() >= thresholdPaise ? 0 : otherwise.costPaise(p);
}
}
class Checkout {
private final ShippingStrategy shipping; // chosen by configuration or by the caller
Checkout(ShippingStrategy shipping) { this.shipping = shipping; }
long total(Parcel p) { return p.orderValuePaise() + shipping.costPaise(p); }
}
Code that ranStrategy.java: the rest of the file, including the main method behind the output below
record Parcel(double weightKg, long orderValuePaise, boolean express) {}
// ... (code shown above) ...
class Demo {
public static void main(String[] args) {
Parcel small = new Parcel(2.3, 30_000, false), big = new Parcel(2.3, 120_000, false);
ShippingStrategy festival = p -> 0; // a one-line strategy needs no class
ShippingStrategy expressFee = p -> p.express() ? 9_900 : 4_900;
ShippingStrategy policy = new FreeAbove(99_900, new ByWeight());
for (Parcel p : List.of(small, big)) System.out.println("order " + p.orderValuePaise() + " -> shipping " + policy.costPaise(p));
assert new Checkout(policy).total(small) == 30_000 + 8_000 && new Checkout(policy).total(big) == 120_000;
assert festival.costPaise(small) == 0 && expressFee.costPaise(new Parcel(1, 1, true)) == 9_900 && new FlatRate(5).costPaise(small) == 5;
}
}
order 30000 -> shipping 8000
order 120000 -> shipping 0
FreeAbove both implements and wraps a strategy, which combines rules without new subclasses.Because ShippingStrategy has one method, lambdas work too:
ShippingStrategy festival = p -> 0; // a one-line strategy needs no class
ShippingStrategy expressFee = p -> p.express() ? 9_900 : 4_900;
- Intent
- Make a family of algorithms interchangeable by putting each behind one interface.
- Use when
- A rule varies by configuration, user, region, or time, or a type switch picks between formulas.
- Shape
- Context holds a
Strategy; implementations are classes or lambdas; a factory or map picks one. - Real world
Comparatorpassed toList.sort; Spring Security'sAuthenticationProvider; retry and backoff policies in Resilience4j.- Avoid when
- Two branches that will never grow, or variants that differ by one constant. Use a parameter.
- Interview signal
- Strategies appear in most LLD problems: spot allocation, pricing, splitting, elevator dispatch, eviction. Name the one in yours early.
6.2Strategy plus factory: killing the switch
Fees differ by payment method (card 2%, UPI free, netbanking flat 15 rupees, wallet 1%), and international cards pay 3.5%. Design the lookup so a new method or a special case does not edit existing code.
Reveal the answer
Two options. A map from method to fee rule handles the simple one-to-one case. For special cases that depend on more than the method (card and international), use a list of rules where each says supports(payment), ordered most specific first with a default last; the first rule that supports the payment computes the fee.
The strategy decides how; something still has to decide which. Two selection styles cover almost every interview:
class FeeCalculator {
private final Map<Method, FeeRule> rules = new EnumMap<>(Method.class);
FeeCalculator() {
rules.put(Method.CARD, p -> p.amountPaise() * 2 / 100); // 2%
rules.put(Method.UPI, p -> 0);
rules.put(Method.NETBANKING, p -> 1_500); // flat 15 rupees
rules.put(Method.WALLET, p -> p.amountPaise() / 100); // 1%
}
long fee(Payment p) {
FeeRule rule = rules.get(p.method());
if (rule == null) throw new IllegalArgumentException("no fee rule for " + p.method());
return rule.feePaise(p);
}
}
interface PricingRule {
boolean supports(Payment p); // decides whether it applies
long feePaise(Payment p);
}
class InternationalCardRule implements PricingRule {
public boolean supports(Payment p) { return p.method() == Method.CARD && p.international(); }
public long feePaise(Payment p) { return p.amountPaise() * 35 / 1000; } // 3.5%
}
class DomesticCardRule implements PricingRule {
public boolean supports(Payment p) { return p.method() == Method.CARD; }
public long feePaise(Payment p) { return p.amountPaise() * 2 / 100; }
}
class DefaultRule implements PricingRule {
public boolean supports(Payment p) { return true; }
public long feePaise(Payment p) { return 0; }
}
class RuleBasedFees {
private final List<PricingRule> rules; // order matters: most specific first
RuleBasedFees(List<PricingRule> rules) { this.rules = rules; }
long fee(Payment p) {
return rules.stream().filter(r -> r.supports(p)).findFirst()
.orElseThrow(() -> new IllegalStateException("no rule for " + p))
.feePaise(p);
}
}
Code that ranStrategyFactory.java: the rest of the file, including the main method behind the output below
enum Method { CARD, UPI, NETBANKING, WALLET }
record Payment(Method method, long amountPaise, boolean international) {}
interface FeeRule {
long feePaise(Payment p);
}
// ... (code shown above) ...
class Demo {
public static void main(String[] args) {
FeeCalculator calc = new FeeCalculator();
System.out.println("card fee on 1000.00: " + calc.fee(new Payment(Method.CARD, 100_000, false)));
RuleBasedFees fees = new RuleBasedFees(List.of(new InternationalCardRule(), new DomesticCardRule(), new DefaultRule()));
System.out.println("international card fee on 1000.00: " + fees.fee(new Payment(Method.CARD, 100_000, true)));
assert fees.fee(new Payment(Method.UPI, 100_000, false)) == 0 && calc.fee(new Payment(Method.NETBANKING, 5, false)) == 1_500;
}
}
card fee on 1000.00: 2000
international card fee on 1000.00: 3500
In Spring, inject all implementations at once: a constructor parameter of type List<PricingRule> receives every bean that implements it (ordered by @Order), and Map<String, PricingRule> receives them keyed by bean name. A new rule is a new @Component; nothing else changes.
6.3Template method
CSV and JSON importers both parse, validate each row, save the valid ones, count rejects, and report. Only parsing and saving differ. How do you keep the sequence in one place, and stop a subclass from skipping validation?
Reveal the answer
An abstract FileImporter with a final run() that performs the steps in order and calls abstract parse and save methods, plus a hook isValid with a default. Subclasses fill in the blanks; final prevents them from changing the order or skipping steps.
A template method fixes an algorithm's skeleton in a base class and lets subclasses supply some steps:
abstract class FileImporter {
// the skeleton is fixed and final: subclasses cannot reorder or skip steps
final ImportResult run(String content) {
int saved = 0, rejected = 0;
for (Row row : parse(content)) {
if (isValid(row)) { save(row); saved++; }
else rejected++;
}
afterImport(saved, rejected);
return new ImportResult(saved, rejected);
}
protected abstract List<Row> parse(String content); // required step
protected abstract void save(Row row); // required step
protected boolean isValid(Row row) { // hook with a sensible default
return row.amountPaise() > 0;
}
protected void afterImport(int saved, int rejected) { } // optional hook
}
class CsvImporter extends FileImporter {
final List<Row> db = new ArrayList<>();
protected List<Row> parse(String content) {
return Arrays.stream(content.split("\n"))
.map(line -> line.split(","))
.map(f -> new Row(f[0].trim(), Long.parseLong(f[1].trim())))
.toList();
}
protected void save(Row row) { db.add(row); }
}
Code that ranTemplate.java: the rest of the file, including the main method behind the output below
record Row(String id, long amountPaise) {}
record ImportResult(int saved, int rejected) {}
// ... (code shown above) ...
interface Parser { List<Row> parse(String content); }
interface Sink { void save(Row row); }
class Importer { // the same flow, assembled from parts
private final Parser parser;
private final Sink sink;
Importer(Parser parser, Sink sink) { this.parser = parser; this.sink = sink; }
ImportResult run(String content) {
int saved = 0, rejected = 0;
for (Row r : parser.parse(content)) {
if (r.amountPaise() > 0) { sink.save(r); saved++; } else rejected++;
}
return new ImportResult(saved, rejected);
}
}
class Demo {
public static void main(String[] args) {
CsvImporter csv = new CsvImporter();
ImportResult r = csv.run("t1, 5000\nt2, -10\nt3, 250");
System.out.println(r + " saved ids " + csv.db.stream().map(Row::id).toList());
assert r.equals(new ImportResult(2, 1));
List<Row> sink = new ArrayList<>();
assert new Importer(c -> csv.parse(c), sink::add).run("a,1\nb,0").equals(new ImportResult(1, 1));
}
}
ImportResult[saved=2, rejected=1] saved ids [t1, t3]
run(): steps 1 and 3 are supplied by the subclass, steps 2 and 4 are hooks with defaults, and the order never changes.The same flow can be built by composition, passing a parser and a sink into one concrete class:
interface Parser { List<Row> parse(String content); }
interface Sink { void save(Row row); }
class Importer { // the same flow, assembled from parts
private final Parser parser;
private final Sink sink;
Importer(Parser parser, Sink sink) { this.parser = parser; this.sink = sink; }
ImportResult run(String content) {
int saved = 0, rejected = 0;
for (Row r : parser.parse(content)) {
if (r.amountPaise() > 0) { sink.save(r); saved++; } else rejected++;
}
return new ImportResult(saved, rejected);
}
}
Prefer composition when steps vary independently (any parser with any sink) or must change at runtime. Prefer a template method when the steps are coupled and the skeleton is the point, as in frameworks: the JDK's AbstractList, where you implement get and size and inherit the rest, and Spring MVC's AbstractController. Spring's JdbcTemplate keeps the fixed skeleton (get a connection, run, map rows, close) but takes the varying step as a callback, which is the composition version of the same idea.
6.4Chain of responsibility
An ATM holds 2,000, 500, and 100 rupee notes. Design the dispensing logic so a new denomination (200) can be added without editing the others.
Reveal the answer
A chain of handlers, one per denomination, ordered from largest to smallest. Each gives as many of its notes as it can and passes the remainder to the next. Adding 200 means inserting one handler between 500 and 100. The last handler throws if anything remains.
A chain of responsibility passes a request along a sequence of handlers. In the classic form, each handler does its part or passes the request on:
abstract class NoteDispenser {
private final int denomination;
private NoteDispenser next;
NoteDispenser(int denomination) { this.denomination = denomination; }
NoteDispenser then(NoteDispenser next) { this.next = next; return next; }
void dispense(int amount, Map<Integer, Integer> out) {
int count = amount / denomination;
if (count > 0) out.put(denomination, count);
int rest = amount % denomination;
if (rest == 0) return;
if (next == null) throw new IllegalArgumentException("cannot dispense remaining " + rest);
next.dispense(rest, out); // pass what is left down the chain
}
}
class TwoThousands extends NoteDispenser { TwoThousands() { super(2000); } }
class FiveHundreds extends NoteDispenser { FiveHundreds() { super(500); } }
class Hundreds extends NoteDispenser { Hundreds() { super(100); } }
NoteDispenser chain = new TwoThousands();
chain.then(new FiveHundreds()).then(new Hundreds());
Map<Integer, Integer> notes = new LinkedHashMap<>();
chain.dispense(3_700, notes); // {2000=1, 500=3, 100=2}
The more common form in business code is a pipeline: a list of checks run in order, stopping at the first rejection. Servlet filters and Spring Security's filter chain work this way.
record Withdrawal(String userId, long amountPaise, boolean kycDone, int todayCount) {}
interface Check {
Optional<String> reject(Withdrawal w); // empty = pass to the next check
}
class ChecksPipeline {
private final List<Check> checks;
ChecksPipeline(List<Check> checks) { this.checks = checks; }
Optional<String> firstRejection(Withdrawal w) {
for (Check c : checks) {
Optional<String> r = c.reject(w);
if (r.isPresent()) return r; // stop at the first handler that rejects
}
return Optional.empty();
}
}
ChecksPipeline checks = new ChecksPipeline(List.of(
w -> w.kycDone() ? Optional.empty() : Optional.of("KYC pending"),
w -> w.amountPaise() <= 5_000_000 ? Optional.empty() : Optional.of("over single-withdrawal limit"),
w -> w.todayCount() < 5 ? Optional.empty() : Optional.of("daily count exceeded")));
Code that ranChain.java: the rest of the file, including the main method behind the output below
class Demo {
public static void main(String[] args) {
// ... (code shown above) ...
System.out.println(notes);
assert notes.equals(Map.of(2000, 1, 500, 3, 100, 2));
try { chain.dispense(3_750, new LinkedHashMap<>()); throw new AssertionError(); } catch (IllegalArgumentException e) { System.out.println(e.getMessage()); }
// ... (code shown above) ...
List<String> out = new ArrayList<>();
out.add(checks.firstRejection(new Withdrawal("u1", 10_000, true, 0)).orElse("approved"));
out.add(checks.firstRejection(new Withdrawal("u2", 9_000_000, true, 0)).orElse("approved"));
out.add(checks.firstRejection(new Withdrawal("u3", 10_000, false, 9)).orElse("approved"));
System.out.println(out);
assert out.equals(List.of("approved", "over single-withdrawal limit", "KYC pending"));
}
}
{2000=1, 500=3, 100=2}
cannot dispense remaining 50
[approved, over single-withdrawal limit, KYC pending]
Order is part of the design. Cheap checks that reject the most requests go first; checks with side effects (reserving a limit) go last, after every check that could still reject.
6.5Choosing between the three
Name the pattern: (a) tax rules per country; (b) export to CSV, JSON, or PDF with identical open, header, rows, close steps; (c) a support ticket escalates from bot to agent to manager until someone resolves it; (d) a discount engine where several promotions may each adjust the price in turn.
Reveal the answer
(a) Strategy, picked by country. (b) Template method, or composition with a writer strategy. (c) Chain of responsibility (classic: each handler resolves or passes on). (d) A chain or pipeline where every handler runs and may adjust the price, in a defined order.
| Strategy | Template method | Chain of responsibility | |
|---|---|---|---|
| What varies | One whole algorithm | Some steps of a fixed algorithm | Which handlers act, and in what order |
| Mechanism | Composition: hold an interface | Inheritance: override steps | A linked chain or a list of handlers |
| Chosen | At runtime | At compile time (subclass) | At assembly time (order of handlers) |
| How many run | Exactly one | One skeleton, with its steps | Until one handles it, or all in order |
| Typical LLD use | Pricing, allocation, splitting, scheduling | Importers, exporters, report jobs | Validation, approvals, note dispensing, logging levels |
6.6Common mistakes
Common mistakes
- A strategy interface with one implementation forever, created for a rule that never varies.
- The switch moved, not removed: a factory with a
switchis fine once; the same switch in three places is not. - Strategies that reach into the context's internals instead of receiving what they need as parameters.
- A template method without
final, so subclasses overriderun()and skip validation. - Deep template hierarchies where behavior is spread across four levels of subclasses.
- An unordered chain, or a chain that applies a side effect before a later check rejects the request.
- A chain where no handler is guaranteed to handle, so requests silently fall off the end.
6.7Self-check
- What is the difference between strategy and polymorphism on the entity itself?
Reveal
Polymorphism on the entity puts behavior that depends on what the thing is inside its class (
vehicle.spotSize()). A strategy holds a policy that varies independently of the entity (weekend pricing for every vehicle). Use strategy when the rule changes without the type changing. - Why put a default rule last in a supports-based list?
Reveal
So every input matches some rule. Without it, an unexpected input finds no rule and fails at runtime; with it, the fallback behavior is explicit and tested.
- What does
finalon a template method protect?Reveal
The order and completeness of the algorithm. Subclasses can fill in steps and hooks but cannot reorder, skip, or bypass them.
- When would you prefer composition over a template method for an importer?
Reveal
When the parser and the destination vary independently (CSV to the database, CSV to a queue, JSON to the database), when they must change at runtime, or when you want to test steps in isolation.
- In a withdrawal pipeline, where does "reserve the daily limit" go, and why?
Reveal
Last, after every check that could still reject. Otherwise a later rejection leaves a reservation behind that must be undone.
Mastery checklist
Tick an item only when it is true with your notes closed.
Module 7: Events, commands, and state machines
Three behavioral patterns carry most LLD problems that have events, actions, or lifecycles. Observer lets one change notify many parts without knowing them. Command turns an action into an object you can queue, log, and undo. State machines, written as an enum transition table or as the State pattern, make illegal transitions impossible. The vending machine, elevator, booking, and order problems all rest on this module.
- When an order is placed you must email a receipt, add loyalty points, and record analytics. Why should
OrderServicenot call those three services directly?Reveal
It would depend on all three and change whenever a reaction is added or removed. Publishing an
OrderPlacedevent lets each reaction subscribe on its own; the order service stays the same. - One observer throws an exception. What should happen to the others?
Reveal
They should still run. The publisher (or event bus) catches and records each listener's failure separately, unless the reactions must succeed or fail together, in which case they are not observers but steps of one transaction.
- How do undo and redo work with the command pattern?
Reveal
Each action is a command with
executeandundo. Executed commands go on an undo stack; undo pops one, reverses it, and pushes it on a redo stack; redo moves it back. A new command clears the redo stack. - An order is SHIPPED and someone calls
cancel(). Where should that be rejected?Reveal
In the order's state machine: the transition table has no CANCEL from SHIPPED, so the call throws. No caller should need to remember the rule.
- Enum with a transition table, or the State pattern?
Reveal
An enum table when states differ mainly in which transitions are allowed. The State pattern when each state has substantial behavior of its own across several operations.
7.1Observer
Placing an order must trigger a receipt email, loyalty points, and analytics. Next quarter, fraud scoring is added. Design it so adding fraud scoring does not touch the order code, and a loyalty outage does not block receipts.
Reveal the answer
The order service publishes an OrderPlaced event to an event bus. Email, loyalty, analytics, and later fraud each subscribe independently. The bus calls listeners one by one and catches each listener's exceptions, so a loyalty outage is recorded and the others still run. Adding fraud scoring is one new subscription.
A subject keeps a list of listeners and notifies them when something happens. The subject knows only the listener interface, never the concrete reactions.
A newsletter. The writer publishes once; subscribers join and leave without the writer changing anything, and one bouncing inbox does not stop delivery to the rest.
The flow of control is no longer visible in one method. Debugging "who reacted to this event" needs logging, and ordering between listeners is not guaranteed unless you design it.
class EventBus {
// one listener list per event type; CopyOnWriteArrayList is safe to iterate while others subscribe
private final Map<Class<?>, List<Consumer<Object>>> listeners = new ConcurrentHashMap<>();
private final List<String> errors = new CopyOnWriteArrayList<>();
@SuppressWarnings("unchecked")
<E> Runnable subscribe(Class<E> type, Consumer<? super E> listener) {
Consumer<Object> l = e -> listener.accept((E) e);
listeners.computeIfAbsent(type, k -> new CopyOnWriteArrayList<>()).add(l);
return () -> listeners.get(type).remove(l); // call it to unsubscribe
}
void publish(Object event) {
for (Consumer<Object> l : listeners.getOrDefault(event.getClass(), List.of())) {
try {
l.accept(event);
} catch (RuntimeException e) { // one broken listener must not stop the rest
errors.add(event.getClass().getSimpleName() + ": " + e.getMessage());
}
}
}
List<String> errors() { return errors; }
}
class OrderService {
private final EventBus bus;
OrderService(EventBus bus) { this.bus = bus; }
void place(String orderId, String userId, long amountPaise) {
// ... validate, save the order ...
bus.publish(new OrderPlaced(orderId, userId, amountPaise)); // does not know who listens
}
}
EventBus bus = new EventBus();
bus.subscribe(OrderPlaced.class, e -> out.add("email receipt for " + e.orderId()));
bus.subscribe(OrderPlaced.class, e -> { throw new IllegalStateException("loyalty service down"); });
Runnable stopAnalytics = bus.subscribe(OrderPlaced.class, e -> out.add("analytics +" + e.amountPaise()));
bus.subscribe(OrderCancelled.class, e -> out.add("refund " + e.orderId()));
OrderService orders = new OrderService(bus);
orders.place("o-1", "u-7", 49_900);
stopAnalytics.run(); // unsubscribe
orders.place("o-2", "u-7", 10_000);
Code that ranObserver.java: the rest of the file, including the main method behind the output below
record OrderPlaced(String orderId, String userId, long amountPaise) {}
record OrderCancelled(String orderId) {}
// ... (code shown above) ...
class Demo {
public static void main(String[] args) {
List<String> out = new ArrayList<>();
// ... (code shown above) ...
out.forEach(System.out::println);
System.out.println("errors: " + bus.errors());
assert out.equals(List.of("email receipt for o-1", "analytics +49900", "email receipt for o-2")) && bus.errors().size() == 2;
}
}
email receipt for o-1
analytics +49900
email receipt for o-2
errors: [OrderPlaced: loyalty service down, OrderPlaced: loyalty service down]
Design decisions to state out loud:
- Synchronous or asynchronous. The bus above calls listeners on the publisher's thread, so a slow listener slows the order. Use an executor or a message queue for slow or remote reactions.
- Failure isolation. Catch per listener, record, and continue. Decide whether a failed reaction is retried.
- Ordering. Do not rely on listener order unless you enforce it.
- Thread safety.
CopyOnWriteArrayListlets listeners be added while events are being delivered. - Leaks. A listener that is never unsubscribed keeps its object alive. Return an unsubscribe handle.
Spring provides this out of the box: ApplicationEventPublisher.publishEvent(...) and @EventListener methods. @TransactionalEventListener(phase = AFTER_COMMIT) runs the reaction only if the transaction commits, so you never email a receipt for an order that rolled back. Across services, the same pattern becomes a Kafka topic.
- Intent
- Notify many dependents of a change without coupling the source to them.
- Use when
- Several independent reactions follow one event, and the set of reactions changes over time.
- Shape
- Subject with
subscribeandpublish; listeners implement one callback; events are immutable records. - Real world
- Spring application events,
PropertyChangeListener, UI event handlers, Kafka consumers at service scale. - Avoid when
- The reactions must all succeed or all fail together with the change. Call them directly in one transaction.
- Interview signal
- Mention failure isolation and sync vs async. Those are the follow-up questions.
7.2Command
Name three things you can do with an action once it is an object instead of a method call.
Reveal the answer
Queue it to run later or on another thread; log or persist it (and replay it after a crash); undo it; retry it; batch several into a macro. A thread pool's Runnable tasks are commands; so is each line a machine coding driver parses from input.
A command packages a request (the receiver, the operation, and its arguments) as an object with a uniform interface, usually execute() and optionally undo().
interface Command {
void execute();
void undo();
}
class Document {
private final StringBuilder text = new StringBuilder();
void insert(int pos, String s) { text.insert(pos, s); }
String delete(int pos, int len) {
String removed = text.substring(pos, pos + len);
text.delete(pos, pos + len);
return removed;
}
int length() { return text.length(); }
@Override public String toString() { return text.toString(); }
}
class InsertCommand implements Command {
private final Document doc;
private final int pos;
private final String s;
InsertCommand(Document doc, int pos, String s) { this.doc = doc; this.pos = pos; this.s = s; }
public void execute() { doc.insert(pos, s); }
public void undo() { doc.delete(pos, s.length()); }
}
class DeleteCommand implements Command {
private final Document doc;
private final int pos, len;
private String removed; // remembered so undo can restore it
DeleteCommand(Document doc, int pos, int len) { this.doc = doc; this.pos = pos; this.len = len; }
public void execute() { removed = doc.delete(pos, len); }
public void undo() { doc.insert(pos, removed); }
}
Commands appear in more places than editors:
| Use | Example |
|---|---|
| Queues and thread pools | executor.submit(runnable): the task is a command |
| Machine coding drivers | Each input line (PARK KA01 CAR) parsed into a command object and executed |
| Audit and replay | Store executed commands; replay them to rebuild state |
| Retries | A failed command is re-queued with its arguments intact |
| Transactions in memory | A key-value store records undo commands per transaction for rollback |
7.3Undo and redo
Type "Hello", type " World", delete the first six characters, undo twice, redo once, then type "!". What is the text, and can you still redo?
Reveal the answer
"Hello World!". The second undo restored the text to "Hello", redo re-applied " World", and typing "!" is a new command, which clears the redo stack, so redo is no longer possible.
class Editor {
private final Deque<Command> undoStack = new ArrayDeque<>();
private final Deque<Command> redoStack = new ArrayDeque<>();
void run(Command c) {
c.execute();
undoStack.push(c);
redoStack.clear(); // a new edit invalidates the redo history
}
boolean undo() {
if (undoStack.isEmpty()) return false;
Command c = undoStack.pop();
c.undo();
redoStack.push(c);
return true;
}
boolean redo() {
if (redoStack.isEmpty()) return false;
Command c = redoStack.pop();
c.execute();
undoStack.push(c);
return true;
}
}
Document doc = new Document();
Editor ed = new Editor();
ed.run(new InsertCommand(doc, 0, "Hello"));
ed.run(new InsertCommand(doc, 5, " World")); // "Hello World"
ed.run(new DeleteCommand(doc, 0, 6)); // "World"
ed.undo(); // "Hello World"
ed.undo(); // "Hello"
ed.redo(); // "Hello World"
ed.run(new InsertCommand(doc, 11, "!")); // "Hello World!", and redo history is gone
Code that ranCommand.java: the rest of the file, including the main method behind the output below
class Demo {
public static void main(String[] args) {
// ... (code shown above) ...
System.out.println(doc + " | redo possible: " + ed.redo());
assert doc.toString().equals("Hello World!");
}
}
Hello World! | redo possible: false
Details that come up as follow-ups:
- Commands must capture what undo needs.
DeleteCommandsaves the removed text at execute time. - Memory. Cap the undo stack (an
ArrayDequeyou trim), or merge small edits (consecutive keystrokes become one insert). - Snapshots instead of reverse operations. For complex state, store the previous state (the memento pattern) instead of writing an inverse for every command.
7.4State machines with enums
Write the allowed transitions for an order as a table: rows are states, columns are events (pay, ship, deliver, cancel, return). What should happen for a blank cell?
Reveal the answer
Created: pay to Paid, cancel to Cancelled. Paid: ship to Shipped, cancel to Cancelled. Shipped: deliver to Delivered. Delivered: return to Returned. Cancelled and Returned allow nothing. A blank cell is an illegal transition and must throw.
| State | pay | ship | deliver | cancel | return |
|---|---|---|---|---|---|
| CREATED | PAID | - | - | CANCELLED | - |
| PAID | - | SHIPPED | - | CANCELLED | - |
| SHIPPED | - | - | DELIVERED | - | - |
| DELIVERED | - | - | - | - | RETURNED (within 7 days) |
| CANCELLED, RETURNED | - | - | - | - | - |
The table is the state diagram from Module 2 written as data. Code it the same way:
enum OrderStatus { CREATED, PAID, SHIPPED, DELIVERED, CANCELLED, RETURNED }
enum OrderEvent { PAY, SHIP, DELIVER, CANCEL, RETURN }
final class OrderMachine {
private static final Map<OrderStatus, Map<OrderEvent, OrderStatus>> NEXT = new EnumMap<>(OrderStatus.class);
private static void allow(OrderStatus from, OrderEvent on, OrderStatus to) {
NEXT.computeIfAbsent(from, k -> new EnumMap<>(OrderEvent.class)).put(on, to);
}
static { // the state diagram, as data
allow(OrderStatus.CREATED, OrderEvent.PAY, OrderStatus.PAID);
allow(OrderStatus.CREATED, OrderEvent.CANCEL, OrderStatus.CANCELLED);
allow(OrderStatus.PAID, OrderEvent.SHIP, OrderStatus.SHIPPED);
allow(OrderStatus.PAID, OrderEvent.CANCEL, OrderStatus.CANCELLED);
allow(OrderStatus.SHIPPED, OrderEvent.DELIVER, OrderStatus.DELIVERED);
allow(OrderStatus.DELIVERED, OrderEvent.RETURN, OrderStatus.RETURNED);
}
static OrderStatus next(OrderStatus from, OrderEvent event) {
OrderStatus to = NEXT.getOrDefault(from, Map.of()).get(event);
if (to == null) throw new IllegalStateException("cannot " + event + " an order that is " + from);
return to;
}
}
The entity applies events in a fixed order: check the transition is legal, check guards that depend on data, run entry actions, then change the state:
class Order {
private final String id;
private OrderStatus status = OrderStatus.CREATED;
private Instant deliveredAt;
Order(String id) { this.id = id; }
void apply(OrderEvent event, Instant now) {
OrderStatus next = OrderMachine.next(status, event); // 1. is the transition legal at all?
// 2. guards that depend on data, not just on the current state
if (event == OrderEvent.RETURN && now.isAfter(deliveredAt.plus(Duration.ofDays(7))))
throw new IllegalStateException("return window closed for " + id);
// 3. actions on entering a state, then commit
if (next == OrderStatus.DELIVERED) deliveredAt = now;
status = next;
}
OrderStatus status() { return status; }
}
Code that ranEnumState.java: the rest of the file, including the main method behind the output below
class Demo {
public static void main(String[] args) {
Instant t = Instant.parse("2026-10-01T10:00:00Z");
Order o = new Order("o-1");
try { o.apply(OrderEvent.RETURN, t); throw new AssertionError(); } catch (IllegalStateException e) { System.out.println(e.getMessage()); }
o.apply(OrderEvent.PAY, t);
o.apply(OrderEvent.SHIP, t);
try { o.apply(OrderEvent.CANCEL, t); throw new AssertionError(); } catch (IllegalStateException e) { System.out.println(e.getMessage()); }
o.apply(OrderEvent.DELIVER, t);
try { o.apply(OrderEvent.RETURN, t.plus(Duration.ofDays(9))); throw new AssertionError(); } catch (IllegalStateException e) { System.out.println(e.getMessage()); }
o.apply(OrderEvent.RETURN, t.plus(Duration.ofDays(3)));
System.out.println("final status " + o.status());
assert o.status() == OrderStatus.RETURNED;
}
}
cannot RETURN an order that is CREATED
cannot CANCEL an order that is SHIPPED
return window closed for o-1
final status RETURNED
Every entity with a status field gets a transition table and one method that changes the status. No other code assigns the status directly. This single rule prevents most lifecycle bugs in LLD problems.
7.5The State pattern
A document can be edited and submitted while a draft; approved (by someone other than the author) or rejected while in review; and nothing once published. Where would the rule "authors cannot approve their own work" live with an enum table, and where with State classes?
Reveal the answer
With an enum, the rule lives in the Document.approve method, which must also check the current status; every method grows a status check. With the State pattern, it lives in InReview.approve, the only place approval exists. Draft and Published do not need to mention approval at all; the interface default throws for them.
In the State pattern, each state is a class implementing the operations it allows; the context delegates to its current state object, and states replace themselves:
interface DocState {
default void edit(Document d, String text) { throw new IllegalStateException(name() + ": cannot edit"); }
default void submit(Document d) { throw new IllegalStateException(name() + ": cannot submit"); }
default void approve(Document d, String reviewer) { throw new IllegalStateException(name() + ": cannot approve"); }
default void reject(Document d, String reason) { throw new IllegalStateException(name() + ": cannot reject"); }
String name();
}
class Draft implements DocState {
public void edit(Document d, String text) { d.setBody(text); }
public void submit(Document d) {
if (d.body().isBlank()) throw new IllegalStateException("empty document");
d.moveTo(new InReview());
}
public String name() { return "Draft"; }
}
class InReview implements DocState {
public void approve(Document d, String reviewer) {
if (reviewer.equals(d.author())) throw new IllegalStateException("authors cannot approve their own work");
d.log("approved by " + reviewer);
d.moveTo(new Published());
}
public void reject(Document d, String reason) {
d.log("rejected: " + reason);
d.moveTo(new Draft());
}
public String name() { return "InReview"; }
}
class Published implements DocState { // everything else falls through to the defaults and throws
public String name() { return "Published"; }
}
class Document {
private final String author;
private String body = "";
private DocState state = new Draft();
private final List<String> history = new ArrayList<>();
Document(String author) { this.author = author; }
// the public API delegates to the current state
void edit(String text) { state.edit(this, text); }
void submit() { state.submit(this); }
void approve(String reviewer) { state.approve(this, reviewer); }
void reject(String reason) { state.reject(this, reason); }
// package-private hooks the states use
void moveTo(DocState next) { history.add(state.name() + " -> " + next.name()); state = next; }
void setBody(String b) { body = b; }
void log(String s) { history.add(s); }
String body() { return body; }
String author() { return author; }
String stateName() { return state.name(); }
List<String> history() { return history; }
}
Code that ranStatePattern.java: the rest of the file, including the main method behind the output below
class Demo {
public static void main(String[] args) {
Document d = new Document("asha");
d.edit("First draft");
d.submit();
try { d.edit("sneaky change"); throw new AssertionError(); } catch (IllegalStateException e) { System.out.println(e.getMessage()); }
d.reject("add numbers");
d.edit("Second draft with numbers");
d.submit();
try { d.approve("asha"); throw new AssertionError(); } catch (IllegalStateException e) { System.out.println(e.getMessage()); }
d.approve("ravi");
System.out.println(d.stateName() + " " + d.history());
assert d.stateName().equals("Published");
}
}
InReview: cannot edit
authors cannot approve their own work
Published [Draft -> InReview, rejected: add numbers, InReview -> Draft, Draft -> InReview, approved by ravi, InReview -> Published]
7.6Enum machine or State pattern?
For each, pick an enum table or the State pattern: (a) a seat that is available, held, or booked; (b) a vending machine where inserting money, selecting a product, and dispensing behave differently in idle, has-money, and dispensing states; (c) a ticket that is open, in progress, resolved, or closed.
Reveal the answer
(a) Enum table: three states, and behavior differs only in which transitions are allowed. (b) State pattern: each state handles several operations with different logic (accept coins, compute change, refuse selection). (c) Enum table, with guards for who may close.
| Enum with transition table | State pattern | |
|---|---|---|
| Best when | States differ in allowed transitions | States differ in behavior across several operations |
| Where rules live | One table plus guards in the entity | Spread across state classes, one per state |
| Adding a state | One enum constant and table rows | One class |
| Adding an operation | One method on the entity | One method on the interface and in each state that allows it |
| Persistence | Trivial: store the enum name | Store a name and map it back to a state object |
| Readability | The whole machine on one screen | Each state's logic in isolation |
Most interview problems start with an enum table. Move to the State pattern when the switch (status) blocks start appearing inside several methods; that is the same smell polymorphism removes from types.
Whichever you choose, draw the state diagram first and say "any transition not drawn throws." Then show where the guard for the trickiest rule lives. That is what interviewers check.
7.7Common mistakes
Common mistakes
- Observers that must succeed together with the change (debiting a wallet), treated as fire-and-forget events.
- No failure isolation: one listener's exception stops the others, or worse, rolls back the publisher.
- Synchronous slow listeners that add their latency to every request.
- Commands that cannot undo because they did not capture the old value at execute time.
- Forgetting to clear the redo stack after a new command.
- Status fields assigned from anywhere (
order.setStatus(SHIPPED)) instead of through one transition method. - Guards checked before legality, so an illegal event fails with a
NullPointerExceptioninstead of a clear error. - The State pattern for three trivial states, or an enum with
switch (status)in every method of a complex machine.
7.8Self-check
- When should an event listener run after the database transaction commits, and how do you get that in Spring?
Reveal
Whenever the reaction has side effects outside the database (emails, messages, cache updates) that must not happen for a rolled-back change. Use
@TransactionalEventListener(phase = AFTER_COMMIT). - How would you implement undo for an operation whose inverse is hard to compute?
Reveal
Snapshot the affected state before executing (memento) and restore it on undo, or store the command log and rebuild state from the last checkpoint.
- Why does the enum state machine use an
EnumMapinstead ofswitchstatements?Reveal
The table is data: one place shows the whole machine, it can be printed or tested exhaustively, and adding a transition is a line of data rather than a change inside a method's branches.
- In the State pattern, why are the interface's default methods written to throw?
Reveal
So each state class overrides only the operations it allows, and every other operation is rejected automatically, with no state having to list what it forbids.
- Give one advantage of the enum approach for persistence.
Reveal
The state is a plain value: store
status = 'SHIPPED'in a column and load it back directly. State objects need a mapping from stored name to class.
Mastery checklist
Tick an item only when it is true with your notes closed.
Smell to pattern drills
Patterns are answers; smells are the questions. This page trains the reverse lookup interviews need: read a design pressure or a piece of code, name what hurts, and pick the smallest pattern that removes the pain. Twelve drills, a picker you can use during practice, and the patterns you only need to recognize.
From pressure to pattern
Do not start from a list of patterns and look for places to use them. Start from the change you expect, and ask what makes it expensive today:
- Name the likely change. "New payment methods", "new vehicle types", "new notification channels", "pricing rules per city".
- Find where that change would land. If it lands in many places, or inside a method that works today, there is a pressure.
- Pick the smallest fix. Often polymorphism or a strategy is enough. Reach for heavier patterns only when the smaller one fails.
- Say the trade-off. Every pattern adds classes; name what you pay and why it is worth it here.
A pattern you cannot justify with a specific expected change is decoration. In an interview, one pattern placed exactly where the variation is beats five patterns placed everywhere.
Drills: code smells
Guided designSmell drill AName the smell, then the fix. Answer before revealing.
- 1A switch on type in several classesJava
// in FeeService, SpotFinder, and ReceiptPrinter switch (vehicle.getType()) { case CAR: ... case BIKE: ... case TRUCK: ... }Write your answer, then reveal
Smell: the same type switch repeated, so a new type edits three classes. Fix: polymorphism if the behavior belongs to the vehicle (
vehicle.spotSize()); strategy plus factory if it is a business policy that varies independently (pricing). See Module 6. - 2Status checks at the top of every methodJava
void dispense() { if (state != HAS_MONEY) throw ...; ... } void insertCoin(int c) { if (state == DISPENSING) throw ...; ... } void cancel() { if (state == IDLE) throw ...; ... }Write your answer, then reveal
Smell: an implicit state machine spread across methods. Fix: an enum transition table with one
apply(event)method, or the State pattern when each state has real behavior. See Module 7. - 3A constructor nobody can readJava
new Notification(userId, "Order shipped", null, null, true, false, 3, "en")Write your answer, then reveal
Smell: telescoping constructor; the
null,true, andfalsemean nothing at the call site. Fix: a builder with required fields up front and validation inbuild(). See Module 4. - 4Vendor types inside business codeJava
StripeIntent intent = stripe.createPaymentIntent(params); if (intent.getStatus().equals("requires_action")) order.markPending();Write your answer, then reveal
Smell: the order logic depends on one vendor's API and status strings. Fix: an adapter that implements your
PaymentGatewayand maps vendor statuses to your own result type. See Module 5. - 5Copy-pasted retry and logging blocksJava
for (int i = 0; i < 3; i++) { try { log...; return gateway.charge(...); } catch (TimeoutException e) { log... } } // the same block around refund(), payout(), and verify()Write your answer, then reveal
Smell: cross-cutting behavior duplicated around every call. Fix: decorators (
RetryingGateway,LoggingGateway) that wrap the interface once. In Spring, an aspect or a resilience library does the same. See Module 5. - 6A service that calls five unrelated services after savingJava
orderRepo.save(o); email.send(...); loyalty.add(...); analytics.track(...); crm.sync(...); fraud.score(...);Write your answer, then reveal
Smell: the order service changes whenever a reaction is added, and one failing reaction breaks ordering. Fix: publish an
OrderPlacedevent; reactions subscribe (observer). Keep only the steps that must succeed with the order inside the service. See Module 7.
Guided designSmell drill BSix more, mixed difficulty
- 1Three importers with identical loops
CsvImporter, JsonImporter, and XmlImporter each parse, validate rows, save valid ones, and count rejects, with the same loop copied three times.
Write your answer, then reveal
Smell: duplicated algorithm skeleton. Fix: template method with a
final run()and abstractparse/save, or oneImportercomposed from aParserand aSinkstrategy. See Module 6. - 2An if-else ladder of validations
A withdrawal method has eight
ifblocks (KYC, limits, fraud, blocked account, daily count...), and product wants to reorder and toggle them per country.Write your answer, then reveal
Smell: a growing ladder with ordering and configuration needs. Fix: a chain or pipeline of
Checkhandlers assembled per country. See Module 6. - 3instanceof inside a tree walkJava
long size(Object node) { if (node instanceof File f) return f.size(); if (node instanceof Dir d) { ... } }Write your answer, then reveal
Smell: callers type-check every node. Fix: composite:
FileandDirectoryshare anFsNodeinterface; directories sum their children. See Module 5. - 4A global settings class used everywhereJava
if (Settings.getInstance().isFeatureOn("newPricing")) ...Write your answer, then reveal
Smell: hidden global dependency; tests share and leak state. Fix: inject a
FeatureFlagsinterface through constructors; keep one instance via DI. See Module 4. - 5Undo implemented by copying the whole document after every keystroke
Write your answer, then reveal
Smell: memory grows with every edit, and redo is awkward. Fix: command objects with
executeandundo, two stacks, and a redo stack cleared on new edits; snapshots only for operations that are hard to invert. See Module 7. - 6FixedDeposit extends Account and its withdraw throws
Write your answer, then reveal
Smell: a Liskov violation: the subtype cannot keep the parent's promise. Fix: model the capability: a
Withdrawableinterface implemented only by accounts that allow withdrawals. See Module 3.
Patterns to recognize, not reach for
These come up in conversation or in frameworks. Know one sentence and one example for each; build them only when the pressure is clear.
| Pattern | One sentence | Where you have seen it |
|---|---|---|
| Iterator | Walk a collection without exposing its structure | Iterator, enhanced for loops |
| Visitor | Add operations to a closed set of types without editing them | Compilers walking syntax trees; Java 21 pattern-matching switch often replaces it |
| Mediator | Objects talk through one coordinator instead of to each other | An elevator controller between floors and cars; a chat room |
| Memento | Save and restore an object's state without exposing internals | Snapshot-based undo; game save points |
| Flyweight | Share immutable parts of many small objects | Integer.valueOf cache for -128 to 127; glyphs in a text editor |
| Bridge | Separate an abstraction from its implementation so both vary | JDBC: the API is one hierarchy, each driver is another |
| Prototype | Create objects by copying a configured instance | Copy constructors; template documents |
| Interpreter | Represent a small language's grammar as classes | Rule engines, expression evaluators |
When patterns hurt
Pattern overuse
- An interface with one implementation and no testing need.
- A factory that wraps
newfor a class that never varies. - A strategy for a rule with two cases that will never grow.
- A singleton for convenience, creating global state.
- Five patterns in a 90-minute machine coding round, with the core flow unfinished.
- Naming patterns without the reason: "I used the observer pattern" with no mention of what change it absorbs.
The strongest answer in a design discussion often sounds like: "I considered a strategy here, but pricing has two fixed rules and the interviewer said it will not change, so a single method is clearer. If it starts varying, extracting the strategy is a five-minute refactor." Knowing when not to use a pattern is an SDE-2 signal.
Module 8: From requirements to classes
This is the method you will run in every LLD round and every machine coding round: clarify, find the entities, assign responsibilities, draw the class diagram, define one service method per use case, find the variation points, then handle state, concurrency, and errors. Each step gets a checklist and the traps it avoids, and a library system is worked end to end.
- "Design a library system." What is the first thing you do?
Reveal
Ask questions: who uses it (members, librarians), which 3-5 use cases matter (search, borrow, return, reserve, fines), what is out of scope, and whether it runs in one process or needs concurrency. Write the answers down before drawing anything.
- A library has "books". Is a book one class?
Reveal
Usually two: a
BookTitle(ISBN, title, author: what you search) and aBookCopy(a physical item with a status: what you lend). Confusing the two is the most common modeling bug in this problem. - Where should "calculate the late fine" live?
Reveal
The loan knows its due and return dates, so it computes days overdue; the amount comes from a
FinePolicystrategy, because fine rules change. The service coordinates; it does not compute. - What is an anemic domain model?
Reveal
Entities with only getters and setters, and every rule in services. It duplicates rules and lets invalid states exist. Push rules that use an entity's own data into the entity.
- Which words in a requirement suggest a pattern?
Reveal
"Different types of" (polymorphism or strategy), "may change" or "configurable" (strategy), "notify" (observer), "status" or "lifecycle" (state machine), "undo" (command), "steps that all requests pass" (chain).
8.1The method in seven steps
Without looking ahead, write the steps you would follow from "design X" to a class diagram you are confident in. What does each step produce?
Reveal the answer
Compare with the figure. The key properties of any good method: requirements are written down before modeling; the model (entities, responsibilities, relationships) comes before code; each use case maps to a method; and the hard parts (variation, state, concurrency, errors) are handled explicitly rather than discovered while coding.
A fixed sequence of small decisions, each with a visible output, so the interviewer can follow your thinking and correct you early.
A pilot's checklist. Experienced pilots still use it, because skipping a step under time pressure is how things go wrong.
It feels slow for the first five minutes. It saves the last thirty, which is where unplanned designs collapse.
| Step | Output you can show | Time in a 45-minute round |
|---|---|---|
| 1. Clarify | Written list of actors, use cases, scope, constraints | 4-5 min |
| 2. Entities | List of nouns that own data, value objects, enums | 3 min |
| 3. Responsibilities | One line per class: what it knows, what it does | 3 min |
| 4. Class diagram | Boxes, relationships, multiplicities | 8-10 min |
| 5. APIs | Service method signatures, one per use case | 4 min |
| 6. Variation | Interfaces where change is expected | 3 min |
| 7. Hard parts | State diagram, concurrency answer, error list | 8-10 min, then code the core |
8.2Clarify requirements
Write eight questions you would ask after "Design a parking lot."
Reveal the answer
Actors: who interacts (driver, attendant, admin)? Vehicle types and spot sizes? How many floors and gates; can gates run at the same time (concurrency)? How is the fee computed (hourly, flat, per vehicle type), and can it change? Payment in or out of scope? Do we need reservations, EV charging, or a display board of free spots? Is this one process in memory, or do we persist? What should happen when the lot is full?
The checklist, in the order to ask it:
| Ask about | Why it changes the design |
|---|---|
| Actors | Each actor usually maps to a set of use cases and permissions |
| Core use cases (3-5) | Each becomes a service method; everything else is out of scope |
| Types and variations | "Several vehicle types", "several split types" mark the strategies |
| Rules that may change | Pricing, limits, eligibility: put them behind interfaces |
| Lifecycle | Anything with a status needs a state machine |
| Concurrency | Several gates, users, or threads acting on the same object |
| Scale and storage | In memory vs persisted; one process vs several |
| Interface | CLI commands, method calls, or REST, and expected output format |
| Out of scope | Write it down: payments, auth, UI, notifications |
End the clarification with a summary the interviewer can accept or correct: "So: three vehicle types, three spot sizes, multiple gates in parallel, hourly pricing that may change, no reservations, in-memory. I'll design for that." This sentence is worth more than any diagram that follows it.
8.3Find entities and value objects
From "members borrow books; each book can have several copies; a member can hold at most 5 books for 14 days; late returns are fined 10 rupees per day", list the classes and classify each as entity, value object, enum, or not a class.
Reveal the answer
Entities: Member, BookCopy, Loan. Entity or value object: BookTitle (identified by ISBN, rarely changes; a record works). Enum: CopyStatus. Policy: FinePolicy (strategy). Not classes: "5 books" and "14 days" (configuration values), "rupees" (part of a Money value object or a long in paise), "late" (a computed fact).
Underline the nouns in the requirements, then filter them:
- Actors outside the system (the driver at the gate) are often not classes, or only an ID.
- Attributes (color, due date) belong in a class; they are not classes.
- Values (money, date range, coordinates, a seat number) become immutable value objects or records.
- Statuses and kinds with no behavior become enums.
- Synonyms collapse into one class; use the interviewer's word.
The most valuable modeling move is spotting type vs instance pairs:
| Problem | Type (described, searched) | Instance (has state, gets used) |
|---|---|---|
| Library | BookTitle |
BookCopy |
| Movie booking | Movie |
Show, and per show a ShowSeat |
| E-commerce | Product |
InventoryUnit |
| Parking | SpotType (size) |
ParkingSpot |
| Car rental | CarModel |
Car (with a registration number) |
8.4Assign responsibilities
Where should each rule live: (a) "a member can hold at most 5 books"; (b) "a copy that is on loan cannot be lent again"; (c) "fine = 10 rupees per day late, capped at 500"; (d) "borrowing finds any available copy of the title, checks the member, creates a loan".
Reveal the answer
(a) Member.canBorrow(activeLoans): the member knows its limit. (b) BookCopy.lend(): the copy guards its own status. (c) A FinePolicy strategy, fed the days overdue that Loan.daysOverdue(date) computes. (d) LibraryService.checkout: a use case that coordinates several objects belongs in a service.
Three rules decide most placements:
- Information expert: give a responsibility to the class that has the information to do it. Due dates are in
Loan, soLoancomputes days overdue. - Creator: a class creates objects it contains or closely uses. An
Ordercreates itsOrderLines; a service creates aLoanbecause no entity owns loans. - Coordinator: a use case that spans several entities lives in a service method, which asks entities to do their part and never reaches into their fields.
Two opposite failures. An anemic model puts every rule in services, so entities are data bags and rules get duplicated. A god entity pulls unrelated rules into one class (Member that also computes fines and searches the catalog). Ask "whose data does this rule read?" and put it there.
8.5Services, repositories, and layers
In a machine coding round, which kinds of classes do you need besides entities, and which direction should dependencies point?
Reveal the answer
Services (one method per use case), repositories (store and find entities, in memory behind an interface), strategies or policies (the variation points), exceptions, and a driver or demo that calls services. Dependencies point downward: driver to services, services to entities, repositories, and strategies; never the reverse.
| Kind | Owns | Must not |
|---|---|---|
| Entity | Its data and invariants (lend, close) |
Call repositories or other services |
| Value object | An immutable value with validation | Have identity or setters |
| Service | A use case: load, ask entities, save, publish | Hold data between calls (beyond its collaborators) |
| Repository | Storing and finding entities | Contain business rules |
| Strategy / policy | One varying rule | Know about storage or the driver |
| Driver | Parsing input and printing output | Contain business logic |
8.6Find the variation points
Mark the words that suggest a pattern: "Support cars, bikes, and trucks. Pricing is hourly now but will be per-minute on weekends. Notify the owner when parking time exceeds 4 hours. A spot is free, reserved, or occupied."
Reveal the answer
"Cars, bikes, and trucks": polymorphism on vehicle or spot size. "Pricing ... will be per-minute on weekends": a pricing strategy. "Notify the owner when": an observer or event. "Free, reserved, or occupied": a state machine on the spot.
| Phrase in the requirement | Likely structure |
|---|---|
| "different types of X", each behaving differently | Polymorphism (X subclasses or an interface) |
| "may change", "configurable", "depends on the region" | Strategy (+ factory to pick it) |
| "notify", "when X happens, also do Y" | Observer / events |
| "status", "can only ... after ...", "cancel" | State machine |
| "undo", "history", "replay", "queue" | Command |
| "every request must pass checks" | Chain of responsibility |
| "wrap", "add logging or retries to" | Decorator |
| "third-party provider", "legacy system" | Adapter |
Place an interface only where the requirement or the interviewer signals change. Everything else stays concrete.
8.7Worked example: library
Guided designLibrary management in 25 minutesRun the method; answer each step before revealing
- 1Clarify
Members borrow and return books; search is by title; a member may hold 5 books for 14 days; late returns cost 10 rupees per day up to 500. What else do you ask, and what do you put out of scope?
Write your answer, then reveal
Ask: multiple copies per title (yes); reservations when no copy is free (out of scope for now, likely follow-up); librarian actions (adding copies, marking lost); concurrency (two members borrowing the last copy at two desks: yes, mention it); persistence (in memory). Out of scope: payments of fines, notifications, authentication.
- 2Entities and responsibilities
List the classes and one line each.
Write your answer, then reveal
BookTitle(record: ISBN, title, author).BookCopy(copy ID, ISBN, status; can lend and take back).Member(ID, max loans; decides if it can borrow).Loan(copy, member, due date, return date; closes, computes days overdue).FinePolicy(interface: days to amount).LibraryService(checkout, return). Repositories for copies and loans. - 3Class diagram
Draw it with multiplicities.
Write your answer, then reveal
See the figure below. Note that
Loanrefers to copy and member by ID, andBookTitletoBookCopyis one to many. - 4Service APIs
Write the method signatures for the two use cases.
Write your answer, then reveal
Loan checkout(String memberId, String isbn, LocalDate today)andReturnReceipt returnCopy(String copyId, LocalDate today). Passingtodayin makes the logic testable without a clock; in production, inject aClock. - 5Hard parts
What can go wrong, and what changes are likely?
Write your answer, then reveal
Errors: unknown member, limit reached, no available copy, returning a copy that is not on loan. Concurrency: two checkouts of the last copy;
BookCopy.lend()must be atomic (synchronize per copy, or a conditional update in a database). Likely changes: fine rules (already a strategy), reservations (a queue per title, notified when a copy returns: observer), different limits per membership tier (a policy onMember).
record BookTitle(String isbn, String title, String author) {} // what you search for
enum CopyStatus { AVAILABLE, ON_LOAN, LOST }
class BookCopy { // what sits on the shelf
private final String copyId;
private final String isbn;
private CopyStatus status = CopyStatus.AVAILABLE;
BookCopy(String copyId, String isbn) { this.copyId = copyId; this.isbn = isbn; }
void lend() {
if (status != CopyStatus.AVAILABLE) throw new IllegalStateException(copyId + " is " + status);
status = CopyStatus.ON_LOAN;
}
void giveBack() {
if (status != CopyStatus.ON_LOAN) throw new IllegalStateException(copyId + " is not on loan");
status = CopyStatus.AVAILABLE;
}
String copyId() { return copyId; }
String isbn() { return isbn; }
boolean isAvailable() { return status == CopyStatus.AVAILABLE; }
}
class Member {
private final String id;
private final int maxLoans;
Member(String id, int maxLoans) { this.id = id; this.maxLoans = maxLoans; }
String id() { return id; }
boolean canBorrow(int activeLoans) { return activeLoans < maxLoans; } // the member knows its own limit
}
class Loan {
private final String id;
private final String copyId;
private final String memberId;
private final LocalDate dueOn;
private LocalDate returnedOn;
Loan(String id, String copyId, String memberId, LocalDate borrowedOn, int days) {
this.id = id; this.copyId = copyId; this.memberId = memberId;
this.dueOn = borrowedOn.plusDays(days);
}
void close(LocalDate on) {
if (returnedOn != null) throw new IllegalStateException("loan " + id + " already closed");
returnedOn = on;
}
long daysOverdue(LocalDate on) { // the loan knows its dates
return Math.max(0, ChronoUnit.DAYS.between(dueOn, on));
}
boolean isActive() { return returnedOn == null; }
String id() { return id; }
String copyId() { return copyId; }
String memberId() { return memberId; }
}
interface FinePolicy {
long finePaise(long daysOverdue);
}
class PerDayFine implements FinePolicy {
private final long perDayPaise, capPaise;
PerDayFine(long perDayPaise, long capPaise) { this.perDayPaise = perDayPaise; this.capPaise = capPaise; }
public long finePaise(long daysOverdue) { return Math.min(capPaise, daysOverdue * perDayPaise); }
}
class CopyRepository {
private final Map<String, BookCopy> byId = new LinkedHashMap<>(); // insertion order: deterministic demos
void save(BookCopy c) { byId.put(c.copyId(), c); }
Optional<BookCopy> find(String copyId) { return Optional.ofNullable(byId.get(copyId)); }
Optional<BookCopy> firstAvailable(String isbn) {
return byId.values().stream().filter(c -> c.isbn().equals(isbn) && c.isAvailable()).findFirst();
}
}
class LoanRepository {
private final Map<String, Loan> byId = new HashMap<>();
void save(Loan l) { byId.put(l.id(), l); }
List<Loan> activeFor(String memberId) {
return byId.values().stream().filter(l -> l.memberId().equals(memberId) && l.isActive()).toList();
}
Optional<Loan> activeForCopy(String copyId) {
return byId.values().stream().filter(l -> l.copyId().equals(copyId) && l.isActive()).findFirst();
}
}
record ReturnReceipt(String loanId, long finePaise) {}
class LibraryService {
private static final int LOAN_DAYS = 14;
private final Map<String, Member> members;
private final CopyRepository copies;
private final LoanRepository loans;
private final FinePolicy fines;
private final AtomicLong ids = new AtomicLong(1);
LibraryService(Map<String, Member> members, CopyRepository copies, LoanRepository loans, FinePolicy fines) {
this.members = members; this.copies = copies; this.loans = loans; this.fines = fines;
}
/** Use case: a member borrows any available copy of a title. */
Loan checkout(String memberId, String isbn, LocalDate today) {
Member m = members.get(memberId);
if (m == null) throw new IllegalArgumentException("unknown member " + memberId);
if (!m.canBorrow(loans.activeFor(memberId).size())) throw new IllegalStateException("loan limit reached");
BookCopy copy = copies.firstAvailable(isbn).orElseThrow(() -> new IllegalStateException("no copy available"));
copy.lend(); // the copy guards its own state
Loan loan = new Loan("L" + ids.getAndIncrement(), copy.copyId(), memberId, today, LOAN_DAYS);
loans.save(loan);
return loan;
}
/** Use case: a copy comes back; close the loan and compute the fine. */
ReturnReceipt returnCopy(String copyId, LocalDate today) {
Loan loan = loans.activeForCopy(copyId).orElseThrow(() -> new IllegalStateException("no active loan for " + copyId));
loan.close(today);
copies.find(copyId).orElseThrow().giveBack();
return new ReturnReceipt(loan.id(), fines.finePaise(loan.daysOverdue(today)));
}
}
Code that ranLibrary.java: the rest of the file, including the main method behind the output below
class Demo {
public static void main(String[] args) {
CopyRepository copies = new CopyRepository();
copies.save(new BookCopy("C1", "978-0134685991"));
copies.save(new BookCopy("C2", "978-0134685991"));
LibraryService lib = new LibraryService(Map.of("m1", new Member("m1", 2)), copies, new LoanRepository(), new PerDayFine(1_000, 50_000));
LocalDate d0 = LocalDate.of(2026, 10, 1);
Loan a = lib.checkout("m1", "978-0134685991", d0);
Loan b = lib.checkout("m1", "978-0134685991", d0);
List<String> out = new ArrayList<>(List.of(a.copyId(), b.copyId()));
try { lib.checkout("m1", "978-0134685991", d0); } catch (IllegalStateException e) { out.add(e.getMessage()); }
out.add("fine " + lib.returnCopy(a.copyId(), d0.plusDays(20)).finePaise());
out.add("fine " + lib.returnCopy(b.copyId(), d0.plusDays(10)).finePaise());
System.out.println(out);
assert out.get(2).equals("loan limit reached") && out.get(3).equals("fine 6000") && out.get(4).equals("fine 0");
}
}
[C1, C2, loan limit reached, fine 6000, fine 0]
8.8Common mistakes
Common mistakes
- Drawing classes before agreeing on use cases and scope.
- One class for a type and its instances (
Bookwith a status, so a title with three copies is three books with duplicated metadata). - Anemic entities with every rule in a service, or a god entity that does everything.
- Services that reach into entity fields (
copy.status = ON_LOAN) instead of asking (copy.lend()). - No service method per use case, so the driver contains business logic.
- Interfaces on everything instead of on the variation points the requirements name.
- Using
LocalDate.now()inside business logic, making behavior untestable; pass the date or inject aClock. - Forgetting the hard parts (status transitions, concurrent access, invalid input) until the interviewer asks.
8.9Self-check
- What does the "information expert" rule say, and where does it put
daysOverdue?Reveal
Give a responsibility to the class that holds the information it needs. The loan holds the due date, so
Loan.daysOverdue(date)computes it. - Name the type/instance pair in a movie booking system and why it matters.
Reveal
Movie(title, runtime) andShow(a screening at a time on a screen), and per show aShowSeatwith its own status. Seat availability belongs to the show, not to the movie or the physical seat. - Why should a service method take
todayas a parameter or use an injectedClock?Reveal
So tests control time: overdue fines, expiry, and windows can be tested deterministically without waiting or mocking statics.
- What should a repository interface contain in a machine coding round?
Reveal
save,findById, and only the queries the use cases need (firstAvailable(isbn),activeFor(memberId)), backed by maps. No business rules. - A requirement says "the late fee may differ for premium members." What changes in the library design?
Reveal
Nothing structural: a different
FinePolicyis chosen per membership tier (a policy factory, or the member exposes its tier and a map picks the policy).LoanandLibraryServicedo not change.
Mastery checklist
Tick an item only when it is true with your notes closed.
Module 9: Concurrency for LLD
The follow-up that separates SDE-2 answers in LLD rounds is "what if two gates, two users, or two threads do this at the same time?" This module teaches the two races behind almost every bug, how to choose lock granularity, the Java tools that make check-and-act atomic, producer-consumer queues, deadlock-free lock ordering, and optimistic versioning. Every example runs with real threads.
if (!map.containsKey(seat)) map.put(seat, user);uses aConcurrentHashMap. Is it thread-safe?Reveal
No. Each call is atomic, but the pair is not: two threads can both see the key missing and both put. Use
putIfAbsent, which checks and acts in one atomic step.- Why is
count++on a sharedintwrong with several threads?Reveal
It is three steps (read, add, write). Two threads can read the same value and both write value + 1, losing an update. Use
AtomicInteger,LongAdder, or a lock. - Two threads transfer money between accounts A and B in opposite directions, each locking the source then the target. What can happen?
Reveal
Deadlock: thread 1 holds A and waits for B, thread 2 holds B and waits for A. Lock in a global order (lower account id first) to remove the cycle.
- Pessimistic vs optimistic locking in one line each?
Reveal
Pessimistic: lock before reading, so others wait. Optimistic: read freely, then write only if the version is unchanged, and retry on conflict. Optimistic wins when conflicts are rare.
- What does a bounded
BlockingQueuegive an async logger that an unbounded one does not?Reveal
Backpressure: when the writer falls behind, producers wait or drop instead of filling memory until the process dies.
9.1Why LLD rounds ask about concurrency
For each problem, name the shared state that two threads could race on: parking lot, movie booking, rate limiter, LRU cache, wallet.
Reveal the answer
Parking lot: a spot's occupant (two gates assign the same spot). Movie booking: a show seat's status (two users hold the same seat). Rate limiter: a key's token count or window (two requests both pass the last token). LRU cache: the map and the linked list together (a get reorders while a put evicts). Wallet: a balance (two debits both pass the balance check).
A race happens when two threads touch shared mutable state and at least one writes, without a rule that orders them. Every fix either removes sharing, removes mutation, or adds that ordering rule.
Two cashiers selling the last concert ticket from one shared paper list. Each checks the list, sees the seat free, and sells it. A rule like "only the cashier holding the pen can check and write" fixes it; so does "one cashier per row".
Locks serialize work and can deadlock; too many locks make code hard to reason about. Find the smallest region that must be atomic and protect exactly that.
Java's memory model gives three guarantees you must ask for explicitly:
| Problem | What goes wrong | Tools that fix it |
|---|---|---|
| Atomicity | A multi-step update is interleaved with another thread's | synchronized, Lock, atomics, ConcurrentHashMap.compute |
| Visibility | A thread keeps reading a stale cached value | volatile, synchronized, atomics, concurrent collections |
| Ordering | Writes appear in a different order to another thread | Same tools: each creates a happens-before relationship |
In an LLD round, answer concurrency in three sentences: what the shared state is, what must be atomic, and which lock or atomic operation protects it at what granularity. "Each ShowSeat is the unit of contention; holding a seat is a compare-and-set from AVAILABLE to HELD; two users on different seats never wait for each other."
9.2Two races to recognize
Two gates call spot.isFree() and then spot.occupy(vehicle) at the same time. Write down one interleaving that gives the spot to both cars.
Reveal the answer
Gate A: isFree() returns true. Gate B: isFree() returns true. Gate A: occupy(KA01). Gate B: occupy(KA02), overwriting A. Both drivers get tickets for spot S7. The check and the act must happen as one atomic step.
Nearly every concurrency bug in LLD problems is one of two shapes:
| Race | Shape | Examples | Fix |
|---|---|---|---|
| Check-then-act | if (condition) act() on shared state |
Spot free then occupy; seat available then hold; balance enough then debit | Make check and act one atomic step |
| Read-modify-write | x = f(x) on shared state |
count++, balance -= amt, token bucket refill |
Atomics, a lock, or a compare-and-set loop |
class SeatMapUnsafe {
private final Map<String, String> holder = new ConcurrentHashMap<>(); // a thread-safe map...
boolean hold(String seat, String user) {
if (!holder.containsKey(seat)) { // check
holder.put(seat, user); // then act: another thread may have held it in between
return true;
}
return false;
}
}
class SeatMap {
private final Map<String, String> holder = new ConcurrentHashMap<>();
boolean hold(String seat, String user) {
return holder.putIfAbsent(seat, user) == null; // check and act in one atomic step
}
}
class Counters {
int plain = 0; // count++ is read, add, write: three steps
final AtomicInteger atomic = new AtomicInteger();
private int locked = 0;
synchronized void incLocked() { locked++; }
synchronized int locked() { return locked; }
}
Code that ranRaces.java: the rest of the file, including the main method behind the output below
class Demo {
public static void main(String[] args) throws Exception {
// 16 users race for one seat, many times over
int unsafeDoubleBookings = 0, safeDoubleBookings = 0;
for (int round = 0; round < 300; round++) {
unsafeDoubleBookings += race(new SeatMapUnsafe()::hold) > 1 ? 1 : 0;
safeDoubleBookings += race(new SeatMap()::hold) > 1 ? 1 : 0;
}
System.out.println("rounds where SeatMap gave the seat to more than one user: " + safeDoubleBookings);
assert safeDoubleBookings == 0;
if (unsafeDoubleBookings > 0) System.err.println("unsafe version double-booked in " + unsafeDoubleBookings + " rounds");
Counters c = new Counters();
ExecutorService pool = Executors.newFixedThreadPool(8);
for (int t = 0; t < 8; t++) pool.submit(() -> { for (int i = 0; i < 100_000; i++) { c.plain++; c.atomic.incrementAndGet(); c.incLocked(); } });
pool.shutdown();
pool.awaitTermination(20, TimeUnit.SECONDS);
System.out.println("atomic = " + c.atomic.get() + ", synchronized = " + c.locked() + " (expected 800000 each)");
assert c.atomic.get() == 800_000 && c.locked() == 800_000;
}
interface Hold { boolean hold(String seat, String user); }
static int race(Hold map) throws InterruptedException {
CountDownLatch start = new CountDownLatch(1);
AtomicInteger winners = new AtomicInteger();
List<Thread> ts = new ArrayList<>();
for (int u = 0; u < 16; u++) {
String user = "u" + u;
Thread t = new Thread(() -> {
try { start.await(); } catch (InterruptedException e) { return; }
if (map.hold("A12", user)) winners.incrementAndGet();
});
ts.add(t);
t.start();
}
start.countDown();
for (Thread t : ts) t.join();
return winners.get();
}
}
rounds where SeatMap gave the seat to more than one user: 0
atomic = 800000, synchronized = 800000 (expected 800000 each)
The test races 16 threads for one seat, 300 times. The safe version never gives a seat to two users. The unsafe version usually does in some rounds, but not reliably, which is exactly why these bugs survive testing.
9.3Locks and lock granularity
Six gates park cars into 100 spots. Compare one lock on the whole ParkingLot with one lock per Spot. Which is simpler, which is faster, and is either wrong?
Reveal the answer
Both are correct if the check and the act sit under the same lock. One lot-wide lock is simpler but serializes every gate, even when they park in different spots. A lock per spot lets gates proceed in parallel and contend only when they try the same spot; the scan just moves on when tryOccupy fails.
class ParkingLotCoarse {
private final List<Spot> spots;
ParkingLotCoarse(List<Spot> spots) { this.spots = spots; }
synchronized Optional<Spot> park(String vehicle) { // one lock: every gate waits for every other gate
for (Spot s : spots) {
if (s.isFree()) { s.occupy(vehicle); return Optional.of(s); }
}
return Optional.empty();
}
}
class Spot {
final String id;
private String vehicle; // guarded by "this"
Spot(String id) { this.id = id; }
synchronized boolean isFree() { return vehicle == null; }
synchronized void occupy(String v) { vehicle = v; }
synchronized boolean tryOccupy(String v) { // check and act under the spot's own lock
if (vehicle != null) return false;
vehicle = v;
return true;
}
synchronized void release() { vehicle = null; }
}
class ParkingLot {
private final List<Spot> spots;
ParkingLot(List<Spot> spots) { this.spots = spots; }
Optional<Spot> park(String vehicle) {
for (Spot s : spots) {
if (s.tryOccupy(vehicle)) return Optional.of(s); // gates contend only on the same spot
}
return Optional.empty();
}
}
Code that ranLocks.java: the rest of the file, including the main method behind the output below
class Printer {
private final ReentrantLock lock = new ReentrantLock();
boolean printIfFree(String job, List<String> out) throws InterruptedException {
if (!lock.tryLock(50, TimeUnit.MILLISECONDS)) return false; // give up instead of waiting forever
try {
out.add(job);
return true;
} finally {
lock.unlock(); // always in finally
}
}
}
class Demo {
public static void main(String[] args) throws Exception {
List<Spot> spots = new ArrayList<>();
for (int i = 0; i < 100; i++) spots.add(new Spot("S" + i));
ParkingLot lot = new ParkingLot(spots);
Set<String> taken = ConcurrentHashMap.newKeySet();
ExecutorService gates = Executors.newFixedThreadPool(6);
for (int g = 0; g < 6; g++) {
int gate = g;
gates.submit(() -> { for (int i = 0; i < 30; i++) lot.park("G" + gate + "-" + i).ifPresent(s -> { if (!taken.add(s.id)) throw new AssertionError("double"); }); });
}
gates.shutdown();
gates.awaitTermination(10, TimeUnit.SECONDS);
System.out.println("180 cars, 100 spots: " + taken.size() + " spots taken, no spot given twice");
assert taken.size() == 100;
List<String> out = new ArrayList<>();
assert new Printer().printIfFree("doc", out) && new ParkingLotCoarse(List.of(new Spot("x"))).park("v").isPresent();
}
}
180 cars, 100 spots: 100 spots taken, no spot given twice
ReentrantLock adds what synchronized cannot do:
class Printer {
private final ReentrantLock lock = new ReentrantLock();
boolean printIfFree(String job, List<String> out) throws InterruptedException {
if (!lock.tryLock(50, TimeUnit.MILLISECONDS)) return false; // give up instead of waiting forever
try {
out.add(job);
return true;
} finally {
lock.unlock(); // always in finally
}
}
}
synchronized |
ReentrantLock |
|
|---|---|---|
| Syntax | Block or method; released automatically | lock() and unlock() in finally |
| Try without blocking, or with a timeout | No | tryLock(), tryLock(time, unit) |
| Interruptible wait | No | lockInterruptibly() |
| Fairness option | No | new ReentrantLock(true) |
| Several wait conditions | One (wait/notify) |
Many Condition objects |
For read-heavy structures, ReentrantReadWriteLock lets many readers in at once and gives writers exclusive access.
On Java 21, a virtual thread that blocks inside a synchronized block pins its carrier thread, so prefer ReentrantLock around blocking I/O when using virtual threads. Java 24 removed most of this pinning. For LLD rounds, mention it only if virtual threads come up.
9.4Atomics and ConcurrentHashMap
Count hits per endpoint from many threads, and decrement stock only if enough is left, without writing synchronized anywhere. Which methods do you use?
Reveal the answer
For counts: map.computeIfAbsent(endpoint, k -> new LongAdder()).increment(). For stock: map.computeIfPresent(sku, (k, have) -> have >= qty ? have - qty : have) and track whether the update happened. Both run the whole lambda atomically for that key.
ConcurrentHashMap makes per-key operations atomic. The methods that take a function are the ones that solve check-then-act:
| Method | Atomic per key | Typical use |
|---|---|---|
putIfAbsent(k, v) |
Insert only if missing | Hold a seat, claim a lock |
computeIfAbsent(k, f) |
Create once | Per-key bucket, per-user state |
compute(k, f) / computeIfPresent |
Read, decide, write | Decrement stock if enough |
merge(k, v, f) |
Add to existing | Counters, sums |
replace(k, old, new) |
Compare-and-set a value | Optimistic updates |
class Metrics {
private final Map<String, LongAdder> hits = new ConcurrentHashMap<>();
void hit(String endpoint) {
// computeIfAbsent is atomic per key: one LongAdder per endpoint, even under races
hits.computeIfAbsent(endpoint, k -> new LongAdder()).increment();
}
long count(String endpoint) {
LongAdder a = hits.get(endpoint);
return a == null ? 0 : a.sum();
}
}
class Inventory {
private final Map<String, Integer> stock = new ConcurrentHashMap<>();
void add(String sku, int qty) { stock.merge(sku, qty, Integer::sum); } // atomic read-modify-write
boolean take(String sku, int qty) {
boolean[] ok = {false};
stock.computeIfPresent(sku, (k, have) -> { // the whole check-and-update runs atomically for this key
if (have < qty) return have;
ok[0] = true;
return have - qty;
});
return ok[0];
}
int available(String sku) { return stock.getOrDefault(sku, 0); }
}
Atomics (AtomicInteger, AtomicLong, AtomicReference) update one variable with compare-and-set. LongAdder is faster than AtomicLong for counters with heavy contention. When the update depends on the current value, use a CAS loop:
class HighWaterMark {
private final AtomicLong max = new AtomicLong(Long.MIN_VALUE);
void observe(long value) {
long cur;
do {
cur = max.get();
if (value <= cur) return; // nothing to do
} while (!max.compareAndSet(cur, value)); // retry if another thread changed it first
}
long get() { return max.get(); } // AtomicLong.accumulateAndGet(value, Math::max) does the same
}
Code that ranAtomics.java: the rest of the file, including the main method behind the output below
class Demo {
public static void main(String[] args) throws Exception {
Metrics m = new Metrics();
Inventory inv = new Inventory();
inv.add("SKU-1", 1_000);
HighWaterMark hw = new HighWaterMark();
AtomicLong taken = new AtomicLong();
ExecutorService pool = Executors.newFixedThreadPool(8);
for (int t = 0; t < 8; t++) {
int id = t;
pool.submit(() -> {
for (int i = 0; i < 10_000; i++) {
m.hit("/pay");
hw.observe(id * 10_000L + i);
if (inv.take("SKU-1", 1)) taken.incrementAndGet();
}
});
}
pool.shutdown();
pool.awaitTermination(20, TimeUnit.SECONDS);
System.out.println("hits " + m.count("/pay") + ", max " + hw.get() + ", taken " + taken.get() + ", left " + inv.available("SKU-1"));
assert m.count("/pay") == 80_000 && hw.get() == 79_999 && taken.get() == 1_000 && inv.available("SKU-1") == 0;
}
}
hits 80000, max 79999, taken 1000, left 0
Keep functions passed to compute short and free of side effects on other keys: they run while that key's bin is locked, and calling back into the same map from inside can deadlock or throw. Atomicity covers one key; an invariant that spans two keys still needs a lock.
9.5Producer-consumer with BlockingQueue
Request threads must log without waiting for disk writes. Design it so the logger cannot run out of memory when the disk is slow, and no log line is lost on a clean shutdown.
Reveal the answer
Request threads offer lines into a bounded BlockingQueue with a short timeout; one writer thread takes and writes them. A full queue makes offer return false, so you can count or drop instead of growing memory. On shutdown, put a sentinel object into the queue and join the writer: everything queued before the sentinel gets written.
final class AsyncLogger { // final: the constructor starts a thread, so no subclass can see a half-built object
private static final String POISON = new String("stop"); // a unique object: compared by reference, never equal to a message
private final BlockingQueue<String> queue = new ArrayBlockingQueue<>(1_024); // bounded: memory cannot explode
private final List<String> sink = Collections.synchronizedList(new ArrayList<>());
private final Thread worker;
AsyncLogger() {
worker = new Thread(() -> {
try {
while (true) {
String msg = queue.take(); // blocks while empty
if (msg == POISON) return; // same reference: our own sentinel
sink.add(msg); // the slow part: file, network, ...
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "log-writer");
worker.start();
}
/** Called by request threads; never waits more than 10 ms. */
boolean log(String msg) throws InterruptedException {
return queue.offer(msg, 10, TimeUnit.MILLISECONDS); // false = queue full: drop or count it
}
void shutdown() throws InterruptedException {
queue.put(POISON); // everything queued before it is still written
worker.join();
}
List<String> written() { return sink; }
}
Code that ranProducerConsumer.java: the rest of the file, including the main method behind the output below
class Demo {
public static void main(String[] args) throws Exception {
AsyncLogger log = new AsyncLogger();
List<Thread> producers = new ArrayList<>();
for (int p = 0; p < 4; p++) {
int id = p;
Thread t = new Thread(() -> {
for (int i = 0; i < 250; i++) {
try { while (!log.log("p" + id + "-" + i)) { /* retry when full */ } } catch (InterruptedException e) { return; }
}
});
producers.add(t);
t.start();
}
for (Thread t : producers) t.join();
log.shutdown();
System.out.println("written " + log.written().size() + " of 1000 messages before shutdown");
assert log.written().size() == 1000;
}
}
written 1000 of 1000 messages before shutdown
The same structure powers thread pools: ExecutorService is a producer-consumer queue of Runnable commands plus worker threads. Shut one down with shutdown() (finish queued work) and awaitTermination, or shutdownNow() (interrupt workers and return queued tasks).
9.6Deadlock and lock ordering
transfer(from, to) locks from, then to. Thread 1 runs A to B while thread 2 runs B to A. Show the deadlock, then fix it without a global lock.
Reveal the answer
Thread 1 locks A and waits for B; thread 2 locks B and waits for A; neither can proceed. Fix: always lock the account with the lower id first, regardless of direction. Then both threads try A first, one waits, and no cycle can form.
class TransferServiceDeadlocks {
void transfer(Account from, Account to, long amt) {
synchronized (from) { // thread 1 locks A, thread 2 locks B ...
synchronized (to) { // ... then each waits for the other's lock forever
from.debit(amt);
to.credit(amt);
}
}
}
}
class TransferService {
void transfer(Account from, Account to, long amt) {
if (from == to) throw new IllegalArgumentException("same account");
Account first = from.id < to.id ? from : to; // every thread locks the lower id first
Account second = first == from ? to : from;
synchronized (first) {
synchronized (second) {
from.debit(amt); // throws before credit if funds are short: no half transfer
to.credit(amt);
}
}
}
}
Code that ranDeadlock.java: the rest of the file, including the main method behind the output below
class Account {
final long id;
private long balancePaise;
Account(long id, long balancePaise) { this.id = id; this.balancePaise = balancePaise; }
void debit(long amt) {
if (amt > balancePaise) throw new IllegalStateException("insufficient funds in " + id);
balancePaise -= amt;
}
void credit(long amt) { balancePaise += amt; }
long balance() { return balancePaise; }
}
// ... (code shown above) ...
class Demo {
public static void main(String[] args) throws Exception {
Account a = new Account(1, 1_000_000), b = new Account(2, 1_000_000);
TransferService svc = new TransferService();
ExecutorService pool = Executors.newFixedThreadPool(8);
for (int t = 0; t < 8; t++) {
boolean ab = t % 2 == 0;
pool.submit(() -> { for (int i = 0; i < 20_000; i++) { if (ab) svc.transfer(a, b, 1); else svc.transfer(b, a, 1); } });
}
pool.shutdown();
boolean finished = pool.awaitTermination(20, TimeUnit.SECONDS);
System.out.println("finished without deadlock: " + finished + "; total " + (a.balance() + b.balance()));
assert finished && a.balance() + b.balance() == 2_000_000;
new TransferServiceDeadlocks().transfer(a, b, 1);
assert List.of(a, b).size() == 2;
}
}
finished without deadlock: true; total 2000000
A deadlock needs four conditions at once; removing any one prevents it:
| Condition | How to break it |
|---|---|
| Mutual exclusion | Use lock-free structures or immutable data |
| Hold and wait | Acquire everything up front, or release before waiting |
| No preemption | Use tryLock with a timeout and back off |
| Circular wait | Order locks globally (by id), the standard fix |
9.7Optimistic concurrency with versions
A wallet is updated by many threads but conflicts are rare. Describe how to update it safely without holding a lock while computing the new balance.
Reveal the answer
Store a version with the balance. Read the row and its version, compute the new balance, then write it only if the version is still the one you read (compare-and-set), incrementing the version. If the write fails, someone else won: re-read and retry, with a retry limit.
record WalletRow(String id, long balancePaise, long version) {}
class OptimisticConflict extends RuntimeException {
OptimisticConflict(String m) { super(m); }
}
class WalletStore {
private final ConcurrentHashMap<String, WalletRow> rows = new ConcurrentHashMap<>();
void insert(WalletRow r) { rows.put(r.id(), r); }
WalletRow read(String id) { return rows.get(id); }
/** Like UPDATE ... SET ..., version = version + 1 WHERE id = ? AND version = ? */
void update(WalletRow expected, long newBalance) {
WalletRow next = new WalletRow(expected.id(), newBalance, expected.version() + 1);
if (!rows.replace(expected.id(), expected, next)) // atomic compare-and-set on the whole row
throw new OptimisticConflict("row " + expected.id() + " changed since version " + expected.version());
}
}
class WalletService {
private final WalletStore store;
final AtomicInteger retries = new AtomicInteger();
WalletService(WalletStore store) { this.store = store; }
void apply(String id, UnaryOperator<Long> change) {
for (int attempt = 1; attempt <= 50; attempt++) {
WalletRow row = store.read(id); // read without locking
long next = change.apply(row.balancePaise()); // compute
try {
store.update(row, next); // write only if nobody else wrote in between
return;
} catch (OptimisticConflict e) {
retries.incrementAndGet(); // someone won the race: re-read and retry
}
}
throw new IllegalStateException("too much contention on " + id);
}
}
Code that ranOptimistic.java: the rest of the file, including the main method behind the output below
class Demo {
public static void main(String[] args) throws Exception {
WalletStore store = new WalletStore();
store.insert(new WalletRow("w1", 0, 0));
WalletService svc = new WalletService(store);
ExecutorService pool = Executors.newFixedThreadPool(8);
for (int t = 0; t < 8; t++) pool.submit(() -> { for (int i = 0; i < 1_000; i++) svc.apply("w1", bal -> bal + 100); });
pool.shutdown();
pool.awaitTermination(20, TimeUnit.SECONDS);
WalletRow end = store.read("w1");
System.out.println("balance " + end.balancePaise() + ", version " + end.version() + " (8,000 updates, none lost)");
assert end.balancePaise() == 800_000 && end.version() == 8_000;
}
}
balance 800000, version 8000 (8,000 updates, none lost)
| Pessimistic (lock first) | Optimistic (version check) | |
|---|---|---|
| Waits | Others block while you hold the lock | Nobody blocks; losers retry |
| Best when | Conflicts are frequent, or retrying is expensive | Conflicts are rare |
| Database form | SELECT ... FOR UPDATE |
UPDATE ... WHERE id = ? AND version = ? |
| JPA | @Lock(PESSIMISTIC_WRITE) |
@Version field |
In-memory locks protect one process only. With several instances of a service, the database becomes the arbiter: a conditional UPDATE (check the affected row count), a unique constraint, or row locks. Machine coding rounds stay in one process; in an LLD discussion, say where the lock would move in production.
9.8Thread-safety checklist
List four ways to make a class safe to use from several threads, from cheapest to most expensive.
Reveal the answer
Make it immutable (nothing to protect). Confine its state to one thread (each request gets its own object, or a single worker owns it). Use thread-safe building blocks (concurrent collections, atomics). Guard the remaining shared mutable state with locks at the right granularity.
Work through this list for every LLD problem:
- Find the shared mutable state. Which objects are touched by more than one thread, and which fields change?
- Remove what you can. Value objects and configuration immutable; per-request objects confined to their thread.
- Name the invariant that must hold ("a seat has at most one holder") and the smallest unit it covers (one seat).
- Choose the tool: an atomic map operation, an atomic variable, a per-object lock, ordered locks for multi-object updates, or optimistic versions.
- Check the multi-object operations (transfers, swaps, moving items between lists) for lock ordering.
- Say what changes with several processes: the database or a distributed lock takes over.
9.9Common mistakes
Common mistakes
- Assuming a concurrent collection makes compound actions atomic (
containsKeythenput). - Locking the whole system when the invariant covers one object, so gates or users wait for each other needlessly.
- Locking different objects for the same data (reads under one lock, writes under another), which protects nothing.
- Calling slow or external code while holding a lock: network calls, logging to disk, callbacks to listeners.
- Locks taken in inconsistent order across methods, waiting to deadlock.
unlock()outside afinallyblock, so an exception leaves the lock held forever.- Unbounded queues between fast producers and a slow consumer.
- Claiming in-memory locks protect a multi-instance service.
9.10Self-check
- Why does
putIfAbsentfix the seat race but not "hold seats A12 and A13 together, or neither"?Reveal
putIfAbsentis atomic for one key. Holding two seats atomically spans two keys, so you need a lock covering both (taken in a fixed order) or a single conditional update over both rows in a database transaction. - When would you prefer
LongAdderoverAtomicLong?Reveal
For counters updated by many threads and read rarely.
LongAdderspreads updates over internal cells to reduce contention;sum()adds them up.AtomicLongis better when you need compare-and-set or frequent exact reads. - How do you shut down an async logger without losing queued lines?
Reveal
Stop accepting new lines, put a sentinel into the queue, and join the writer thread. The writer drains everything before the sentinel and then exits.
- Give the standard fix for deadlock in money transfers and explain why it works.
Reveal
Always lock the two accounts in a global order (by id). Every thread acquires locks in the same sequence, so a cycle of waits cannot form.
- When is optimistic locking a bad choice?
Reveal
When conflicts are frequent (a hot row everyone updates), because most attempts fail and retry, wasting work; or when the work done before the write is expensive or has side effects that cannot be repeated.
Mastery checklist
Tick an item only when it is true with your notes closed.
Module 10: Machine coding craft
A machine coding round asks for working, readable, extensible code in 90 to 120 minutes, and it is graded by people who run it and then ask you to change it. This module is the craft: how the round is scored, a project layout you can type from memory, generic in-memory repositories, the collections that solve most LLD problems, errors and edge cases, a command-driven driver, tests in a time box, and how to stay extensible without over-engineering.
- It is minute 70 of a 90-minute round. Your design is elegant but the main flow does not run. What matters most now?
Reveal
Getting the happy path running end to end. Most rubrics treat a working demo as the gate: an unfinished elegant design usually scores below a simpler one that runs.
- Which JDK class gives you an LRU cache in about ten lines?
Reveal
LinkedHashMapwithaccessOrder = trueand an overriddenremoveEldestEntrythat returnssize() > capacity. - Check whether a new meeting [10:30, 11:30) overlaps existing ones in O(log n). Which collection?
Reveal
A
TreeMapfrom start to end: check the entry atfloorEntry(start)(does it end after our start?) and atceilingEntry(start)(does it start before our end?). - A lookup by ID finds nothing. Return
Optionalor throw?Reveal
For a query where absence is normal (
findById), returnOptional. For a command that needs the object to exist (transfer(from, to)), throw a named exception such asNotFoundException. - Why use
LinkedHashMapinstead ofHashMapin in-memory repositories?Reveal
Insertion order makes listings and demo output deterministic, so the reviewer sees the same output every run and you can compare it against expectations.
10.1What the round tests
List the things an evaluator checks after your machine coding round ends, in the order they usually check them.
Reveal the answer
- Does it run, and do the required flows work (often with their own inputs)? 2. Is the code modular: models, services, and strategies in sensible places? 3. Can a new requirement be added quickly (they often ask for one live)? 4. Are invalid inputs and edge cases handled with clear errors? 5. Is it readable: names, small methods, no duplication? 6. Concurrency, if it was part of the prompt.
Machine coding is LLD under a clock with a compiler as a second interviewer. The design is judged through the code: if it does not run, nobody sees the design.
A cooking competition. The judges taste the dish first; plating and technique matter only once it is edible.
Time spent on patterns, frameworks, or perfect abstractions is time not spent getting the flows working. Spend design effort only where the prompt says change will come.
What evaluators score, and what earns each:
| Dimension | Earns full marks |
|---|---|
| Working code | All required commands or methods run; output matches the format given |
| Modularity | Model, repository, strategy, service, driver layers; no logic in the driver |
| Extensibility | The variation the prompt mentions is behind an interface; a new type is a new class |
| Correctness at the edges | Invalid input rejected with clear messages; limits and empty states handled |
| Readability | Domain names, short methods, immutable values, no copy-paste |
| Concurrency (if asked) | Shared state identified and protected at the right granularity |
| Communication | Clarified scope early, explained trade-offs, walked through the code |
Typical setups: a problem statement with sample input and output; your own IDE; no database (in-memory is expected); 90 to 120 minutes; then 15 to 30 minutes of review where you demo, explain, and add a feature. The round timer and scoring rubric are on the interview playbook page.
Do not use frameworks (Spring, Lombok) unless the prompt allows them: reviewers often want to see plain Java design, and setup problems eat your time. Plain Java with records and collections is enough for every problem in this handbook.
10.2Project layout
Without looking, write the packages you would create for a parking lot solution and what goes in each.
Reveal the answer
model (entities, value objects, enums), repository (interfaces and in-memory implementations), strategy (spot allocation, pricing), service (one class coordinating the use cases), exception (named failures), and a Main or Driver that parses input and calls the service.
Aim for a compiling skeleton (models, exceptions, repository interfaces, service method signatures) by minute 30, and the happy path running end to end by minute 65. Everything after that is edges, tests, and polish.
10.3The starter project
A complete skeleton of this layout, with one tiny example per layer (a library that lends books), ships as lld-starter.zip alongside this handbook: Maven with JUnit 5, a generic repository, readable IDs, an exception family, a command driver, a demo script, and tests including a concurrency test. Every file is also below, so the handbook alone is enough. Rebuild it from memory once a week until it takes under ten minutes; in a round, you type it while you think about the model.
import app.driver.CommandDriver;
import app.model.Book;
import app.repository.InMemoryRepository;
import app.service.LibraryService;
import app.strategy.PerDayAfterGrace;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.stream.Collectors;
/**
* Assumptions (write yours here during the round):
* 1. In memory only; one library.
* 2. Late fee: 14 free days, then 5 rupees a day.
*/
public class Main {
public static void main(String[] args) throws Exception {
LibraryService library = new LibraryService(new InMemoryRepository<>(), new PerDayAfterGrace(14, 500));
CommandDriver driver = new CommandDriver()
.on("add", a -> "added " + library.addBook(join(a, 1)))
.on("borrow", a -> { library.borrow(a[1], a[2]); return a[1] + " lent to " + a[2]; })
.on("return", a -> a[1] + " returned, late fee " + library.giveBack(a[1], Integer.parseInt(a[2])) + " paise")
.on("available", a -> lines(library.available()))
.on("list", a -> lines(library.all()));
List<String> demo = List.of("add Clean Code", "add Refactoring", "borrow B1 asha", "borrow B1 ravi",
"return B1 20", "return B1 3", "borrow B9 asha", "available", "fly away");
if (args.length > 0 && args[0].equals("-i")) { // interactive: java app.Main -i
BufferedReader in = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8));
for (String line; (line = in.readLine()) != null; ) if (!line.isBlank()) System.out.println(driver.run(line));
} else {
for (String line : demo) System.out.println("> " + line + "\n" + driver.run(line));
}
}
private static String join(String[] a, int from) { return String.join(" ", java.util.Arrays.copyOfRange(a, from, a.length)); }
private static String lines(List<Book> books) { return books.isEmpty() ? "(none)" : books.stream().map(Book::toString).collect(Collectors.joining("\n")); }
}
import app.exception.NotFoundException;
import app.exception.ValidationException;
import app.model.Book;
import app.repository.Ids;
import app.repository.Repository;
import app.strategy.LateFeePolicy;
import java.util.List;
/** One method per use case. Rules live in the entities and strategies; the service coordinates. */
public class LibraryService {
private final Repository<Book, String> books;
private final LateFeePolicy lateFees;
private final Ids ids = new Ids("B");
public LibraryService(Repository<Book, String> books, LateFeePolicy lateFees) {
this.books = books;
this.lateFees = lateFees;
}
public Book addBook(String title) { return books.save(new Book(ids.next(), title)); }
public void borrow(String bookId, String member) {
if (member == null || member.isBlank()) throw new ValidationException("member is required");
find(bookId).lendTo(member); // Book guards its own state: two borrowers, one wins
}
/** Returns the late fee in paise. */
public long giveBack(String bookId, int daysKept) {
if (daysKept < 0) throw new ValidationException("days kept cannot be negative");
find(bookId).giveBack();
return lateFees.feePaise(daysKept);
}
public List<Book> available() { return books.findAll(Book::isAvailable); }
public List<Book> all() { return books.findAll(); }
private Book find(String id) { return books.findById(id).orElseThrow(() -> new NotFoundException("book", id)); }
}
Go deeperThe remaining starter files
import app.exception.DomainException;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.Function;
/** Maps command names to handlers. Parsing lives here; no business rules. */
public class CommandDriver {
private final Map<String, Function<String[], String>> commands = new LinkedHashMap<>();
public CommandDriver on(String name, Function<String[], String> handler) {
commands.put(name, handler);
return this;
}
/** Runs one line such as "borrow B1 asha" and returns what to print. */
public String run(String line) {
String[] parts = line.trim().split("\\s+");
Function<String[], String> handler = commands.get(parts[0].toLowerCase());
if (handler == null) return "unknown command: " + parts[0] + " (try: " + String.join(", ", commands.keySet()) + ")";
try {
return handler.apply(parts);
} catch (DomainException e) {
return "error: " + e.getMessage();
} catch (ArrayIndexOutOfBoundsException | NumberFormatException e) {
return "error: bad arguments for " + parts[0];
}
}
}
import app.exception.ConflictException;
import app.exception.ValidationException;
/** Example entity: validates in the constructor, guards its own state, changes only through named methods. Replace it. */
public class Book implements Identifiable<String> {
private final String id;
private final String title;
private String borrower; // guarded by this; null when on the shelf
public Book(String id, String title) {
if (title == null || title.isBlank()) throw new ValidationException("title is required");
this.id = id;
this.title = title.strip();
}
public synchronized void lendTo(String member) {
if (borrower != null) throw new ConflictException(id + " is already lent to " + borrower);
borrower = member;
}
public synchronized void giveBack() {
if (borrower == null) throw new ConflictException(id + " is not lent out");
borrower = null;
}
@Override public String id() { return id; }
public String title() { return title; }
public synchronized boolean isAvailable() { return borrower == null; }
public synchronized String borrower() { return borrower; }
@Override public synchronized String toString() {
return id + " " + title + (borrower == null ? " [on shelf]" : " [with " + borrower + "]");
}
}
/** Anything stored in a repository has an ID. */
public interface Identifiable<ID> {
ID id();
}
import java.util.List;
import java.util.Optional;
import java.util.function.Predicate;
public interface Repository<T, ID> {
T save(T item);
Optional<T> findById(ID id);
List<T> findAll(Predicate<? super T> filter);
default List<T> findAll() { return findAll(x -> true); }
boolean delete(ID id);
}
import app.model.Identifiable;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Predicate;
/** One generic store for every entity. Insertion order keeps demo output stable. */
public class InMemoryRepository<T extends Identifiable<ID>, ID> implements Repository<T, ID> {
private final Map<ID, T> items = new LinkedHashMap<>();
@Override public synchronized T save(T item) { items.put(item.id(), item); return item; }
@Override public synchronized Optional<T> findById(ID id) { return Optional.ofNullable(items.get(id)); }
@Override public synchronized boolean delete(ID id) { return items.remove(id) != null; }
@Override
public synchronized List<T> findAll(Predicate<? super T> filter) {
List<T> out = new ArrayList<>();
for (T t : items.values()) if (filter.test(t)) out.add(t);
return out;
}
}
import java.util.concurrent.atomic.AtomicLong;
/** Readable sequential IDs: B1, B2, ... Thread-safe. */
public class Ids {
private final String prefix;
private final AtomicLong next = new AtomicLong();
public Ids(String prefix) { this.prefix = prefix; }
public String next() { return prefix + next.incrementAndGet(); }
}
/** Example variation point: the rule the prompt says will change. Replace it. */
public interface LateFeePolicy {
long feePaise(int daysKept);
}
public class PerDayAfterGrace implements LateFeePolicy {
private final int graceDays;
private final long perDayPaise;
public PerDayAfterGrace(int graceDays, long perDayPaise) {
this.graceDays = graceDays;
this.perDayPaise = perDayPaise;
}
@Override
public long feePaise(int daysKept) { return Math.max(0, daysKept - graceDays) * perDayPaise; }
}
/** Base of every expected failure. The driver prints the message; anything else is a bug. */
public class DomainException extends RuntimeException {
public DomainException(String message) { super(message); }
}
public class NotFoundException extends DomainException {
public NotFoundException(String what, Object id) { super(what + " " + id + " not found"); }
}
public class ValidationException extends DomainException {
public ValidationException(String message) { super(message); }
}
/** The request is valid but clashes with the current state (already booked, already returned). */
public class ConflictException extends DomainException {
public ConflictException(String message) { super(message); }
}
import app.exception.ConflictException;
import app.exception.NotFoundException;
import app.model.Book;
import app.repository.InMemoryRepository;
import app.strategy.PerDayAfterGrace;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class LibraryServiceTest {
private LibraryService library;
private Book book;
@BeforeEach
void setUp() {
library = new LibraryService(new InMemoryRepository<>(), new PerDayAfterGrace(14, 500));
book = library.addBook("Clean Code");
}
@Test
void happyPath() {
library.borrow(book.id(), "asha");
assertEquals(0, library.available().size());
assertEquals(3_000, library.giveBack(book.id(), 20)); // 6 late days x 500
assertEquals(1, library.available().size());
}
@Test
void cannotBorrowTwice() {
library.borrow(book.id(), "asha");
assertThrows(ConflictException.class, () -> library.borrow(book.id(), "ravi"));
}
@Test
void unknownBook() {
assertThrows(NotFoundException.class, () -> library.borrow("B99", "asha"));
}
@Test
void onlyOneOfManyConcurrentBorrowsWins() throws Exception {
AtomicInteger wins = new AtomicInteger();
ExecutorService pool = Executors.newFixedThreadPool(8);
for (int i = 0; i < 50; i++) {
String member = "m" + i;
pool.submit(() -> {
try { library.borrow(book.id(), member); wins.incrementAndGet(); } catch (ConflictException ignored) { }
});
}
pool.shutdown();
assertTrue(pool.awaitTermination(10, TimeUnit.SECONDS));
assertEquals(1, wins.get());
}
}
import app.model.Book;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class InMemoryRepositoryTest {
@Test
void keepsInsertionOrderAndFilters() {
InMemoryRepository<Book, String> repo = new InMemoryRepository<>();
repo.save(new Book("B2", "Refactoring"));
repo.save(new Book("B1", "Clean Code"));
assertEquals(List.of("B2", "B1"), repo.findAll().stream().map(Book::id).toList());
assertTrue(repo.findById("B1").isPresent());
assertEquals(1, repo.findAll(b -> b.title().startsWith("Clean")).size());
assertTrue(repo.delete("B1"));
assertTrue(repo.findById("B1").isEmpty());
}
}
> borrow B1 asha
B1 lent to asha
> borrow B1 ravi
error: B1 is already lent to asha
> return B1 20
B1 returned, late fee 3000 paise
> borrow B9 asha
error: book B9 not found
> fly away
unknown command: fly (try: add, borrow, return, available, list)
10.4Models, repositories, and IDs
You will need storage for users, groups, expenses, and payments, all in memory. How do you avoid writing four nearly identical repository classes?
Reveal the answer
One generic InMemoryRepository<T extends Identifiable<ID>, ID> backed by a LinkedHashMap, with save, findById returning Optional, and findAll(filter). Entity-specific queries live in small subclasses or in the service as filters. Each entity implements Identifiable.
interface Identifiable<ID> {
ID id();
}
interface Repository<T extends Identifiable<ID>, ID> {
T save(T entity);
Optional<T> findById(ID id);
List<T> findAll(Predicate<T> filter);
}
class InMemoryRepository<T extends Identifiable<ID>, ID> implements Repository<T, ID> {
private final Map<ID, T> store = new LinkedHashMap<>(); // insertion order: stable, predictable output
public synchronized T save(T entity) { store.put(entity.id(), entity); return entity; }
public synchronized Optional<T> findById(ID id) { return Optional.ofNullable(store.get(id)); }
public synchronized List<T> findAll(Predicate<T> filter) {
List<T> out = new ArrayList<>();
for (T t : store.values()) if (filter.test(t)) out.add(t);
return out;
}
}
final class Ids {
private final String prefix;
private final AtomicLong next = new AtomicLong(1);
Ids(String prefix) { this.prefix = prefix; }
String next() { return prefix + next.getAndIncrement(); } // readable in demos: U1, U2, B1 ...
}
record User(String id, String name, String email) implements Identifiable<String> {
User {
if (name == null || name.isBlank()) throw new IllegalArgumentException("name required");
if (email == null || !email.contains("@")) throw new IllegalArgumentException("invalid email: " + email);
}
}
Ids userIds = new Ids("U");
Repository<User, String> users = new InMemoryRepository<>();
users.save(new User(userIds.next(), "Asha", "asha@example.com"));
users.save(new User(userIds.next(), "Ravi", "ravi@example.com"));
System.out.println(users.findById("U2").map(User::name).orElse("missing"));
System.out.println(users.findAll(u -> u.email().endsWith("example.com")).size() + " users");
Code that ranRepo.java: the rest of the file, including the main method behind the output below
class Demo {
public static void main(String[] args) {
// ... (code shown above) ...
try { new User("U9", "X", "not-an-email"); throw new AssertionError(); } catch (IllegalArgumentException e) { System.out.println(e.getMessage()); }
}
}
Ravi
2 users
invalid email: not-an-email
Choices to make deliberately:
- IDs: sequential with a prefix (
U1,E7) are easy to read in demos and tests;UUID.randomUUID()when IDs must be unguessable or globally unique. - Synchronized repository methods cost nothing in a single-threaded demo and make the concurrency follow-up easier. For heavy concurrency, use
ConcurrentHashMapand atomic per-key operations (Module 9). - Repositories return copies or immutable records when callers should not mutate stored objects directly. Mutable entities with intent-named methods (
spot.occupy()) are fine when the service owns the transaction.
10.5Collections that solve LLD problems
Match each need to a collection: (a) the next job to run by priority; (b) recently used order with eviction; (c) find bookings that overlap a time range; (d) undo history; (e) per-key counters from many threads; (f) a map keyed by an enum.
Reveal the answer
(a) PriorityQueue with a comparator. (b) LinkedHashMap in access order (or your own HashMap plus doubly linked list). (c) TreeMap with floorEntry and ceilingEntry. (d) ArrayDeque as a stack. (e) ConcurrentHashMap with merge or LongAdder values. (f) EnumMap.
| Need | Collection | Key operations and cost |
|---|---|---|
| Lookup by key | HashMap |
get, put: O(1) average |
| Stable iteration order | LinkedHashMap |
Insertion order; access order for LRU |
| Sorted keys, ranges, nearest key | TreeMap |
floorKey, ceilingKey, subMap: O(log n) |
| Next by priority or time | PriorityQueue |
add, poll: O(log n); peek: O(1) |
| Stack or queue | ArrayDeque |
push/pop, offer/poll: O(1) |
| Keys from an enum | EnumMap, EnumSet |
Array-backed, fast, ordered by declaration |
| Thread-safe map | ConcurrentHashMap |
Atomic compute, merge, putIfAbsent per key |
| Producer-consumer | ArrayBlockingQueue |
put/take block; offer with timeout |
| Delayed tasks | DelayQueue, ScheduledExecutorService |
Elements become available after their delay |
Three that solve whole problems:
class LruCache<K, V> extends LinkedHashMap<K, V> {
private final int capacity;
LruCache(int capacity) {
super(16, 0.75f, true); // accessOrder = true: get() moves an entry to the end
this.capacity = capacity;
}
@Override protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > capacity; // evict the least recently used after each put
}
}
LruCache<String, Integer> cache = new LruCache<>(2);
cache.put("a", 1);
cache.put("b", 2);
cache.get("a"); // "a" is now most recent
cache.put("c", 3); // evicts "b"
System.out.println(cache.keySet()); // [a, c]
class RoomCalendar {
private final TreeMap<LocalTime, LocalTime> bookings = new TreeMap<>(); // start -> end, sorted by start
/** Books [start, end) if it overlaps nothing. O(log n). */
boolean book(LocalTime start, LocalTime end) {
if (!start.isBefore(end)) throw new IllegalArgumentException("empty interval");
Map.Entry<LocalTime, LocalTime> before = bookings.floorEntry(start); // latest booking starting at or before start
if (before != null && before.getValue().isAfter(start)) return false; // it runs past our start
Map.Entry<LocalTime, LocalTime> after = bookings.ceilingEntry(start); // earliest booking starting at or after start
if (after != null && after.getKey().isBefore(end)) return false; // it starts before we end
bookings.put(start, end);
return true;
}
}
record Job(String name, int priority, long submittedAt) {}
class JobQueue {
// highest priority first; among equals, the earliest submitted first (stable, fair)
private final PriorityQueue<Job> pq = new PriorityQueue<>(
Comparator.comparingInt(Job::priority).reversed().thenComparingLong(Job::submittedAt));
void submit(Job j) { pq.add(j); } // O(log n)
Job next() { return pq.poll(); } // O(log n); null when empty
}
Code that ranCollections.java: the rest of the file, including the main method behind the output below
class Demo {
public static void main(String[] args) {
// ... (code shown above) ...
assert cache.keySet().equals(java.util.Set.of("a", "c"));
RoomCalendar cal = new RoomCalendar();
boolean b1 = cal.book(LocalTime.of(10, 0), LocalTime.of(11, 0));
boolean b2 = cal.book(LocalTime.of(10, 30), LocalTime.of(11, 30)); // overlaps
boolean b3 = cal.book(LocalTime.of(11, 0), LocalTime.of(12, 0)); // touches: allowed
System.out.println("bookings: " + b1 + " " + b2 + " " + b3);
assert b1 && !b2 && b3;
JobQueue q = new JobQueue();
q.submit(new Job("report", 1, 1));
q.submit(new Job("payout", 5, 2));
q.submit(new Job("refund", 5, 3));
System.out.println(q.next().name() + ", " + q.next().name() + ", " + q.next().name());
Deque<Integer> stack = new ArrayDeque<>();
stack.push(1); stack.push(2);
assert stack.pop() == 2 && List.of(1).size() == 1;
}
}
[a, c]
bookings: true false true
payout, refund, report
In an LRU cache round, mention LinkedHashMap first, then build the HashMap plus doubly linked list version when asked "implement it yourself" (Problem 3). Knowing both is the SDE-2 answer.
10.6Errors and edge cases
Write five edge cases for "transfer money between two wallets" before writing any code.
Reveal the answer
Non-positive amount; transfer to self; unknown source or destination wallet; insufficient balance; overflow or precision (use long paise or BigDecimal). With concurrency: two transfers from the same wallet at once. With retries: the same request submitted twice (idempotency).
Use a small exception hierarchy named by what went wrong, so the driver can report clearly and tests can assert the exact failure:
// one base type per application; subclasses named by what went wrong
class AppException extends RuntimeException {
AppException(String message) { super(message); }
}
class NotFoundException extends AppException {
NotFoundException(String what, String id) { super(what + " not found: " + id); }
}
class ValidationException extends AppException {
ValidationException(String message) { super(message); }
}
class ConflictException extends AppException { // valid request, but the current state forbids it
ConflictException(String message) { super(message); }
}
class Wallets {
private final Map<String, Long> balances = new HashMap<>();
void create(String user, long initial) {
if (user == null || user.isBlank()) throw new ValidationException("user required");
if (initial < 0) throw new ValidationException("initial balance cannot be negative");
if (balances.putIfAbsent(user, initial) != null) throw new ConflictException("wallet exists for " + user);
}
Optional<Long> balance(String user) { // absence is normal for a lookup: Optional
return Optional.ofNullable(balances.get(user));
}
void transfer(String from, String to, long amount) { // absence is an error for a command: exception
if (amount <= 0) throw new ValidationException("amount must be positive");
if (from.equals(to)) throw new ValidationException("cannot transfer to self");
long have = balance(from).orElseThrow(() -> new NotFoundException("wallet", from));
balance(to).orElseThrow(() -> new NotFoundException("wallet", to));
if (have < amount) throw new ConflictException("insufficient balance in " + from);
balances.merge(from, -amount, Long::sum);
balances.merge(to, amount, Long::sum);
}
}
Code that ranErrors.java: the rest of the file, including the main method behind the output below
class Demo {
public static void main(String[] args) {
Wallets w = new Wallets();
w.create("alice", 100);
w.create("bob", 0);
String[][] cases = {{"alice", "bob", "30"}, {"alice", "carol", "10"}, {"bob", "alice", "500"}, {"alice", "alice", "1"}, {"alice", "bob", "-5"}};
for (String[] c : cases) {
try {
w.transfer(c[0], c[1], Long.parseLong(c[2]));
System.out.println("ok " + String.join(" ", c));
} catch (AppException e) {
System.out.println(e.getClass().getSimpleName() + ": " + e.getMessage());
}
}
assert w.balance("bob").orElseThrow() == 30;
}
}
ok alice bob 30
NotFoundException: wallet not found: carol
ConflictException: insufficient balance in bob
ValidationException: cannot transfer to self
ValidationException: amount must be positive
The edge-case checklist to run before coding each use case:
| Category | Ask |
|---|---|
| Input | null, blank, negative, zero, too large, malformed |
| Existence | Unknown IDs; duplicates; already exists |
| State | Wrong status (cancel after ship); already done (pay twice) |
| Limits | Full lot, no seats, capacity zero, empty queue |
| Money and time | Rounding and leftover paise; time zones; boundaries (exactly 7 days) |
| Concurrency | Two callers on the same object at once |
10.7The driver and the demo
The prompt gives commands like CREATE alice 100 and TRANSFER alice bob 30 and expects output lines. How do you structure the input handling so a new command is easy to add and one bad line does not crash the session?
Reveal the answer
A map from command name to a handler (the command pattern): each handler parses its own arguments and calls one service method. The driver loop looks up the handler, runs it inside a try, and prints either the result or a clean error. A new command is one new map entry.
class Driver {
private final Map<String, Function<String[], String>> commands = new LinkedHashMap<>();
Driver(WalletService wallets) {
// each command parses its own arguments and calls exactly one service method
commands.put("CREATE", a -> { wallets.create(a[1], Long.parseLong(a[2])); return "created " + a[1]; });
commands.put("TRANSFER", a -> { wallets.transfer(a[1], a[2], Long.parseLong(a[3])); return "ok"; });
commands.put("BALANCE", a -> a[1] + " " + wallets.balance(a[1]));
}
String handle(String line) {
String[] parts = line.trim().split("\\s+");
Function<String[], String> cmd = commands.get(parts[0].toUpperCase());
if (cmd == null) return "unknown command: " + parts[0];
try {
return cmd.apply(parts);
} catch (ArrayIndexOutOfBoundsException e) { // too few arguments on the line
return "error: missing arguments for " + parts[0];
} catch (NumberFormatException e) {
return "error: not a number in: " + line;
} catch (RuntimeException e) { // one bad line must not end the session
return "error: " + e.getMessage();
}
}
}
Driver d = new Driver(new WalletService());
List<String> input = List.of(
"CREATE alice 100", "CREATE bob 20", "TRANSFER alice bob 30",
"TRANSFER bob alice 500", "TRANSFER bob", "BALANCE alice", "BALANCE bob", "REFUND alice 5");
for (String line : input) System.out.println("> " + line + "\n" + d.handle(line));
Code that ranDriver.java: the rest of the file, including the main method behind the output below
class WalletService {
private final Map<String, Long> balances = new LinkedHashMap<>();
void create(String u, long amt) { if (balances.putIfAbsent(u, amt) != null) throw new IllegalStateException("exists: " + u); }
void transfer(String f, String t, long a) {
if (balances.getOrDefault(f, -1L) < a) throw new IllegalStateException("insufficient balance in " + f);
if (!balances.containsKey(t)) throw new IllegalStateException("no wallet: " + t);
balances.merge(f, -a, Long::sum);
balances.merge(t, a, Long::sum);
}
long balance(String u) {
Long b = balances.get(u);
if (b == null) throw new IllegalStateException("no wallet: " + u);
return b;
}
}
// ... (code shown above) ...
class Demo {
public static void main(String[] args) {
// ... (code shown above) ...
List<String> out = new ArrayList<>();
for (String l : input) out.add(d.handle("BALANCE alice"));
assert out.get(0).equals("alice 70");
}
}
> CREATE alice 100
created alice
> CREATE bob 20
created bob
> TRANSFER alice bob 30
ok
> TRANSFER bob alice 500
error: insufficient balance in bob
> TRANSFER bob
error: missing arguments for TRANSFER
> BALANCE alice
alice 70
> BALANCE bob
bob 50
> REFUND alice 5
unknown command: REFUND
If the prompt does not specify input, write a main that runs the main scenarios in order and prints readable results. Reviewers read the output first.
10.8Tests in a time box
You have 10 minutes for tests. Which three tests do you write first?
Reveal the answer
The core rule of the problem (for example, a split leaves no paise unaccounted for), the most important rejection (insufficient balance, double booking), and one end-to-end flow through the service. Skip tests for getters and trivial code.
If JUnit is available, a focused test reads like a specification:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class WalletsTest {
@Test
void transferMovesMoney() {
Wallets w = new Wallets();
w.create("alice", 100);
w.create("bob", 0);
w.transfer("alice", "bob", 30);
assertEquals(70L, w.balance("alice").orElseThrow().longValue());
assertEquals(30L, w.balance("bob").orElseThrow().longValue());
}
@Test
void cannotOverdraw() {
Wallets w = new Wallets();
w.create("alice", 10);
w.create("bob", 0);
assertThrows(ConflictException.class, () -> w.transfer("alice", "bob", 11));
}
}
Without a build tool, run java -ea and use assert, or a tiny check(condition, message) helper in main that throws on failure. Every runnable example in this handbook checks itself that way.
The same habits carry to work: name exceptions by failure, keep validation at the boundary, inject time (Clock) and randomness (a Random or IdGenerator interface) so tests are deterministic, and test the rules, not the getters.
10.9Extensible without over-engineering
The prompt says "support equal and exact splits; more split types may come". Which parts of your design get an interface, and which stay concrete?
Reveal the answer
SplitStrategy gets an interface (the prompt names the variation), with EqualSplit and ExactSplit and a factory or map to pick one. Users, groups, the balance ledger, and the service stay concrete classes: nothing suggests they vary. The repository can be an interface because it costs one line and helps tests.
The one-change test: name the most likely new requirement and check that it lands as a new class plus at most one line of wiring. Apply it to the two or three variations the prompt hints at, and nowhere else.
| Make pluggable | Keep concrete |
|---|---|
| Rules the prompt says vary (pricing, splitting, allocation, scheduling) | Entities and value objects |
| External systems (payment, notification) via an interface | The service coordinating use cases |
| Storage, via a repository interface (cheap, helps tests) | The driver |
When the interviewer adds a requirement live, narrate: "This is a new pricing rule, so it is a new PricingStrategy; I register it in the factory, and nothing else changes." Then do it in under five minutes. That moment is often what decides the round.
10.10Common mistakes
Common mistakes
- Designing for 40 minutes and running out of time to code.
- Logic in
mainor the driver instead of the service. - No running code at the end, or code that runs only after "one more fix".
HashMapiteration order in output, so the demo prints in a different order than expected.- Swallowing exceptions or printing stack traces for expected errors like "insufficient balance".
- Patterns everywhere: interfaces for entities, factories for classes with one implementation.
- Ignoring the given input and output format.
- Never compiling until the end, then fighting 30 errors at minute 85.
10.11Self-check
- What should be done by minute 30 in a 90-minute round?
Reveal
Requirements written down, entities and service method signatures decided, and a compiling skeleton: models, exceptions, repository interfaces, and empty service methods.
- How does
LinkedHashMapimplement LRU eviction?Reveal
With
accessOrder = true, eachgetorputmoves the entry to the end of its internal linked list.removeEldestEntryis called after each insertion; returningsize() > capacityevicts the head, the least recently used entry. - Why should the driver catch exceptions per command?
Reveal
So one invalid line prints an error and the session continues, like a real CLI; reviewers often feed invalid commands on purpose.
- Give two ways to make time-dependent logic testable.
Reveal
Pass the current time as a parameter, or inject a
java.time.Clockand useClock.fixedin tests. - The interviewer asks for a new feature live. What do you say before typing?
Reveal
Where it fits in the design and why little changes: "it's a new strategy implementation plus one registration", or "it's a new state and two transitions". Then implement it quickly and run the demo again.
Mastery checklist
Tick an item only when it is true with your notes closed.
Module 11: Tables and APIs for LLD rounds
After the class diagram, many LLD rounds ask "what tables would you have?" and "what does the API look like?". This module turns classes into tables whose constraints enforce the invariants, picks indexes from the queries, shows the four ways to make a contended write safe in SQL, and designs REST endpoints with the right status codes, idempotency keys, and pagination. One example, movie booking (P9), runs through all of it, and its SQL runs on SQLite.
- A
Bookingobject holds aList<ShowSeat>. How does that list look in tables?Reveal
As rows on the many side pointing back: each show seat row gets a
booking_idforeign key. A collection never becomes a column. - Why store prices as integer paise in the database?
Reveal
For the same reason as in Java: binary floating point cannot represent 0.1 exactly, so sums drift. Integers in the smallest unit are exact.
SELECT statusand thenUPDATE ... SET status = 'HELD'as two statements. What can go wrong?Reveal
Check-then-act (Module 9): two requests both read AVAILABLE and both write HELD. Both users are told they have the seat.
- Which HTTP method is not idempotent, and what makes retrying it safe?
Reveal
POST. An idempotency key sent by the client lets the server recognize a retry and return the first result instead of acting twice.
11.1When the round asks for tables and APIs
| They ask | They check |
|---|---|
| "What tables would you have?" | Entities and relationships right; keys; money and time types |
| "How does the database stop double booking?" | A constraint or a conditional update, not an application-level check |
| "Which queries are hot?" | Indexes that match those queries, and no more |
| "What does the API look like?" | Resources and verbs, status codes, idempotency, pagination |
Answer from the invariants. "The invariant is one booking per seat per show. So show_seats has the primary key (show_id, seat_id), and holding a seat is one conditional update from AVAILABLE to HELD; zero rows changed means someone else got it, and the API answers 409."
11.2From classes to tables
Map these classes to tables: Movie; Show (movie, screen, start time); Seat (screen, label); Booking (user, show, seats, total). Where does "seat A4 is held for the 7 pm show" live?
Reveal the answer
Tables movies, shows (with movie_id, screen_id), seats (with screen_id), bookings. The hold belongs to neither the seat (it exists for every show) nor the show (it has many seats), so it gets its own table: show_seats, one row per seat per show, with the status, the holder, and the booking.
| In the class diagram | In tables |
|---|---|
| Entity with identity | A table with a primary key (usually a surrogate id) |
| Value object (money, interval, address) | Columns on the owner's table (price_paise, start_at, end_at) |
| One-to-many | A foreign key on the many side |
| Many-to-many | A join table with a composite primary key, plus fields of the relationship (a grade, a joined date) |
| Enum | A text column with a CHECK listing the values |
| Inheritance | One table with a type column (simplest); a table per subclass when their fields differ a lot |
| Money | Integer minor units, plus a currency column if there is more than one |
| Time | UTC timestamps; convert to local time only for display |
CREATE TABLE movies (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
duration_min INTEGER NOT NULL CHECK (duration_min > 0)
);
CREATE TABLE screens (
id INTEGER PRIMARY KEY,
theater_id INTEGER NOT NULL, -- theaters table left out
name TEXT NOT NULL,
UNIQUE (theater_id, name)
);
CREATE TABLE seats ( -- physical seats: fixed per screen
id INTEGER PRIMARY KEY,
screen_id INTEGER NOT NULL REFERENCES screens (id),
label TEXT NOT NULL, -- 'A1'
type TEXT NOT NULL CHECK (type IN ('REGULAR', 'PREMIUM')),
UNIQUE (screen_id, label)
);
CREATE TABLE shows (
id INTEGER PRIMARY KEY,
movie_id INTEGER NOT NULL REFERENCES movies (id),
screen_id INTEGER NOT NULL REFERENCES screens (id),
starts_at INTEGER NOT NULL -- UTC, epoch seconds
);
CREATE TABLE bookings (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
show_id INTEGER NOT NULL REFERENCES shows (id),
total_paise INTEGER NOT NULL CHECK (total_paise >= 0),
status TEXT NOT NULL CHECK (status IN ('CONFIRMED', 'CANCELLED')),
idempotency_key TEXT NOT NULL,
created_at INTEGER NOT NULL,
UNIQUE (user_id, idempotency_key) -- a retried request finds the first booking
);
CREATE TABLE show_seats ( -- one row per seat per show: the unit of contention
show_id INTEGER NOT NULL REFERENCES shows (id),
seat_id INTEGER NOT NULL REFERENCES seats (id),
price_paise INTEGER NOT NULL CHECK (price_paise >= 0),
status TEXT NOT NULL DEFAULT 'AVAILABLE' CHECK (status IN ('AVAILABLE', 'HELD', 'BOOKED')),
held_by INTEGER, -- user id, only while HELD
held_until INTEGER, -- epoch seconds, only while HELD
booking_id INTEGER REFERENCES bookings (id), -- only when BOOKED
PRIMARY KEY (show_id, seat_id),
CHECK ((status = 'HELD') = (held_by IS NOT NULL AND held_until IS NOT NULL)),
CHECK ((status = 'BOOKED') = (booking_id IS NOT NULL))
);
CREATE INDEX shows_by_movie_and_time ON shows (movie_id, starts_at);
CREATE INDEX bookings_by_user ON bookings (user_id, created_at);
show_seats is the ShowSeat class from P9. The thing two users fight over gets its own row, and that row becomes the unit of locking in the database, exactly as the object was the unit of locking in memory.
11.3Constraints enforce invariants
Name the database rule for each invariant: (1) a seat label appears once per screen; (2) a seat is booked at most once per show; (3) a HELD seat always has a holder and an expiry; (4) a driver has at most one live trip.
Reveal the answer
(1) UNIQUE (screen_id, label). (2) The primary key (show_id, seat_id): there is exactly one row per seat per show, and its status can only be one value. (3) A CHECK tying the status to the hold columns. (4) A partial unique index on driver_id over rows whose status is ASSIGNED or STARTED.
Application checks are races between instances; a constraint holds no matter how many instances run or what bug ships next. Put each invariant in the strongest place that can hold it.
| Invariant | Database rule |
|---|---|
| A field is always present | NOT NULL |
| One per something (username, seat per show) | UNIQUE or PRIMARY KEY |
| Allowed values; fields that go together | CHECK |
| A referenced row exists | FOREIGN KEY |
| One live row per something (live trip, active subscription) | Partial unique index: unique over rows matching a WHERE (PostgreSQL, SQLite; MySQL has no partial indexes) |
| No two ranges overlap (room bookings) | Exclusion constraint in PostgreSQL, as in P14 |
CREATE TABLE trips (
id INTEGER PRIMARY KEY,
driver_id INTEGER NOT NULL,
rider_id INTEGER NOT NULL,
status TEXT NOT NULL CHECK (status IN ('ASSIGNED', 'STARTED', 'COMPLETED', 'CANCELLED'))
);
-- at most one live trip per driver: a unique index over live rows only
CREATE UNIQUE INDEX one_live_trip_per_driver ON trips (driver_id) WHERE status IN ('ASSIGNED', 'STARTED');
A constraint violation is an expected outcome, not a crash. Catch the unique violation and answer 409 Conflict, or return the existing row when it was an idempotent retry. Letting it surface as a 500 tells the client to retry something that will never succeed.
11.4Indexes come from queries
Write the three most frequent reads of a booking app and the index each needs.
Reveal the answer
Shows of a movie in a date range: (movie_id, starts_at). The seat map of a show: already served by the primary key (show_id, seat_id). A user's bookings, newest first: (user_id, created_at).
- Write the queries first, from the use cases, then index exactly those.
- Composite order: equality columns first, then the range or sort column.
(movie_id, starts_at)servesWHERE movie_id = ? AND starts_at BETWEEN ? AND ? ORDER BY starts_at;(starts_at, movie_id)serves it badly. - Keys are already indexes. A primary key or unique constraint needs no second index.
- Every index costs writes and space. Do not index columns no query filters or sorts on.
- Foreign keys are not indexed automatically in PostgreSQL (MySQL's InnoDB does it); index the ones you join or delete by.
11.5Safe writes under concurrency
Two users press "hold A4" at the same instant. Write SQL that lets exactly one succeed, with no locks in the application.
Reveal the answer
One statement that checks and writes: UPDATE show_seats SET status = 'HELD', ... WHERE show_id = ? AND seat_id = ? AND status = 'AVAILABLE', then read the affected row count. 1: held. 0: someone else has it, answer 409.
SELECT status FROM show_seats WHERE show_id = :show AND seat_id = :seat;
-- the application sees 'AVAILABLE', then:
UPDATE show_seats SET status = 'HELD', held_by = :user, held_until = :now + 600
WHERE show_id = :show AND seat_id = :seat;
compareAndSet did in memory.UPDATE show_seats
SET status = 'HELD', held_by = :user, held_until = :now + 600
WHERE show_id = :show AND seat_id = :seat
AND (status = 'AVAILABLE' OR (status = 'HELD' AND held_until < :now)); -- an expired hold counts as free
| Tool | SQL shape | Use when |
|---|---|---|
| Unique constraint | INSERT; catch the violation |
Creating something that must be one of a kind (a booking per idempotency key, a username) |
| Conditional update | UPDATE ... WHERE status = 'AVAILABLE'; check the row count |
A state change on one row (hold a seat, accept an order) |
| Version column | UPDATE ... SET version = version + 1 WHERE id = ? AND version = ? |
Read, compute in the application, write back (Module 9) |
| Row lock | SELECT ... FOR UPDATE in a transaction, then UPDATE |
Several dependent reads and writes (a transfer between two wallets, locked in ID order) |
Several rows that must change together go in one transaction. Holding seats A1 and A2 is all or nothing:
hold(user, show, seats):
BEGIN
for each seat, in seat ID order: -- one order for everyone: no deadlocks
run the hold UPDATE; 0 rows changed -> ROLLBACK, answer 409 naming the seat
COMMIT, answer 201 with the expiry time
confirm(user, show, seats, idempotencyKey):
a booking with (user, key) exists -> return it again: this is a retry
BEGIN
insert the booking; unique violation -> ROLLBACK, return the booking a concurrent retry made
for each seat, in seat ID order:
run the confirm UPDATE; 0 rows changed -> ROLLBACK, answer 409: the hold expired
COMMIT, answer 201 with the booking
UPDATE show_seats
SET status = 'BOOKED', booking_id = :booking, held_by = NULL, held_until = NULL
WHERE show_id = :show AND seat_id = :seat
AND status = 'HELD' AND held_by = :user AND held_until >= :now;
Code that ransqlcheck.py: the test script that ran the SQL above
a, b = connect(), connect() # two clients, like two app servers
a.executescript(R["schema"])
a.executescript("""
INSERT INTO movies VALUES (1, 'Interstellar', 169);
INSERT INTO screens VALUES (1, 10, 'Audi 2');
INSERT INTO seats VALUES (1, 1, 'A1', 'REGULAR'), (2, 1, 'A2', 'REGULAR'), (3, 1, 'A3', 'REGULAR'), (4, 1, 'A4', 'PREMIUM');
INSERT INTO shows VALUES (1, 1, 1, 1000000);
INSERT INTO show_seats (show_id, seat_id, price_paise) VALUES (1, 1, 25000), (1, 2, 25000), (1, 3, 25000), (1, 4, 40000);
""")
RAVI, MEERA = 101, 202
LABEL = {1: "A1", 2: "A2", 3: "A3", 4: "A4"}
now = 900_000
# 1. read-then-write: two clients both believe they got seat A4
p = dict(show=1, seat=4, now=now)
ra = a.execute(R["naive_read"], p).fetchone()[0]
rb = b.execute(R["naive_read"], p).fetchone()[0]
if ra == "AVAILABLE":
a.execute(R["naive_write"], dict(p, user=RAVI))
if rb == "AVAILABLE":
b.execute(R["naive_write"], dict(p, user=MEERA))
owner = a.execute("SELECT held_by FROM show_seats WHERE show_id = 1 AND seat_id = 4").fetchone()[0]
say(f"read then write: ravi told held, meera told held; A4 is actually held by {'meera' if owner == MEERA else 'ravi'}")
a.execute("UPDATE show_seats SET status = 'AVAILABLE', held_by = NULL, held_until = NULL WHERE seat_id = 4")
# 2. conditional update: the second client gets 0 rows
n1 = a.execute(R["hold"], dict(p, user=RAVI)).rowcount
n2 = b.execute(R["hold"], dict(p, user=MEERA)).rowcount
say(f"conditional update: ravi {n1} row (held), meera {n2} rows (refused, answer 409)")
def hold(conn, user, seats, t):
conn.execute("BEGIN IMMEDIATE")
for s in sorted(seats):
if conn.execute(R["hold"], dict(show=1, seat=s, user=user, now=t)).rowcount != 1:
conn.execute("ROLLBACK")
return f"{LABEL[s]} is taken, rolled back"
conn.execute("COMMIT")
return "ok"
def confirm(conn, user, seats, key, t):
found = conn.execute(R["find_booking"], dict(user=user, key=key)).fetchone()
if found:
return found[0], "existing"
total = sum(conn.execute("SELECT price_paise FROM show_seats WHERE show_id = 1 AND seat_id = ?", (s,)).fetchone()[0] for s in seats)
conn.execute("BEGIN IMMEDIATE")
try:
bid = conn.execute(R["insert_booking"], dict(user=user, show=1, total=total, key=key, now=t)).lastrowid
except sqlite3.IntegrityError: # a concurrent retry inserted it first
conn.execute("ROLLBACK")
return conn.execute(R["find_booking"], dict(user=user, key=key)).fetchone()[0], "existing"
for s in sorted(seats):
if conn.execute(R["confirm_seat"], dict(show=1, seat=s, user=user, booking=bid, now=t)).rowcount != 1:
conn.execute("ROLLBACK")
return None, f"rejected, {LABEL[s]} is no longer held by this user; rolled back, no booking"
conn.execute("COMMIT")
return bid, "new"
def status(seat):
return a.execute("SELECT status FROM show_seats WHERE show_id = 1 AND seat_id = ?", (seat,)).fetchone()[0]
say("hold A1, A2 for ravi: " + hold(a, RAVI, [1, 2], now))
say("hold A2, A3 for meera: " + hold(b, MEERA, [2, 3], now) + f"; A3 is still {status(3)}")
later = now + 11 * 60
say("11 minutes later, hold A2, A3 for meera: " + hold(b, MEERA, [2, 3], later) + " (ravi's hold on A2 expired)")
bid, how = confirm(a, RAVI, [1, 2], "k-12", later)
say(f"ravi confirms A1, A2 after 11 minutes (holds last 10): {how}")
bid, how = confirm(b, MEERA, [2, 3], "k-77", later + 30)
say(f"confirm A2, A3 for meera with key k-77: booking {bid} ({how})")
bid2, how2 = confirm(b, MEERA, [2, 3], "k-77", later + 45)
count = a.execute("SELECT COUNT(*) FROM bookings").fetchone()[0]
say(f"retry with key k-77: booking {bid2} ({how2}); bookings table has {count} row")
assert bid2 == bid and count == 1 and status(2) == "BOOKED" and status(3) == "BOOKED"
# 3. a partial unique index: one live trip per driver
a.executescript(R["trips"])
a.execute("INSERT INTO trips VALUES (1, 7, 501, 'ASSIGNED')")
try:
a.execute("INSERT INTO trips VALUES (2, 7, 502, 'ASSIGNED')")
say("second live trip for driver 7: accepted (wrong)")
except sqlite3.IntegrityError as e:
say(f"second live trip for driver 7: rejected ({e})")
a.execute("UPDATE trips SET status = 'COMPLETED' WHERE id = 1")
a.execute("INSERT INTO trips VALUES (3, 7, 503, 'ASSIGNED')")
say("after trip 1 completes, a new trip for driver 7: accepted")
# 4. CHECK constraints catch an inconsistent row
try:
a.execute("UPDATE show_seats SET status = 'HELD' WHERE show_id = 1 AND seat_id = 3")
say("HELD without held_by: accepted (wrong)")
except sqlite3.IntegrityError as e:
say(f"HELD without held_by or held_until: rejected ({e.args[0].split(':')[0]})")
read then write: ravi told held, meera told held; A4 is actually held by meera
conditional update: ravi 1 row (held), meera 0 rows (refused, answer 409)
hold A1, A2 for ravi: ok
hold A2, A3 for meera: A2 is taken, rolled back; A3 is still AVAILABLE
11 minutes later, hold A2, A3 for meera: ok (ravi's hold on A2 expired)
ravi confirms A1, A2 after 11 minutes (holds last 10): rejected, A1 is no longer held by this user; rolled back, no booking
confirm A2, A3 for meera with key k-77: booking 1 (new)
retry with key k-77: booking 1 (existing); bookings table has 1 row
second live trip for driver 7: rejected (UNIQUE constraint failed: trips.driver_id)
after trip 1 completes, a new trip for driver 7: accepted
HELD without held_by or held_until: rejected (CHECK constraint failed)
SQLite locks the whole database for writes, so the test runs requests one after another. PostgreSQL and MySQL lock rows: the second UPDATE on the same row waits for the first transaction to finish, then re-checks its WHERE against the new row, which gives the same outcome under real concurrency. A read-then-write pair is still a race at PostgreSQL's default isolation level (READ COMMITTED); stricter levels turn some of these races into serialization errors that the application must retry. Expired holds need no cleanup job for correctness: the hold update treats them as free.
11.6REST APIs
Design endpoints for: list a movie's shows; see a show's seat map; hold seats; confirm a booking; cancel a booking; list my bookings.
Reveal the answer
See the table below. The points that matter: nouns for resources, POST for actions that create or change state, 201 with the new resource, 409 for a state clash, an idempotency key on confirm, and a cursor on the list.
| Endpoint | Does | Success | Typical errors |
|---|---|---|---|
GET /movies/{id}/shows?city=BLR&date=2026-11-02 |
A movie's shows | 200 | 404 unknown movie |
GET /shows/{id}/seats |
Seat map with status | 200 | 404 |
POST /shows/{id}/holds with {seatIds} |
Hold seats for 10 minutes | 201 with expiresAt |
409 seat taken; 422 more than 10 seats |
POST /bookings with {showId, seatIds} and an Idempotency-Key header |
Confirm held seats after payment | 201 with the booking | 409 hold expired |
POST /bookings/{id}/cancel |
Cancel; the row stays as CANCELLED | 200 | 403 not yours; 409 too late |
GET /users/me/bookings?limit=20&cursor=... |
My bookings, newest first | 200 with nextCursor |
401 |
| Code | Meaning | Example |
|---|---|---|
| 200 OK | Success with a body | Seat map |
| 201 Created | A new resource, with its ID | Booking confirmed |
| 204 No Content | Success, nothing to return | A saved card deleted |
| 400 Bad Request | Malformed request | seatIds missing |
| 401 Unauthorized | Not authenticated | No token or an expired one |
| 403 Forbidden | Authenticated but not allowed | Cancelling someone else's booking |
| 404 Not Found | No such resource (or hidden from this caller) | Unknown show |
| 409 Conflict | Clashes with the current state | Seat already held |
| 412 Precondition Failed | The If-Match version is stale |
Saving an outdated edit |
| 422 Unprocessable Content | Well formed, but breaks a rule | 11 seats when the limit is 10 |
| 429 Too Many Requests | Rate limited; send Retry-After |
P6 |
| 500, 503 | Server fault; temporarily unavailable | The client retries with backoff |
Idempotency keys
- GET, PUT, and DELETE are idempotent by definition; POST is not. Any POST that creates something or moves money takes an
Idempotency-Keyheader, generated by the client (a UUID) once per user action and reused on every retry. - The server stores the key with the user, a fingerprint of the request, and the response. Same key and same request: return the stored response. Same key, different request: an error (the IETF draft suggests 422). Same key while the first request is still running: 409.
- A
UNIQUE (user_id, idempotency_key)constraint makes two concurrent retries safe: only one insert can win. - Keys are kept for a bounded time; Stripe, for example, may prune a key once it is at least 24 hours old.
Pagination and concurrent edits
Offset: ?page=3&size=20 |
Cursor: ?limit=20&cursor=... |
|
|---|---|---|
| Good | Simple; can jump to any page | Stable while rows are added; fast at any depth |
| Bad | New rows shift pages, so items repeat or are skipped; deep pages scan and discard rows | Cannot jump to page 50 |
| Query | ORDER BY created_at DESC LIMIT 20 OFFSET 40 |
WHERE (created_at, id) < (?, ?) ORDER BY created_at DESC, id DESC LIMIT 20 |
The cursor is the last row's (created_at, id), encoded so clients treat it as opaque. For edits that may collide (two admins editing one show), use the version column over HTTP: GET returns ETag: "7", the client sends If-Match: "7" with its PUT, and the server answers 412 if the version moved on.
Conventions that read well in an interview: plural nouns (/bookings), actions as sub-resources when they are not plain updates (/bookings/{id}/cancel), a version in the path (/v1), one error shape everywhere ({"code": "SEAT_TAKEN", "message": ..., "details": ...}), money as integer minor units with a currency, and timestamps in ISO 8601 UTC.
11.7Common mistakes
Common mistakes
- A status column on
seats: the seat exists for every show, so its status cannot live there. - Double booking prevented only in application code, which breaks with two instances.
- Read, then write where one conditional
UPDATEwould do. - Not checking the affected row count, so a refused update looks like success.
- Unique violations surfacing as 500s instead of 409 or the existing row.
- Indexes on every column, or composite indexes with the range column first.
- POST endpoints that charge or book without an idempotency key.
- Offset pagination on a feed that changes while the user scrolls.
- Floating-point money and local-time timestamps in the schema.
11.8Self-check
- Why is
show_seatskeyed by (show_id, seat_id) rather than having its ownid?Reveal
The pair is the natural identity: there must be exactly one row per seat per show. Making it the primary key enforces that and gives the seat-map query its index for free. A surrogate
idwould still need a unique constraint on the pair. - The hold
UPDATEreturned 0 rows. What are the possible reasons, and what does the API answer?Reveal
The seat is held by someone else (not expired), or already booked, or the show or seat does not exist. Answer 409 for the first two; check existence first or separately to answer 404 for the last.
- Why hold seats in seat ID order inside the transaction?
Reveal
Two requests for overlapping seats in opposite orders could each lock one row and wait for the other: a deadlock. One global order removes the cycle, as with wallets in Module 9.
- When would you choose
SELECT ... FOR UPDATEover a conditional update?Reveal
When the decision needs several reads and the write depends on them, such as a transfer that checks a balance, a daily limit, and the target's cap before writing both rows. A single-row state change is simpler and faster as a conditional update.
- Why does a cursor stay correct when new bookings arrive and an offset does not?
Reveal
The cursor says "rows older than this one", which does not move when new rows are added at the top. An offset says "skip 40 rows", and new rows push older ones down, so the next page repeats items.
Mastery checklist
Tick an item only when it is true with your notes closed.
Scoping drills: from a one-line prompt to a design
Real prompts are often one line: "Design Uber", "Design a payment system". In an LLD round, that is an invitation to scope: pick the part with interesting objects and rules, find what two users can fight over, say what you leave out, and get agreement within five minutes. This page gives the method and eight drills. Cover each answer, scope the prompt yourself in five minutes, then compare.
The five-minute method
- Say the frame out loud. "In this round I will model the core of one service, in one process. Scaling and the distributed parts are a system design conversation; I can note where they would change things."
- Find the core loop: the one flow the product exists for (get a ride, get food, sell a seat, move money).
- Find the contended resource: what two users or threads could fight over (a driver, a seat, a balance, a stock count). This is where SDE-2 depth shows, so pick a scope that contains one.
- Pick three use cases to build and name three to skip. Skipping out loud is a strength: it shows you know what is big.
- Name the variation points the interviewer is likely to push: pricing, matching, notification channels, payment methods.
- Confirm. "I will build request, match, and complete, with driver assignment under concurrency as the focus. Does that match what you want?"
| The interviewer says | They want |
|---|---|
| "Which classes?", "Write the code for...", "How would you add X?" | LLD: objects, rules, code |
| "How many servers?", "100 million users", "Which database and why?" | System design: components, data flow, scale |
| Just "Design X" | Ask: "Should I focus on the object model of one service, or on the overall architecture?" |
Interviewers score scoping without saying so. A candidate who asks two sharp questions, names the contended resource, and proposes a scope in five minutes has shown more seniority than one who lists twenty features.
Drills
Uber
Design Uber. Core loop, contended resource, three use cases to build, three to skip, variation points.
Reveal the answer
- Ask: one city or many? Vehicle types? Can the rider cancel, and until when? Is surge in scope?
- Core loop: a rider requests; the nearest free driver is assigned; the driver starts and completes; a fare is charged.
- Contended resource: the driver. Two requests must never get the same one.
- Build: request and match; start and complete with the fare; rider cancellation that frees the driver.
- Skip: maps, routing, and ETA; payments; ratings.
- Variation points: matching strategy, pricing and surge, vehicle types.
- Worked: P17.
Swiggy
Design Swiggy (or Zomato).
Reveal the answer
- Ask: who can cancel, and until when? Is partner assignment automatic? Are coupons in scope?
- Core loop: place an order; the restaurant accepts and prepares; a partner picks up and delivers.
- Contended resource: the delivery partner (two orders, one partner), and the order's status (accept racing cancel).
- Build: the order state machine with the actor allowed for each move; partner assignment; cancellation with refunds by status.
- Skip: search and menus, live tracking, payments, ratings.
- Variation points: assignment strategy, cancellation policy, notification channels.
- Worked: P15.
BookMyShow
Design BookMyShow.
Reveal the answer
- Ask: hold seats before payment, and for how long? Several seats at once? What happens if payment fails?
- Core loop: pick a show, pick seats, hold them, pay, confirm.
- Contended resource: a seat for one show.
- Build: the seat map; holding several seats all or nothing with expiry; confirming a hold.
- Skip: search, reviews, real payments, offers.
- Variation points: pricing by seat type and day, payment methods.
- Worked: P9, with tables and an API in Module 11.
A payment system
Design a payment system (a wallet, as at Paytm or PhonePe).
Reveal the answer
- Ask: wallet to wallet only, or also bank top-ups and withdrawals? Balance limits? What does the client do on a timeout?
- Core loop: top up, transfer, see history.
- Contended resource: balances, with transfers touching two of them.
- Build: transfers with ordered locking and both ledger rows; idempotency keys; top-up callbacks that may repeat.
- Skip: card networks, fraud checks, KYC, settlement with banks.
- Variation points: fee rules, limits per account type, notifications.
- Worked: P10. If the interviewer means card processing at Stripe's level, that is a system design question; say so and ask.
Design Facebook.
Reveal the answer
- Ask: which part: posts and the news feed, friend requests, or messaging? (Pick the feed unless told otherwise.)
- Core loop: a user posts; followers see it in their feed, newest first.
- Contended resource: less about locks than about work: building a feed by merging the latest posts of everyone followed (a heap merge of k sorted lists), and counters such as likes updated by many users at once.
- Build: post, follow and unfollow, get the feed with a cursor; likes with an atomic counter.
- Skip: ranking models, media, comments threads, notifications.
- Variation points: feed ranking strategy; building feeds when posting (fan-out on write) or when reading (fan-out on read).
- Practice prompt: social network news feed in the tracker.
Amazon
Design Amazon.
Reveal the answer
- Ask: browsing, cart, checkout, or delivery? (Pick cart and checkout.) Is stock reserved at add-to-cart or at checkout?
- Core loop: add to cart, check out, reserve stock, pay, create the order.
- Contended resource: the stock count of a product, especially in a flash sale.
- Build: cart with a pricing pipeline (discount rules); checkout that reserves stock atomically with expiry; order creation with an idempotency key.
- Skip: search, recommendations, shipping, returns, sellers.
- Variation points: discount rules, payment methods, reservation timing.
- Practice prompts: shopping cart and checkout, coupon engine, inventory reservation in the tracker.
Design WhatsApp.
Reveal the answer
- Ask: one-to-one and groups? Delivery and read receipts? Offline users?
- Core loop: send a message to a chat; members receive it in order; ticks change from sent to delivered to read.
- Contended resource: the order of messages in one chat; give each chat its own sequence number, assigned atomically.
- Build: chats and members; send with per-chat sequence numbers; a receipt state machine per message and member; fetching messages after a sequence number for a reconnecting user.
- Skip: networking, encryption, media, calls.
- Variation points: delivery channels (online push or offline notification), group permissions.
- Related: the queue and offsets in P11.
Airbnb
Design Airbnb.
Reveal the answer
- Ask: host and guest flows, or booking only? Instant booking or host approval? Cancellation policies?
- Core loop: search dates, hold a listing, pay, confirm.
- Contended resource: a listing for a night; a stay of several nights must hold all of them or none.
- Build: availability per listing per night; booking a date range all or nothing; cancellation policy as a strategy.
- Skip: search ranking, messaging, reviews, payouts to hosts.
- Variation points: pricing by night and season, cancellation policies.
- Related: P14 for date ranges, P9 for holds; hotel booking in the tracker.
Practice this out loud, against a timer, on prompts you have not seen. The timer on the playbook page has a five-minute setting worth using for exactly this.
Problem 1: Parking lot
The most asked LLD problem, and the reference for how a complete solution looks: every file of a working, concurrent, extensible parking lot in plain Java, organized the way Module 10 recommends. Learn this one thoroughly; most other problems reuse its shape.
Design a parking lot with multiple floors, spots of different sizes, several vehicle types, entry and exit gates that work at the same time, tickets, and hourly pricing that may change. Then compare with this page section by section.
Reveal the answer
A strong answer covers: clarified scope (vehicle and spot types, fit rules, gates in parallel, pricing changes); entities Vehicle, ParkingSpot, Ticket, Receipt, enums for types and sizes; a ParkingLot service with park, unpark, and freeSpots; a spot allocation strategy and a pricing strategy behind interfaces; atomic spot occupation (check and act under the spot's lock); a ticket that can close only once; clear exceptions for a full lot, an unknown ticket, and a vehicle that is already inside; and a plan for extensions (free lists for O(1) allocation, reservations, EV spots, display board events).
P1.1Requirements
Clarifying questions and the answers assumed here:
| Question | Assumed answer |
|---|---|
| Vehicle types? | Motorcycle, car, truck |
| Spot sizes and fit rules? | Small, medium, large. Motorcycles fit any, cars fit medium or large, trucks fit large only |
| Floors? | Several; prefer lower floors |
| Gates? | Several entry and exit gates running at the same time |
| Pricing? | Hourly by vehicle type; any started hour is charged; rules will change |
| Payment, reservations, EV charging? | Out of scope now; likely follow-ups |
| Storage? | In memory, one process |
Functional: park a vehicle and get a ticket; exit with a ticket and get a receipt with the fee; show free spots per size; reject a vehicle when no spot fits.
Rules that must always hold: a spot holds at most one vehicle; a vehicle is parked at most once; a ticket is closed (paid) at most once.
P1.2Entities and responsibilities
| Class | Knows | Does |
|---|---|---|
Vehicle (record) |
Number, type | Validates itself |
SpotSize, VehicleType (enums) |
Constants | SpotSize.fitting(type): which sizes a type can use |
ParkingSpot |
ID, floor, size, occupant | tryOccupy atomically, release |
Ticket |
Vehicle, spot, entry and exit time | close once, returning the duration |
Receipt (record) |
Ticket, spot, minutes, fee | Nothing: a result value |
SpotAllocationStrategy |
Preference order | Ranks candidate spots for a vehicle |
PricingStrategy |
Rates | Fee for a type and duration |
TicketRepository |
Tickets by ID | Save, find |
ParkingLot (service) |
Spots, strategies, active vehicles | park, unpark, freeSpots |
Two placement decisions worth saying out loud: the spot guards "one vehicle per spot" because it owns the occupant; the ticket guards "close once" because it owns the exit time. The service coordinates and never assigns fields directly.
P1.3Class diagram
P1.4Key flows
P1.5Code
The complete solution, in the Module 10 layout. Every file below compiles and the demo runs.
public class ParkingSpot {
private final String id;
private final int floor;
private final SpotSize size;
private Vehicle occupant; // guarded by this
public ParkingSpot(String id, int floor, SpotSize size) {
this.id = id;
this.floor = floor;
this.size = size;
}
/** Check and occupy in one atomic step; false if someone else got here first. */
public synchronized boolean tryOccupy(Vehicle v) {
if (occupant != null) return false;
occupant = v;
return true;
}
public synchronized void release() {
if (occupant == null) throw new IllegalStateException("spot " + id + " is already free");
occupant = null;
}
public synchronized boolean isFree() { return occupant == null; }
public String id() { return id; }
public int floor() { return floor; }
public SpotSize size() { return size; }
}
import java.time.Duration;
import java.time.Instant;
public class Ticket {
private final String id;
private final Vehicle vehicle;
private final ParkingSpot spot;
private final Instant entryTime;
private Instant exitTime; // guarded by this
public Ticket(String id, Vehicle vehicle, ParkingSpot spot, Instant entryTime) {
this.id = id;
this.vehicle = vehicle;
this.spot = spot;
this.entryTime = entryTime;
}
/** Close once; a second close (double exit, double payment) is rejected. */
public synchronized Duration close(Instant at) {
if (exitTime != null) throw new IllegalStateException("ticket " + id + " already closed");
if (at.isBefore(entryTime)) throw new IllegalArgumentException("exit before entry");
exitTime = at;
return Duration.between(entryTime, at);
}
public String id() { return id; }
public Vehicle vehicle() { return vehicle; }
public ParkingSpot spot() { return spot; }
public Instant entryTime() { return entryTime; }
}
import java.util.List;
public enum SpotSize {
SMALL, MEDIUM, LARGE; // declared smallest first: ordinal order is size order
/** Sizes a vehicle type can use, smallest first. */
public static List<SpotSize> fitting(VehicleType type) {
return switch (type) {
case MOTORCYCLE -> List.of(SMALL, MEDIUM, LARGE);
case CAR -> List.of(MEDIUM, LARGE);
case TRUCK -> List.of(LARGE);
};
}
}
import java.util.List;
import parkinglot.model.ParkingSpot;
import parkinglot.model.Vehicle;
/** Decides the order in which free spots are tried. The lot does the atomic occupy. */
public interface SpotAllocationStrategy {
List<ParkingSpot> candidates(List<ParkingSpot> spots, Vehicle vehicle);
}
import java.util.Comparator;
import java.util.List;
import parkinglot.model.ParkingSpot;
import parkinglot.model.SpotSize;
import parkinglot.model.Vehicle;
/** Smallest spot that fits first (keeps large spots for trucks), then the lowest floor. */
public class BestFitLowestFloor implements SpotAllocationStrategy {
@Override
public List<ParkingSpot> candidates(List<ParkingSpot> spots, Vehicle vehicle) {
List<SpotSize> fitting = SpotSize.fitting(vehicle.type());
return spots.stream()
.filter(s -> fitting.contains(s.size()) && s.isFree())
.sorted(Comparator.comparing(ParkingSpot::size).thenComparingInt(ParkingSpot::floor))
.toList();
}
}
import java.time.Duration;
import java.util.EnumMap;
import java.util.Map;
import parkinglot.model.VehicleType;
/** Per-hour rate by vehicle type; any started hour is charged; minimum one hour. */
public class HourlyPricing implements PricingStrategy {
private final Map<VehicleType, Long> ratePaise = new EnumMap<>(VehicleType.class);
public HourlyPricing(long motorcycle, long car, long truck) {
ratePaise.put(VehicleType.MOTORCYCLE, motorcycle);
ratePaise.put(VehicleType.CAR, car);
ratePaise.put(VehicleType.TRUCK, truck);
}
@Override
public long feePaise(VehicleType type, Duration parked) {
long seconds = parked.getSeconds();
long hours = Math.max(1, (seconds + 3_599) / 3_600); // ceiling: 60 min 1 s is two hours
return hours * ratePaise.get(type);
}
}
import java.time.Duration;
import java.time.Instant;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import parkinglot.exception.AlreadyParkedException;
import parkinglot.exception.ParkingFullException;
import parkinglot.exception.TicketNotFoundException;
import parkinglot.model.ParkingSpot;
import parkinglot.model.Receipt;
import parkinglot.model.SpotSize;
import parkinglot.model.Ticket;
import parkinglot.model.Vehicle;
import parkinglot.repository.TicketRepository;
import parkinglot.strategy.PricingStrategy;
import parkinglot.strategy.SpotAllocationStrategy;
public class ParkingLot {
private static final String PENDING = "pending";
private final List<ParkingSpot> spots;
private final SpotAllocationStrategy allocation;
private final PricingStrategy pricing;
private final TicketRepository tickets;
private final Map<String, String> activeByVehicle = new ConcurrentHashMap<>(); // number -> ticket id
private final AtomicLong ticketSeq = new AtomicLong(1);
public ParkingLot(List<ParkingSpot> spots, SpotAllocationStrategy allocation,
PricingStrategy pricing, TicketRepository tickets) {
this.spots = List.copyOf(spots);
this.allocation = allocation;
this.pricing = pricing;
this.tickets = tickets;
}
/** Entry gate: assigns a spot and issues a ticket. Safe to call from many gates at once. */
public Ticket park(Vehicle v, Instant at) {
if (activeByVehicle.putIfAbsent(v.number(), PENDING) != null) { // claim the vehicle first
throw new AlreadyParkedException(v.number());
}
try {
for (ParkingSpot spot : allocation.candidates(spots, v)) {
if (spot.tryOccupy(v)) { // atomic per spot
try {
Ticket t = new Ticket("T" + ticketSeq.getAndIncrement(), v, spot, at);
tickets.save(t);
activeByVehicle.put(v.number(), t.id());
return t;
} catch (RuntimeException e) {
spot.release(); // never leave a spot taken without a ticket
throw e;
}
}
}
throw new ParkingFullException(v.number());
} catch (RuntimeException e) {
activeByVehicle.remove(v.number(), PENDING); // undo the claim on any failure
throw e;
}
}
/** Exit gate: closes the ticket once, computes the fee, frees the spot. */
public Receipt unpark(String ticketId, Instant at) {
Ticket t = tickets.findById(ticketId).orElseThrow(() -> new TicketNotFoundException(ticketId));
Duration parked = t.close(at); // throws on a second exit
long fee = pricing.feePaise(t.vehicle().type(), parked);
t.spot().release();
activeByVehicle.remove(t.vehicle().number());
return new Receipt(t.id(), t.spot().id(), parked.toMinutes(), fee);
}
/** Display board: free spots per size. */
public Map<SpotSize, Long> freeSpots() {
Map<SpotSize, Long> free = new EnumMap<>(SpotSize.class);
for (SpotSize s : SpotSize.values()) free.put(s, 0L);
for (ParkingSpot s : spots) if (s.isFree()) free.merge(s.size(), 1L, Long::sum);
return free;
}
}
Go deeperThe remaining small files
public record Vehicle(String number, VehicleType type) {
public Vehicle {
if (number == null || number.isBlank()) throw new IllegalArgumentException("vehicle number required");
if (type == null) throw new IllegalArgumentException("vehicle type required");
}
}
public enum VehicleType { MOTORCYCLE, CAR, TRUCK }
public record Receipt(String ticketId, String spotId, long minutes, long feePaise) {}
import java.time.Duration;
import parkinglot.model.VehicleType;
public interface PricingStrategy {
long feePaise(VehicleType type, Duration parked);
}
import java.util.Optional;
import parkinglot.model.Ticket;
public interface TicketRepository {
void save(Ticket ticket);
Optional<Ticket> findById(String id);
}
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import parkinglot.model.Ticket;
public class InMemoryTicketRepository implements TicketRepository {
private final Map<String, Ticket> tickets = new ConcurrentHashMap<>();
@Override public void save(Ticket t) { tickets.put(t.id(), t); }
@Override public Optional<Ticket> findById(String id) { return Optional.ofNullable(tickets.get(id)); }
}
public class ParkingException extends RuntimeException {
public ParkingException(String message) { super(message); }
}
public class ParkingFullException extends ParkingException {
public ParkingFullException(String vehicle) { super("no free spot for " + vehicle); }
}
public class AlreadyParkedException extends ParkingException {
public AlreadyParkedException(String number) { super(number + " is already parked"); }
}
public class TicketNotFoundException extends ParkingException {
public TicketNotFoundException(String id) { super("ticket not found: " + id); }
}
List<ParkingSpot> spots = List.of(
new ParkingSpot("F1-S1", 1, SpotSize.SMALL),
new ParkingSpot("F1-M1", 1, SpotSize.MEDIUM),
new ParkingSpot("F2-M1", 2, SpotSize.MEDIUM),
new ParkingSpot("F2-L1", 2, SpotSize.LARGE));
ParkingLot lot = new ParkingLot(spots, new BestFitLowestFloor(),
new HourlyPricing(1_000, 4_000, 10_000), new InMemoryTicketRepository());
Ticket bike = lot.park(new Vehicle("KA01-B-1", VehicleType.MOTORCYCLE), t0);
Ticket car = lot.park(new Vehicle("KA01-C-1", VehicleType.CAR), t0);
Ticket truck = lot.park(new Vehicle("KA01-T-1", VehicleType.TRUCK), t0);
System.out.println(bike.spot().id() + " " + car.spot().id() + " " + truck.spot().id());
System.out.println("free: " + lot.freeSpots());
try {
lot.park(new Vehicle("KA02-T-2", VehicleType.TRUCK), t0);
} catch (ParkingException e) {
System.out.println("rejected: " + e.getMessage());
}
Receipt r = lot.unpark(car.id(), t0.plusSeconds(150 * 60)); // 2h30m -> 3 hours
System.out.println("car paid " + r.feePaise() / 100 + " rupees for " + r.minutes() + " minutes");
try {
lot.unpark(car.id(), t0.plusSeconds(160 * 60));
} catch (IllegalStateException e) {
System.out.println("rejected: " + e.getMessage());
}
Code that ranDemo.java: the complete code behind the output below
public class Demo {
public static void main(String[] args) throws Exception {
List<ParkingSpot> spots = List.of(
new ParkingSpot("F1-S1", 1, SpotSize.SMALL),
new ParkingSpot("F1-M1", 1, SpotSize.MEDIUM),
new ParkingSpot("F2-M1", 2, SpotSize.MEDIUM),
new ParkingSpot("F2-L1", 2, SpotSize.LARGE));
ParkingLot lot = new ParkingLot(spots, new BestFitLowestFloor(),
new HourlyPricing(1_000, 4_000, 10_000), new InMemoryTicketRepository());
Instant t0 = Instant.parse("2026-10-21T09:00:00Z");
Ticket bike = lot.park(new Vehicle("KA01-B-1", VehicleType.MOTORCYCLE), t0);
Ticket car = lot.park(new Vehicle("KA01-C-1", VehicleType.CAR), t0);
Ticket truck = lot.park(new Vehicle("KA01-T-1", VehicleType.TRUCK), t0);
System.out.println(bike.spot().id() + " " + car.spot().id() + " " + truck.spot().id());
System.out.println("free: " + lot.freeSpots());
try {
lot.park(new Vehicle("KA02-T-2", VehicleType.TRUCK), t0);
} catch (ParkingException e) {
System.out.println("rejected: " + e.getMessage());
}
Receipt r = lot.unpark(car.id(), t0.plusSeconds(150 * 60)); // 2h30m -> 3 hours
System.out.println("car paid " + r.feePaise() / 100 + " rupees for " + r.minutes() + " minutes");
try {
lot.unpark(car.id(), t0.plusSeconds(160 * 60));
} catch (IllegalStateException e) {
System.out.println("rejected: " + e.getMessage());
}
check(r.feePaise() == 12_000, "fee");
check(new HourlyPricing(1_000, 4_000, 10_000).feePaise(VehicleType.CAR, java.time.Duration.ofSeconds(3_601)) == 8_000, "started hour");
check(lot.freeSpots().get(SpotSize.MEDIUM) == 2, "free medium");
// 8 gates, 400 cars, 1,000 medium spots on a fresh lot: no spot is ever given twice
List<ParkingSpot> big = new ArrayList<>();
for (int i = 0; i < 1_000; i++) big.add(new ParkingSpot("M" + i, i / 100, SpotSize.MEDIUM));
ParkingLot busy = new ParkingLot(big, new BestFitLowestFloor(), new HourlyPricing(1, 1, 1), new InMemoryTicketRepository());
Set<String> used = ConcurrentHashMap.newKeySet();
ExecutorService gates = Executors.newFixedThreadPool(8);
for (int g = 0; g < 8; g++) {
int gate = g;
gates.submit(() -> {
for (int i = 0; i < 50; i++) {
Ticket t = busy.park(new Vehicle("G" + gate + "-" + i, VehicleType.CAR), t0);
check(used.add(t.spot().id()), "spot given twice: " + t.spot().id());
}
});
}
gates.shutdown();
check(gates.awaitTermination(30, TimeUnit.SECONDS), "gates finished");
System.out.println("concurrent gates: " + used.size() + " cars, " + used.size() + " distinct spots");
check(used.size() == 400, "400 parked");
}
static void check(boolean ok, String what) { if (!ok) throw new AssertionError(what); }
}
F1-S1 F1-M1 F2-L1
free: {SMALL=0, MEDIUM=1, LARGE=0}
rejected: no free spot for KA02-T-2
car paid 120 rupees for 150 minutes
rejected: ticket T2 already closed
concurrent gates: 400 cars, 400 distinct spots
The demo also runs 8 gates parking 400 cars at once and checks that no spot is ever handed out twice.
P1.6Concurrency
Three races, each closed at the smallest scope that covers it:
| Race | Where it is closed | How |
|---|---|---|
| Two gates take the same spot | ParkingSpot.tryOccupy |
Check and set the occupant under the spot's lock; the loser moves to the next candidate |
| The same vehicle enters twice | ParkingLot.park |
activeByVehicle.putIfAbsent claims the number before allocating; undone on failure |
| A ticket is paid twice | Ticket.close |
Synchronized; a second close throws |
The candidate list can be stale by the time a gate tries a spot: another gate may have taken it. That is fine, because tryOccupy re-checks under the lock and returns false. Stale reads are acceptable when the final write is atomic and re-checks. That one sentence answers most concurrency follow-ups on this problem.
With several servers, in-process locks stop working. The spot table becomes the arbiter: UPDATE spot SET vehicle = ? WHERE id = ? AND vehicle IS NULL, accepting the spot only if one row changed, and a unique index on the active vehicle number to prevent double entry.
P1.7Deep dives
Guided designExtensions interviewers ask forAnswer each before revealing
- 1Find a free spot in O(1)
The strategy scans and sorts all spots on every entry. The lot has 10,000 spots. How do you make allocation constant time?
Write your answer, then reveal
Keep a free list per size (and per floor if floor preference matters): a
ConcurrentLinkedDeque<ParkingSpot>perSpotSize.parkpolls from the smallest fitting size's deque;unparkoffers the spot back. Polling is atomic, so two gates never receive the same spot, and the scan disappears. The strategy then chooses which deque to poll, not which spot. - 2Weekend pricing and a free first 30 minutes
How do these land in the design?
Write your answer, then reveal
New
PricingStrategyimplementations, composed:FreeGracePeriod(30 min, inner)wraps another strategy (a decorator), andWeekendPricingchooses rates by the entry day. Wiring changes;ParkingLotdoes not. If the rule depends on the entry time, pass the ticket's entry time to the strategy instead of only the duration. - 3EV charging spots
Some spots have chargers, and EVs should prefer them.
Write your answer, then reveal
Add a capability to the spot (
Set<Feature> featureswithEV_CHARGER) and a vehicle attribute (electric). A new allocation strategy (or a filter before the default one) puts charger spots first for EVs and last for others, so non-EVs do not block chargers. Pricing may add a charging fee through another strategy. - 4A display board at each entrance
The board shows free spots per size and must update when vehicles enter and leave.
Write your answer, then reveal
Publish
VehicleParkedandVehicleLeftevents (observer); each board subscribes and keeps its own counts, or readsfreeSpots()on each event. Updating boards must not slow down the gate, so deliver events asynchronously or keep the board update trivially cheap. - 5Pay before you leave
Exit should open only after a successful payment, with several payment methods.
Write your answer, then reveal
Give the ticket a lifecycle: ACTIVE, then PAID (after payment succeeds), then EXITED (at the exit gate, within 15 minutes of payment).
unparksplits intopay(ticketId, method)andexit(ticketId). Payment methods sit behind aPaymentGatewayinterface with an adapter per provider, and payment uses an idempotency key so a retried request never charges twice. - 6Reservations
Users can reserve a spot for a time window.
Write your answer, then reveal
The spot gains a RESERVED state with an expiry (a state machine: FREE, RESERVED, OCCUPIED). Allocation skips reserved spots unless the vehicle holds that reservation, and a sweeper (or a lazy check on read) releases expired reservations. Reservations need their own entity and repository.
P1.8Machine coding plan
| Minutes | Do | Files |
|---|---|---|
| 0-8 | Clarify; write requirements and the three invariants as comments | README or Main comments |
| 8-18 | Enums, Vehicle, ParkingSpot, Ticket, Receipt |
model/ |
| 18-28 | Exceptions; TicketRepository and in-memory version |
exception/, repository/ |
| 28-40 | Strategy interfaces and one implementation each | strategy/ |
| 40-65 | ParkingLot.park, unpark, freeSpots; demo of the happy path |
service/, Demo |
| 65-80 | Full lot, unknown ticket, double exit, already parked; concurrency test with a thread pool | Demo |
| 80-90 | Walk through; show how weekend pricing would be added | none |
If time is short, cut in this order: the concurrency test (describe it instead), freeSpots, then floor preference. Never cut the full-lot error or the double-exit check.
P1.9What a strong answer shows
- SDE-2 bar: correct fit rules; strategies only where variation is stated; atomic spot occupation explained with the race; tickets that close once; clean exceptions; running code.
- Above the bar: separates "which spot" (strategy) from "claim the spot" (atomic lot logic); proposes free lists for O(1); explains stale reads with atomic re-checks; says how locking moves to the database with several servers.
- Why does the allocation strategy return candidates instead of occupying a spot itself?
Reveal
So the policy (which spot is preferable) stays separate from the concurrency rule (claim atomically). Any new strategy is automatically thread-safe because the lot does the claiming.
- What happens if a gate crashes after
tryOccupysucceeds but before the ticket is saved?Reveal
In memory, the process is gone anyway. With a database, occupy the spot and insert the ticket in one transaction, so both happen or neither does.
- Should
Ticketcompute its own fee?Reveal
It could compute the duration, which it owns, but the fee depends on pricing rules that change independently, so a pricing strategy computes it from the duration and vehicle type.
- How would you support a truck occupying two adjacent large spots?
Reveal
Allocation must claim several spots atomically: find adjacent free spots, then occupy them in a fixed order (by spot ID) under their locks, releasing any already taken if one fails. The ticket then holds a list of spots.
Problem 2: Vending machine
The canonical State pattern problem. The machine behaves differently for the same button depending on whether money is inside, and the subtle parts are money: escrow until the sale is certain, change that may be impossible to make, and committing every effect together or none at all.
Design a vending machine that accepts coins and notes, sells products from slots, returns change, supports cancel, and can be put into maintenance. Then compare.
Reveal the answer
A strong answer: states Idle, HasMoney, Maintenance with a diagram and illegal actions rejected; inserted money held in escrow until a sale succeeds; selection checks product, stock, amount, and whether change can be made before changing anything; change planning that does not rely on greedy luck; cancel refunds escrow; and a single place where a sale commits stock, cash, and escrow together.
P2.1Requirements
| Question | Assumed answer |
|---|---|
| Money accepted? | Coins of 1, 2, 5, 10 rupees; notes of 20, 50, 100 |
| Products? | Slots with a code, a product, a price, and a count |
| Change? | From the cash box, which has limited coins; refuse a sale it cannot change |
| Cancel? | Returns exactly the money inserted for this purchase |
| Admin? | Maintenance mode to refill; customers cannot use the machine meanwhile |
| Concurrency? | One customer at a time per machine |
Invariants: money inserted for a purchase is either returned or kept with a dispensed product, never lost; stock never goes negative; the cash box never pays out coins it does not have.
P2.2Entities and responsibilities
| Class | Responsibility |
|---|---|
Denomination (enum) |
Each coin or note and its value in paise |
Product (record) |
Code, name, price |
Inventory |
Products and counts per code; take fails when sold out |
CashBox |
Coin counts; plans change for an amount or reports that it cannot |
VendingState |
Idle, HasMoney, Maintenance: which actions are allowed and what they do |
VendingMachine |
Holds the state and escrow; delegates actions to the state; commits a sale |
Sale (record) |
The product and the change given |
P2.3Class diagram
P2.4Key flows
P2.5Code
interface VendingState {
default void insert(VendingMachine m, Denomination d) { throw new VendingException(name() + ": cannot accept money"); }
default Sale select(VendingMachine m, String code) { throw new VendingException(name() + ": insert money first"); }
default List<Denomination> cancel(VendingMachine m) { return List.of(); }
String name();
VendingState IDLE = new VendingState() {
public void insert(VendingMachine m, Denomination d) {
m.escrow(d);
m.moveTo(HAS_MONEY);
}
public String name() { return "Idle"; }
};
VendingState HAS_MONEY = new VendingState() {
public void insert(VendingMachine m, Denomination d) { m.escrow(d); }
public Sale select(VendingMachine m, String code) {
Sale s = m.sell(code); // throws, and stays in HAS_MONEY, if it cannot sell
m.moveTo(IDLE);
return s;
}
public List<Denomination> cancel(VendingMachine m) {
List<Denomination> refund = m.refundEscrow();
m.moveTo(IDLE);
return refund;
}
public String name() { return "HasMoney"; }
};
VendingState MAINTENANCE = new VendingState() { // every customer action falls through to the defaults
public String name() { return "Maintenance"; }
};
}
class VendingMachine {
private final Inventory inventory;
private final CashBox cash;
private final List<Denomination> escrow = new ArrayList<>(); // inserted, not yet kept
private VendingState state = VendingState.IDLE;
VendingMachine(Inventory inventory, CashBox cash) { this.inventory = inventory; this.cash = cash; }
// customer API: synchronized because a machine serves one customer at a time
synchronized void insert(Denomination d) { state.insert(this, d); }
synchronized Sale select(String code) { return state.select(this, code); }
synchronized List<Denomination> cancel() { return state.cancel(this); }
synchronized void startMaintenance() {
if (!escrow.isEmpty()) throw new VendingException("customer has money inserted");
state = VendingState.MAINTENANCE;
}
synchronized void endMaintenance() { state = VendingState.IDLE; }
synchronized String stateName() { return state.name(); }
// hooks for the states
void moveTo(VendingState next) { state = next; }
void escrow(Denomination d) { escrow.add(d); }
List<Denomination> refundEscrow() {
List<Denomination> out = List.copyOf(escrow);
escrow.clear();
return out;
}
Sale sell(String code) {
Product p = inventory.product(code);
if (!inventory.inStock(code)) throw new VendingException(code + " is sold out");
long paid = escrow.stream().mapToLong(d -> d.paise).sum();
if (paid < p.pricePaise()) throw new VendingException("insert " + (p.pricePaise() - paid) / 100 + " more rupees");
List<Denomination> change = cash.planChange(paid - p.pricePaise(), escrow)
.orElseThrow(() -> new VendingException("cannot make change; use exact money or cancel"));
// every check passed: commit all effects together
cash.addAll(escrow);
cash.removeAll(change);
inventory.take(code);
escrow.clear();
return new Sale(p, change);
}
}
class CashBox {
private final Map<Denomination, Integer> count = new EnumMap<>(Denomination.class);
void add(Denomination d, int n) { count.merge(d, n, Integer::sum); }
void addAll(List<Denomination> ds) { ds.forEach(d -> add(d, 1)); }
void removeAll(List<Denomination> ds) { ds.forEach(d -> add(d, -1)); }
/** Plans change for `amount` using `extra` (coins just inserted) plus the box, or empty if impossible. */
Optional<List<Denomination>> planChange(long amount, List<Denomination> extra) {
Map<Denomination, Integer> avail = new EnumMap<>(count);
extra.forEach(d -> avail.merge(d, 1, Integer::sum));
Denomination[] ds = Denomination.values();
List<Denomination> out = new ArrayList<>();
return plan(amount, ds.length - 1, ds, avail, out) ? Optional.of(out) : Optional.empty();
}
// largest denomination first; backtrack when a greedy choice leads to a dead end
private boolean plan(long left, int i, Denomination[] ds, Map<Denomination, Integer> avail, List<Denomination> out) {
if (left == 0) return true;
if (i < 0) return false;
Denomination d = ds[i];
int max = (int) Math.min(avail.getOrDefault(d, 0), left / d.paise);
for (int k = max; k >= 0; k--) {
for (int j = 0; j < k; j++) out.add(d);
if (plan(left - k * d.paise, i - 1, ds, avail, out)) return true;
for (int j = 0; j < k; j++) out.remove(out.size() - 1);
}
return false;
}
}
Go deeperThe remaining small files
public enum Denomination {
COIN_1(100), COIN_2(200), COIN_5(500), COIN_10(1_000), NOTE_20(2_000), NOTE_50(5_000), NOTE_100(10_000);
final long paise;
Denomination(long paise) { this.paise = paise; }
}
record Product(String code, String name, long pricePaise) {}
import java.util.LinkedHashMap;
import java.util.Map;
class Inventory {
private final Map<String, Product> products = new LinkedHashMap<>();
private final Map<String, Integer> stock = new LinkedHashMap<>();
void load(Product p, int count) {
if (count <= 0) throw new VendingException("load a positive count");
Product existing = products.putIfAbsent(p.code(), p);
if (existing != null && !existing.equals(p)) throw new VendingException("slot " + p.code() + " holds " + existing.name());
stock.merge(p.code(), count, Integer::sum);
}
Product product(String code) {
Product p = products.get(code);
if (p == null) throw new VendingException("no such product: " + code);
return p;
}
boolean inStock(String code) { return stock.getOrDefault(code, 0) > 0; }
void take(String code) {
if (!inStock(code)) throw new VendingException(code + " is sold out");
stock.merge(code, -1, Integer::sum);
}
}
import java.util.List;
record Sale(Product product, List<Denomination> change) {}
class VendingException extends RuntimeException {
VendingException(String message) { super(message); }
}
try { vm.select("A1"); } catch (VendingException e) { log.add(e.getMessage()); } // Idle: insert money first
vm.insert(Denomination.NOTE_50);
Sale s1 = vm.select("A1"); // 50 - 20 = 30 change
log.add("got " + s1.product().name() + ", change " + s1.change());
vm.insert(Denomination.NOTE_100);
try { vm.select("B2"); } catch (VendingException e) { log.add(e.getMessage()); } // 70 change, box cannot
log.add("refund " + vm.cancel());
vm.insert(Denomination.NOTE_20);
vm.insert(Denomination.COIN_10);
Sale s2 = vm.select("B2"); // exact money
log.add("got " + s2.product().name() + ", change " + s2.change());
vm.insert(Denomination.NOTE_20);
try { vm.select("B2"); } catch (VendingException e) { log.add(e.getMessage()); } // sold out, still HasMoney
log.add("state " + vm.stateName() + ", refund " + vm.cancel());
Code that ranDemo.java: the complete code behind the output below
public class Demo {
public static void main(String[] args) {
Inventory inv = new Inventory();
inv.load(new Product("A1", "Water", 2_000), 2);
inv.load(new Product("B2", "Chips", 3_000), 1);
CashBox cash = new CashBox();
cash.add(Denomination.COIN_10, 3);
cash.add(Denomination.COIN_5, 2);
VendingMachine vm = new VendingMachine(inv, cash);
List<String> log = new ArrayList<>();
try { vm.select("A1"); } catch (VendingException e) { log.add(e.getMessage()); } // Idle: insert money first
vm.insert(Denomination.NOTE_50);
Sale s1 = vm.select("A1"); // 50 - 20 = 30 change
log.add("got " + s1.product().name() + ", change " + s1.change());
vm.insert(Denomination.NOTE_100);
try { vm.select("B2"); } catch (VendingException e) { log.add(e.getMessage()); } // 70 change, box cannot
log.add("refund " + vm.cancel());
vm.insert(Denomination.NOTE_20);
vm.insert(Denomination.COIN_10);
Sale s2 = vm.select("B2"); // exact money
log.add("got " + s2.product().name() + ", change " + s2.change());
vm.insert(Denomination.NOTE_20);
try { vm.select("B2"); } catch (VendingException e) { log.add(e.getMessage()); } // sold out, still HasMoney
log.add("state " + vm.stateName() + ", refund " + vm.cancel());
log.forEach(System.out::println);
check(s1.change().stream().mapToLong(d -> d.paise).sum() == 3_000, "change 30");
check(vm.stateName().equals("Idle"), "idle");
vm.startMaintenance();
try { vm.insert(Denomination.COIN_1); throw new AssertionError(); } catch (VendingException e) { System.out.println(e.getMessage()); }
// greedy would fail here: 60 rupees from {50 x1, 20 x3}; backtracking finds 20+20+20
CashBox tricky = new CashBox();
tricky.add(Denomination.NOTE_50, 1);
tricky.add(Denomination.NOTE_20, 3);
check(tricky.planChange(6_000, List.of()).orElseThrow().size() == 3, "backtracking change");
try { inv.load(new Product("A1", "Juice", 2_500), 3); throw new AssertionError(); } catch (VendingException e) { System.out.println("refill rejected: " + e.getMessage()); }
}
static void check(boolean ok, String what) { if (!ok) throw new AssertionError(what); }
}
Idle: insert money first
got Water, change [COIN_10, COIN_10, COIN_10]
cannot make change; use exact money or cancel
refund [NOTE_100]
got Chips, change []
B2 is sold out
state HasMoney, refund [NOTE_20]
Maintenance: cannot accept money
refill rejected: slot A1 holds Water
Read the third and fourth output lines together: the machine refused a sale it could not change, kept the 100 rupee note in escrow, and returned it on cancel. Nothing was dispensed and the cash box did not change.
P2.6Hard parts
Why greedy change can fail. Paying 60 rupees from a box holding one 50 and three 20s: greedy takes the 50 and is stuck with 10 to go, but three 20s work. With unlimited coins of standard denominations, greedy is optimal; with limited counts it is not. The cash box tries the largest denomination first and backtracks. That is fast for a machine's handful of denominations and modest coin counts; with large counts, backtracking can explode on impossible amounts, and a bounded-coin dynamic program over the amount (one row per denomination, one column per rupee) gives an answer in time proportional to amount times denominations.
Commit everything or nothing. sell performs every check first (product, stock, amount, change plan) and only then mutates stock, cash, and escrow together. If any check fails, nothing has changed, so the state can stay HasMoney without cleanup.
Concurrency. A machine serves one customer at a time, so its public methods are synchronized: a coin inserted while a selection is running waits. If the machine also talks to a remote inventory or payment service, the sale becomes a small saga: reserve, then charge, then dispense, with compensation on failure.
P2.7Deep dives
Guided designExtensions interviewers ask forAnswer each before revealing
- 1Card and UPI payments
How do you add cashless payment without rewriting the states?
Write your answer, then reveal
Add a payment method abstraction: money in escrow becomes a
PaymentAuthorization(cash escrow is one kind, a card authorization is another). Selection captures the authorization for the price instead of planning change. Card and UPI sit behind aPaymentGatewayinterface with adapters. A new state, AwaitingPayment, may be needed while the card network responds. - 2Dispense failure
The motor jams after payment is committed. What now?
Write your answer, then reveal
Treat dispensing as a step that can fail: commit stock and cash only after the dispenser confirms, or record the sale as pending and refund automatically if the dispenser reports failure. Add a Faulty state that blocks sales until maintenance. Log every sale with an ID for reconciliation.
- 3Enum instead of State objects
Could this machine be an enum with a transition table?
Write your answer, then reveal
Yes, for three states; the transitions are simple. The State pattern pays off when each state does real work for several actions (HasMoney accumulates escrow, sells, refunds) and when states grow (AwaitingPayment, Faulty, Dispensing). Say why you chose one; both are acceptable.
- 4Exact-change-only mode
When the cash box is low, the display should say "exact change only".
Write your answer, then reveal
Expose a query on the cash box (can it make change for typical amounts?) and have the display subscribe to cash box changes (observer), or compute it on each sale. It does not change the state machine: it is a display rule, not a state.
P2.8Machine coding plan
| Minutes | Do |
|---|---|
| 0-7 | Clarify; draw the state diagram |
| 7-20 | Denomination, Product, Inventory, VendingException |
| 20-35 | CashBox with change planning and a quick test of the 60-from-50+20s case |
| 35-55 | VendingState constants and VendingMachine with sell committing at the end |
| 55-70 | Demo: success, insufficient money, cannot change, sold out, cancel, maintenance |
| 70-75 | Walk through, mention the card payment extension |
P2.9What a strong answer shows
- SDE-2 bar: correct state diagram, illegal actions rejected, escrow and refund, change limited by the cash box, stock checks.
- Above the bar: all checks before any mutation; explains why greedy change fails with limited coins; handles dispense failure and cashless payment without rewriting the states.
- Why keep inserted money in escrow instead of adding it to the cash box immediately?
Reveal
So cancel returns exactly what was inserted, and a failed sale leaves the cash box unchanged. Money moves into the box only when a sale commits.
- The State pattern here uses constants with anonymous classes. Is that fine?
Reveal
Yes, because the states hold no data of their own; the machine holds the escrow. Stateless state objects can be shared singletons.
- How would you test change planning?
Reveal
Unit-test
CashBox.planChangedirectly: exact amounts, impossible amounts, the greedy trap case, and that it never uses more coins than available. - What if two products share a slot code by mistake when refilling?
Reveal
Inventory.loadshould reject a code that already holds a different product, unless the operator explicitly replaces it.
Problem 3: LRU cache
Asked as a data structure problem and as a design problem. The data structure answer is a hash map plus a doubly linked list with O(1) get and put. The design answer separates storage from the eviction policy, so LRU, LFU, and FIFO plug in, and explains why a cache read needs a write lock.
Design a fixed-capacity cache with get(key) and put(key, value) in O(1) that evicts the least recently used entry when full. Then make the eviction policy pluggable and the cache thread-safe.
Reveal the answer
Strong answer: a HashMap<K, Node> for lookup and a doubly linked list with sentinel head and tail for recency; get and put move the node to the front; eviction removes the node before tail and its map entry. For design: a Cache that owns storage and a lock and delegates order to an EvictionPolicy (LRU, FIFO, LFU). Thread safety needs an exclusive lock on reads too, because a read changes recency.
P3.1Requirements
| Question | Assumed answer |
|---|---|
| Operations? | get(key), put(key, value), remove(key) |
| Complexity? | O(1) for get and put |
| Capacity? | Fixed at construction, positive |
| Eviction? | Least recently used by default; other policies may follow |
Does put on an existing key count as a use? |
Yes: it updates the value and makes the key most recent |
| Nulls? | Not supported (a null value would be indistinguishable from a miss) |
| Threads? | Many threads read and write concurrently |
P3.2Entities and responsibilities
| Class | Responsibility |
|---|---|
Cache<K, V> |
Stores values, enforces capacity, owns the lock, asks the policy who to evict |
EvictionPolicy<K> |
Tracks access order and names the next victim |
LruPolicy<K> |
Recency with a doubly linked list and a map from key to node |
FifoPolicy<K> |
Insertion order with a LinkedHashSet |
DoublyLinkedList<K> |
O(1) add-first, unlink, remove-last with sentinels |
P3.3Class diagram
P3.4Key flows
P3.5Code
The version to write when asked "implement LRU" with no design discussion:
/** The 15-minute version: a map for O(1) lookup, a doubly linked list for O(1) reordering. */
class ClassicLru<K, V> {
private static final class Node<K, V> {
final K key;
V value;
Node<K, V> prev, next;
Node(K key, V value) { this.key = key; this.value = value; }
}
private final int capacity;
private final Map<K, Node<K, V>> index = new HashMap<>();
private final Node<K, V> head = new Node<>(null, null); // sentinel: most recent is head.next
private final Node<K, V> tail = new Node<>(null, null); // sentinel: least recent is tail.prev
ClassicLru(int capacity) {
if (capacity <= 0) throw new IllegalArgumentException("capacity must be positive");
this.capacity = capacity;
head.next = tail;
tail.prev = head;
}
V get(K key) {
Node<K, V> n = index.get(key);
if (n == null) return null;
unlink(n);
linkFirst(n); // touched: now most recent
return n.value;
}
void put(K key, V value) {
Node<K, V> n = index.get(key);
if (n != null) { // update counts as a use
n.value = value;
unlink(n);
linkFirst(n);
return;
}
if (index.size() == capacity) { // evict before inserting
Node<K, V> lru = tail.prev;
unlink(lru);
index.remove(lru.key);
}
n = new Node<>(key, value);
linkFirst(n);
index.put(key, n);
}
private void unlink(Node<K, V> n) {
n.prev.next = n.next;
n.next.prev = n.prev;
}
private void linkFirst(Node<K, V> n) {
n.next = head.next;
n.prev = head;
head.next.prev = n;
head.next = n;
}
}
The design version, when the interviewer asks for pluggable eviction and threads:
/** Decides which key leaves when the cache is full. Not thread-safe on its own: the cache locks around it. */
interface EvictionPolicy<K> {
void onPut(K key); // a key was inserted or updated
void onGet(K key); // a key was read
void onRemove(K key); // a key was removed explicitly
K victim(); // the key to evict next; the policy forgets it
}
class LruPolicy<K> implements EvictionPolicy<K> {
private final DoublyLinkedList<K> order = new DoublyLinkedList<>();
private final Map<K, DoublyLinkedList.Node<K>> nodes = new HashMap<>();
public void onPut(K key) { touch(key); }
public void onGet(K key) { touch(key); }
public void onRemove(K key) {
DoublyLinkedList.Node<K> n = nodes.remove(key);
if (n != null) order.unlink(n);
}
public K victim() {
DoublyLinkedList.Node<K> n = order.removeLast();
if (n == null) throw new IllegalStateException("nothing to evict");
nodes.remove(n.key);
return n.key;
}
private void touch(K key) {
DoublyLinkedList.Node<K> n = nodes.get(key);
if (n != null) order.unlink(n);
else nodes.put(key, n = new DoublyLinkedList.Node<>(key));
order.addFirst(n);
}
}
/** First in, first out: reads do not change the order. */
class FifoPolicy<K> implements EvictionPolicy<K> {
private final LinkedHashSet<K> order = new LinkedHashSet<>();
public void onPut(K key) { order.add(key); } // re-adding an existing key keeps its position
public void onGet(K key) { }
public void onRemove(K key) { order.remove(key); }
public K victim() {
Iterator<K> it = order.iterator();
K k = it.next();
it.remove();
return k;
}
}
class Cache<K, V> {
private final int capacity;
private final Map<K, V> storage = new HashMap<>();
private final EvictionPolicy<K> policy;
private final ReentrantLock lock = new ReentrantLock(); // a get changes recency, so reads need the lock too
Cache(int capacity, EvictionPolicy<K> policy) {
if (capacity <= 0) throw new IllegalArgumentException("capacity must be positive");
this.capacity = capacity;
this.policy = policy;
}
Optional<V> get(K key) {
lock.lock();
try {
V v = storage.get(key);
if (v != null) policy.onGet(key);
return Optional.ofNullable(v);
} finally {
lock.unlock();
}
}
/** Returns the evicted key, if any. */
Optional<K> put(K key, V value) {
if (key == null || value == null) throw new IllegalArgumentException("null keys and values are not supported");
lock.lock();
try {
K evicted = null;
if (!storage.containsKey(key) && storage.size() == capacity) {
evicted = policy.victim();
storage.remove(evicted);
}
storage.put(key, value);
policy.onPut(key);
return Optional.ofNullable(evicted);
} finally {
lock.unlock();
}
}
boolean remove(K key) {
lock.lock();
try {
if (storage.remove(key) == null) return false;
policy.onRemove(key);
return true;
} finally {
lock.unlock();
}
}
int size() {
lock.lock();
try { return storage.size(); } finally { lock.unlock(); }
}
}
Go deeperDoublyLinkedList.java
class DoublyLinkedList<K> {
static final class Node<K> {
final K key;
Node<K> prev, next;
Node(K key) { this.key = key; }
}
private final Node<K> head = new Node<>(null), tail = new Node<>(null);
DoublyLinkedList() { head.next = tail; tail.prev = head; }
void addFirst(Node<K> n) {
n.next = head.next;
n.prev = head;
head.next.prev = n;
head.next = n;
}
void unlink(Node<K> n) {
n.prev.next = n.next;
n.next.prev = n.prev;
n.prev = n.next = null;
}
Node<K> removeLast() {
if (tail.prev == head) return null;
Node<K> n = tail.prev;
unlink(n);
return n;
}
}
Cache<Integer, String> lru = new Cache<>(3, new LruPolicy<>());
Cache<Integer, String> fifo = new Cache<>(3, new FifoPolicy<>());
List<String> out = new ArrayList<>();
for (Cache<Integer, String> c : List.of(lru, fifo)) {
c.put(1, "a"); c.put(2, "b"); c.put(3, "c");
c.get(1); // 1 becomes most recent (LRU only)
out.add("put 4 evicts " + c.put(4, "d").orElse(null));
}
// LRU evicts 2 (least recently used); FIFO evicts 1 (oldest insert)
Code that ranDemo.java: the complete code behind the output below
public class Demo {
public static void main(String[] args) throws Exception {
Cache<Integer, String> lru = new Cache<>(3, new LruPolicy<>());
Cache<Integer, String> fifo = new Cache<>(3, new FifoPolicy<>());
List<String> out = new ArrayList<>();
for (Cache<Integer, String> c : List.of(lru, fifo)) {
c.put(1, "a"); c.put(2, "b"); c.put(3, "c");
c.get(1); // 1 becomes most recent (LRU only)
out.add("put 4 evicts " + c.put(4, "d").orElse(null));
}
// LRU evicts 2 (least recently used); FIFO evicts 1 (oldest insert)
out.forEach(System.out::println);
check(out.equals(List.of("put 4 evicts 2", "put 4 evicts 1")), "policies");
ClassicLru<String, Integer> classic = new ClassicLru<>(2);
classic.put("x", 1); classic.put("y", 2); classic.get("x"); classic.put("z", 3);
check(classic.get("y") == null && classic.get("x") == 1 && classic.get("z") == 3, "classic");
// 8 threads hammer a 100-entry cache with 1,000 keys: size never exceeds capacity, nothing throws
Cache<Integer, Integer> shared = new Cache<>(100, new LruPolicy<>());
ExecutorService pool = Executors.newFixedThreadPool(8);
for (int t = 0; t < 8; t++) pool.submit(() -> {
ThreadLocalRandom r = ThreadLocalRandom.current();
for (int i = 0; i < 50_000; i++) {
int k = r.nextInt(1_000);
if (r.nextBoolean()) shared.put(k, k); else shared.get(k).ifPresent(v -> check(v == k, "value"));
check(shared.size() <= 100, "capacity");
}
});
pool.shutdown();
check(pool.awaitTermination(30, TimeUnit.SECONDS), "finished");
System.out.println("concurrent run finished; size " + shared.size() + " of capacity 100");
}
static void check(boolean ok, String what) { if (!ok) throw new AssertionError(what); }
}
put 4 evicts 2
put 4 evicts 1
concurrent run finished; size 100 of capacity 100
P3.6Concurrency
The trap: get looks like a read, but it moves the node in the list, so two concurrent gets corrupt the links. A ReadWriteLock with gets under the read lock is therefore wrong for LRU. Options, from simplest:
| Approach | Trade-off |
|---|---|
One ReentrantLock (or synchronized) around every operation |
Correct and simple; all operations serialize. Fine for interviews and most services |
Collections.synchronizedMap(new LinkedHashMap<>(cap, 0.75f, true)) |
Same, using the JDK's LRU support |
| Lock striping: N independent caches, key hash picks one | Less contention; LRU becomes approximate across the whole cache |
| Approximate recency (Caffeine, Redis) | Record accesses in buffers and apply them in batches, or sample keys at eviction time; near-LRU with high concurrency |
In production Java, use Caffeine rather than hand-written caches: it uses an admission and eviction policy (W-TinyLFU) that beats plain LRU on most workloads and stays fast under concurrency. Redis approximates LRU by sampling a few keys and evicting the least recent among them. The interview version shows you understand what those libraries do.
P3.7Deep dives
Guided designExtensions interviewers ask forAnswer each before revealing
- 1Time-to-live per entry
Entries must expire 5 minutes after they are written.
Write your answer, then reveal
Store an expiry time with each value (
record Entry(V value, long expiresAt)). Onget, treat an expired entry as a miss and remove it (lazy expiry). To free memory without reads, run a periodic sweep, or keep aPriorityQueueorDelayQueueordered by expiry. Inject aClockso tests can move time. - 2LFU instead of LRU
Evict the least frequently used key, breaking ties by least recent. O(1)?
Write your answer, then reveal
Keep
Map<K, Integer> freq,Map<Integer, LinkedHashSet<K>> keysByFreq, andminFreq. On access, move the key from its frequency set to the next one; if the old set was the minimum and is now empty, incrementminFreq. On eviction, remove the first key ofkeysByFreq.get(minFreq). A new key starts at frequency 1 and resetsminFreqto 1. All operations are O(1). It fits behindEvictionPolicyunchanged. - 3Loading on a miss
Callers want
get(key, loader)that computes and caches missing values, without two threads loading the same key.Write your answer, then reveal
Single-flight per key: a
ConcurrentHashMap<K, CompletableFuture<V>>of in-flight loads; the first caller installs a future withcomputeIfAbsentand runs the loader, others wait on the same future. Remove the future when it completes, and store the value in the cache. - 4Size by bytes, not entries
Values vary from 1 KB to 5 MB.
Write your answer, then reveal
Give the cache a weigher (
ToLongFunction<V>) and a maximum weight; evict victims until the total weight fits. Large values may evict many small ones; consider refusing values above a threshold.
P3.8Machine coding plan
| Minutes | Do |
|---|---|
| 0-5 | Clarify update semantics, nulls, capacity, threads |
| 5-25 | ClassicLru with sentinels; test the evict-after-get case |
| 25-40 | Extract EvictionPolicy, LruPolicy, Cache with a lock |
| 40-50 | FifoPolicy or LFU to prove the seam |
| 50-60 | Concurrency test with a thread pool; walk through |
P3.9What a strong answer shows
- SDE-2 bar: correct O(1) structure with sentinels; update counts as a use; capacity edge cases; thread safety with one lock and the reason a read lock is wrong.
- Above the bar: separates policy from storage; implements LFU in O(1) on request; discusses TTL, single-flight loading, and what Caffeine or Redis do differently.
- Why a doubly linked list rather than a singly linked one?
Reveal
Unlinking a node in O(1) needs its predecessor. With a doubly linked list each node knows
prev; a singly linked list would need a walk from the head. - Why sentinel head and tail nodes?
Reveal
They remove the special cases for empty lists and for the first and last node, so link and unlink are four assignments each with no null checks.
- Your
CachereturnsOptional<V>. Why notnullon a miss?Reveal
Optionalforces callers to handle the miss explicitly. It also explains whynullvalues are rejected: a storednullcould not be told apart from a miss. - How would you test the LRU order?
Reveal
Put keys 1, 2, 3 into a capacity-3 cache, get 1, put 4, and assert that 2 was evicted and 1, 3, 4 remain. Then repeat with an update to an existing key instead of a get.
Problem 4: Snake and ladder
A favorite machine coding warm-up because it rewards clean modeling over cleverness: a board that validates itself, dice and rules behind interfaces so tests can script games, a turn queue, and a game loop with a clear end. The same skeleton fits tic-tac-toe, Ludo, and chess.
Design snake and ladder for N players on a configurable board. A player must land exactly on the last cell to win. Variants (two dice, an extra turn on a six) are likely. Write it so a test can play a full game deterministically.
Reveal the answer
Strong answer: an immutable Board with one map of jumps (snakes and ladders are both start-to-end jumps), built by a builder that validates direction, range, duplicates, and chains; Dice interface with random and scripted implementations; Rules interface for exact finish and extra turns; a Game with a turn queue (ArrayDeque), playTurn returning the winner when there is one, and a move log; seeded randomness for reproducible games.
P4.1Requirements
| Question | Assumed answer |
|---|---|
| Board? | Size N (100 by default), configurable snakes and ladders |
| Players? | Two or more, in a fixed turn order |
| Winning? | Land exactly on the last cell; overshooting means you stay |
| Dice? | One six-sided die by default; variants with more dice |
| Variants? | Extra turn on a six, possibly others |
| Input? | Board and players from input, then play until someone wins and print the moves |
Invariants: snakes go down and ladders go up; no two jumps start on the same cell; no jump starts where another ends (no chains); the game has at most one winner and no turns after it.
P4.2Entities and responsibilities
| Class | Responsibility |
|---|---|
Board |
Size and jumps, immutable once built; its builder validates them; resolve(cell) gives the final cell after a snake or ladder |
Dice |
roll(); RandomDice with a seed, ScriptedDice for tests |
Rules |
Target cell for a roll (exact finish) and whether a roll earns an extra turn |
Player |
Name and position |
Game |
Turn queue, winner, move log; plays one turn or to the end |
Modeling insight worth saying aloud: a snake and a ladder are the same thing, a jump from one cell to another. One map handles both; only the validation differs.
P4.3Class diagram
P4.4Key flows
P4.5Code
/** Immutable once built, so one board can be shared by many games. */
final class Board {
private final int size;
private final Map<Integer, Integer> jumps; // start cell -> end cell, snakes and ladders alike
private Board(int size, Map<Integer, Integer> jumps) {
this.size = size;
this.jumps = Map.copyOf(jumps);
}
static Builder builder(int size) { return new Builder(size); }
/** Where a piece ends up after landing on `cell`. */
int resolve(int cell) { return jumps.getOrDefault(cell, cell); }
int size() { return size; }
static final class Builder {
private final int size;
private final Map<Integer, Integer> jumps = new HashMap<>();
private Builder(int size) {
if (size < 10) throw new IllegalArgumentException("board too small");
this.size = size;
}
Builder snake(int head, int tail) {
if (head <= tail) throw new IllegalArgumentException("snake must go down: " + head + " -> " + tail);
return addJump(head, tail);
}
Builder ladder(int bottom, int top) {
if (bottom >= top) throw new IllegalArgumentException("ladder must go up: " + bottom + " -> " + top);
return addJump(bottom, top);
}
private Builder addJump(int from, int to) {
if (from <= 1 || from >= size || to < 1 || to > size) throw new IllegalArgumentException("jump out of range: " + from + " -> " + to);
if (jumps.containsKey(from)) throw new IllegalArgumentException("cell " + from + " already has a jump");
if (jumps.containsValue(from) || jumps.containsKey(to)) throw new IllegalArgumentException("chained jumps at " + from + " -> " + to);
jumps.put(from, to);
return this;
}
Board build() { return new Board(size, jumps); }
}
}
class Game {
private final Board board;
private final Dice dice;
private final Rules rules;
private final Deque<Player> turns = new ArrayDeque<>();
private final List<String> log = new ArrayList<>();
private Player winner;
Game(Board board, Dice dice, Rules rules, List<Player> players) {
if (players.size() < 2) throw new IllegalArgumentException("need at least 2 players");
this.board = board;
this.dice = dice;
this.rules = rules;
this.turns.addAll(players);
}
/** Plays one turn; returns the winner once someone reaches the last cell. */
Optional<Player> playTurn() {
if (winner != null) throw new IllegalStateException("game over: " + winner.name() + " won");
Player p = turns.poll();
int roll = dice.roll();
int from = p.position();
int target = rules.target(from, roll, board.size());
int end = board.resolve(target);
p.moveTo(end);
log.add(describe(p, roll, from, target, end));
if (end == board.size()) {
winner = p;
return Optional.of(p);
}
if (rules.extraTurn(roll)) turns.addFirst(p); // same player again
else turns.addLast(p);
return Optional.empty();
}
Player playToEnd(int maxTurns) {
for (int i = 0; i < maxTurns; i++) {
Optional<Player> w = playTurn();
if (w.isPresent()) return w.get();
}
throw new IllegalStateException("no winner after " + maxTurns + " turns");
}
List<String> log() { return log; }
private String describe(Player p, int roll, int from, int target, int end) {
String s = p.name() + " rolled " + roll + ": " + from + " -> " + target;
if (target == from) return s + " (overshoot, stays)";
if (end > target) return s + " ladder up to " + end;
if (end < target) return s + " snake down to " + end;
return s;
}
}
interface Rules {
/** Target cell before snakes and ladders, or the current cell if the move is not allowed. */
int target(int current, int roll, int boardSize);
boolean extraTurn(int roll);
}
class StandardRules implements Rules {
public int target(int current, int roll, int boardSize) {
int next = current + roll;
return next > boardSize ? current : next; // must land exactly on the last cell
}
public boolean extraTurn(int roll) { return false; }
}
class SixGetsAnotherTurn extends StandardRules {
@Override public boolean extraTurn(int roll) { return roll == 6; }
}
Go deeperDice and Player
interface Dice {
int roll();
}
import java.util.Random;
class RandomDice implements Dice {
private final Random random;
private final int count;
RandomDice(int count, long seed) { this.count = count; this.random = new Random(seed); } // seed: reproducible games
public int roll() {
int sum = 0;
for (int i = 0; i < count; i++) sum += 1 + random.nextInt(6);
return sum;
}
}
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.List;
class ScriptedDice implements Dice { // for tests and demos
private final Deque<Integer> rolls;
ScriptedDice(List<Integer> rolls) { this.rolls = new ArrayDeque<>(rolls); }
public int roll() {
if (rolls.isEmpty()) throw new IllegalStateException("script ran out of rolls");
return rolls.poll();
}
}
class Player {
private final String name;
private int position = 0; // 0 = off the board
Player(String name) { this.name = name; }
String name() { return name; }
int position() { return position; }
void moveTo(int cell) { position = cell; }
}
Board board = Board.builder(20).ladder(3, 11).ladder(8, 16).snake(14, 4).snake(19, 2).build();
List<Player> players = List.of(new Player("Asha"), new Player("Ravi"));
Dice dice = new ScriptedDice(List.of(3, 6, 3, 2, 4, 3, 6, 5, 4));
Game game = new Game(board, dice, new StandardRules(), players);
Player winner = game.playToEnd(100);
Code that ranDemo.java: the complete code behind the output below
public class Demo {
public static void main(String[] args) {
Board board = Board.builder(20).ladder(3, 11).ladder(8, 16).snake(14, 4).snake(19, 2).build();
List<Player> players = List.of(new Player("Asha"), new Player("Ravi"));
Dice dice = new ScriptedDice(List.of(3, 6, 3, 2, 4, 3, 6, 5, 4));
Game game = new Game(board, dice, new StandardRules(), players);
Player winner = game.playToEnd(100);
game.log().forEach(System.out::println);
System.out.println("winner: " + winner.name());
check(winner.name().equals("Asha"), "winner");
try { Board.builder(20).snake(5, 9); throw new AssertionError(); } catch (IllegalArgumentException e) { System.out.println("rejected: " + e.getMessage()); }
try { Board.builder(20).ladder(3, 11).snake(11, 2); throw new AssertionError(); } catch (IllegalArgumentException e) { System.out.println("rejected: " + e.getMessage()); }
Player w = new Game(Board.builder(100).ladder(2, 38).snake(99, 7).build(), new RandomDice(1, 42), new SixGetsAnotherTurn(),
List.of(new Player("A"), new Player("B"), new Player("C"))).playToEnd(10_000);
check(w.position() == 100, "random game ends on 100");
}
static void check(boolean ok, String what) { if (!ok) throw new AssertionError(what); }
}
Asha rolled 3: 0 -> 3 ladder up to 11
Ravi rolled 6: 0 -> 6
Asha rolled 3: 11 -> 14 snake down to 4
Ravi rolled 2: 6 -> 8 ladder up to 16
Asha rolled 4: 4 -> 8 ladder up to 16
Ravi rolled 3: 16 -> 19 snake down to 2
Asha rolled 6: 16 -> 16 (overshoot, stays)
Ravi rolled 5: 2 -> 7
Asha rolled 4: 16 -> 20
winner: Asha
rejected: snake must go down: 5 -> 9
rejected: chained jumps at 11 -> 2
The demo also runs a three-player game on a 100-cell board with a seeded random die and the six-gets-another-turn rule, and checks it ends on cell 100.
P4.6Hard parts
- Determinism. Randomness is injected (
Dice), so tests script exact games and a seededRandomreproduces a reported bug. - Validation at construction. A board that cannot be built invalid never needs checks during play, and because
build()copies the jumps into an immutable map, nothing can change it mid-game. - Termination.
playToEnd(maxTurns)guards against a board where nobody can win (for example, every path to the end is a snake), instead of looping forever. - No concurrency in the classic version: one game runs on one thread. For many games on a server, each game is confined to one thread or guarded by its own lock; games share nothing.
P4.7Deep dives
Guided designExtensions interviewers ask forAnswer each before revealing
- 1Two dice, and three sixes in a row forfeit the turn
How do these fit?
Write your answer, then reveal
Two dice:
RandomDice(2, seed)already sums several dice. Three sixes: a newRulesimplementation that tracks consecutive sixes per player; since rules now need state per player, either pass the turn history into the rules or letGamekeep a counter and ask aRulesmethodforfeit(consecutiveSixes). - 2Pieces capture each other (Ludo-style)
Landing on an opponent sends them back to start.
Write your answer, then reveal
After
resolve, check whether another player stands on the end cell (aMap<Integer, Player>of occupancy kept by the game) and move them to 0. This is a rule, so it belongs inRulesor a separateCollisionRulehook called by the game. - 3Undo the last move
A casual mode lets players undo.
Write your answer, then reveal
Make each turn a command with the player, previous position, and new position; push it on a stack; undo pops it, restores the position, and puts the player back at the head of the queue (Module 7).
- 4Run thousands of games on a server
How do you keep games independent and fast?
Write your answer, then reveal
Each
Gameobject is self-contained; store games in aConcurrentHashMap<gameId, Game>and process each game's moves on one thread at a time (a per-game lock or an actor-style single-threaded executor per game). Boards can be shared because they are immutable after construction.
P4.8Machine coding plan
| Minutes | Do |
|---|---|
| 0-7 | Clarify rules (exact finish, extra turns), input format, output lines |
| 7-25 | Board with validation, Player, Dice with scripted and random |
| 25-45 | Rules, Game.playTurn, playToEnd, move log |
| 45-60 | Driver: parse snakes, ladders, players from input; print moves and the winner |
| 60-75 | Tests with scripted dice: overshoot, snake, ladder, extra turn, invalid board |
P4.9What a strong answer shows
- SDE-2 bar: validated board; injected dice; exact-finish rule; correct turn order; deterministic tests; clean output.
- Above the bar: snakes and ladders unified as jumps; rules as a strategy with variants added live; a guard against games that never end; immutable boards shared across games.
- Why is
Board.resolvea single map lookup instead of looping until no jump applies?Reveal
Chains are rejected at construction, so one lookup is always final. That makes moves O(1) and removes any chance of a cycle.
- How would you print the board?
Reveal
A separate renderer class reads the board and player positions and formats them; the board itself should not know about console output.
- Why use
ArrayDequefor turns?Reveal
Taking the next player from the front and putting them at the back (or the front, for an extra turn) are O(1) deque operations, and the order is explicit.
- How do you make tic-tac-toe from this skeleton?
Reveal
Replace
Boardwith a grid,Dicewith player input, andRuleswith a win check (row, column, and diagonal counters for O(1) checks).Game,Player, the turn queue, and the log stay the same.
Problem 5: Logging framework
A logging framework is a pattern showcase that is also code you use every day: a shared registry of loggers, levels that filter cheaply, appenders that fan out, formatters as strategies, a template method in the base appender, and an async decorator built on a bounded queue. Knowing how SLF4J and Logback do it is the bonus.
Design a logging library: named loggers, levels (DEBUG, INFO, WARN, ERROR), several destinations with their own minimum levels and formats, and an option to log without blocking the calling thread. Then compare.
Reveal the answer
Strong answer: LogManager.getLogger(name) returning a cached logger; a logger level checked first, with lazy messages (Supplier<String>) so disabled logs cost nothing; appenders behind an interface, each with its own level and formatter; formatters as strategies (plain, JSON); an async appender as a decorator over a bounded queue with a worker thread, a drop counter, and a draining shutdown; reconfiguration that applies to existing loggers.
P5.1Requirements
| Question | Assumed answer |
|---|---|
| Levels? | DEBUG < INFO < WARN < ERROR; a logger has a minimum level |
| Destinations? | Console, file, memory (tests); each with its own minimum level and format |
| Formats? | Plain text and JSON |
| Performance? | Disabled levels must be nearly free; logging must not block request threads |
| Configuration? | Change levels and appenders at runtime for all loggers |
| Threads? | Many threads log at once |
Invariants: a message below the logger's level is never built; a message is written by an appender only if it meets that appender's level; every line logged before shutdown starts is written; when the async queue is full, drops are counted, not silent.
P5.2Entities and responsibilities
| Class | Responsibility | Pattern |
|---|---|---|
LogManager |
Registry of loggers by name; global configuration | Singleton-style registry (static) |
Logger |
Level check, build LogMessage, fan out to appenders |
|
LogMessage (record) |
Time, level, logger, thread, text | Value object |
Appender |
Write a message somewhere | Strategy / observer |
LevelAppender |
Filter by level, format, then write |
Template method |
Formatter |
Message to string | Strategy |
AsyncAppender |
Queue and a worker thread around any appender | Decorator + producer-consumer |
Textbooks often model logging as a chain where an ERROR handler passes to a WARN handler and so on. Real frameworks (Logback, Log4j 2) do what this design does instead: the logger level is a cheap first filter, then every attached appender decides for itself. Mention the textbook framing if asked, and why the fan-out is simpler.
P5.3Class diagram
P5.4Key flows
P5.5Code
public class Logger {
/** Everything a log call reads, swapped as one object so a call never mixes old and new settings. */
record Settings(Clock clock, LogLevel threshold, List<Appender> appenders) {
Settings { appenders = List.copyOf(appenders); }
}
private final String name;
private volatile Settings settings;
Logger(String name, Settings settings) {
this.name = name;
this.settings = settings;
}
public void debug(String msg) { log(LogLevel.DEBUG, () -> msg); }
public void info(String msg) { log(LogLevel.INFO, () -> msg); }
public void warn(String msg) { log(LogLevel.WARN, () -> msg); }
public void error(String msg) { log(LogLevel.ERROR, () -> msg); }
/** The message is built only if the level is enabled: no cost for disabled debug logs. */
public void debug(Supplier<String> msg) { log(LogLevel.DEBUG, msg); }
public boolean isEnabled(LogLevel level) { return level.atLeast(settings.threshold()); }
private void log(LogLevel level, Supplier<String> msg) {
Settings s = settings; // one volatile read: a consistent snapshot
if (!level.atLeast(s.threshold())) return; // cheap check first
LogMessage m = new LogMessage(s.clock().instant(), level, name, Thread.currentThread().getName(), msg.get());
for (Appender a : s.appenders()) a.append(m); // each appender applies its own level
}
void reconfigure(Settings next) { this.settings = next; }
}
/** One shared registry of loggers: getLogger returns the same Logger for the same name. */
public final class LogManager {
private static final Map<String, Logger> LOGGERS = new ConcurrentHashMap<>();
private static Logger.Settings settings = // guarded by LogManager.class
new Logger.Settings(Clock.systemUTC(), LogLevel.INFO, List.of(new ConsoleAppender(LogLevel.DEBUG, new PlainFormatter())));
private LogManager() { }
public static Logger getLogger(String name) {
Logger existing = LOGGERS.get(name); // fast path: no lock once created
if (existing != null) return existing;
synchronized (LogManager.class) { // creation and configure never interleave
return LOGGERS.computeIfAbsent(name, n -> new Logger(n, settings));
}
}
/** Applies to existing and future loggers. Tests call this with a fixed clock and a memory appender. */
public static synchronized void configure(Clock clock, LogLevel level, List<Appender> appenders) {
settings = new Logger.Settings(clock, level, appenders);
LOGGERS.values().forEach(l -> l.reconfigure(settings)); // existing loggers switch too
}
public static synchronized void shutdown() {
settings.appenders().forEach(Appender::close);
}
}
/** Filters by its own level, formats, and writes to a sink. */
abstract class LevelAppender implements Appender {
private final LogLevel threshold;
private final Formatter formatter;
LevelAppender(LogLevel threshold, Formatter formatter) { this.threshold = threshold; this.formatter = formatter; }
public final void append(LogMessage m) { // template method: filter, format, write
if (m.level().atLeast(threshold)) write(formatter.format(m));
}
protected abstract void write(String line);
}
/** Decorator: callers enqueue and return; one worker thread writes to the wrapped appender. */
final class AsyncAppender implements Appender {
private static final LogMessage STOP = new LogMessage(null, LogLevel.ERROR, "", "", ""); // unique sentinel
private final Appender inner;
private final BlockingQueue<LogMessage> queue;
private final LongAdder dropped = new LongAdder();
private final AtomicBoolean closed = new AtomicBoolean();
private final Thread worker;
AsyncAppender(Appender inner, int capacity) {
this.inner = inner;
this.queue = new ArrayBlockingQueue<>(capacity);
this.worker = new Thread(this::drain, "log-async");
worker.setDaemon(true);
worker.start(); // last step of a final class's constructor: every field is already set
}
public void append(LogMessage m) {
if (closed.get() || !queue.offer(m)) dropped.increment(); // never block the caller; count what we lose
}
private void drain() {
try {
for (LogMessage m = queue.take(); m != STOP; m = queue.take()) inner.append(m);
List<LogMessage> late = new ArrayList<>();
queue.drainTo(late); // lines that raced with close: write them too
late.forEach(inner::append);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
/** Lines logged before close() starts are all written; close is safe to call twice. */
public void close() {
if (!closed.compareAndSet(false, true)) return;
try {
queue.put(STOP);
worker.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
inner.close();
}
long dropped() { return dropped.sum(); }
}
Go deeperThe remaining files
public enum LogLevel {
DEBUG, INFO, WARN, ERROR; // declaration order is severity order
boolean atLeast(LogLevel threshold) { return compareTo(threshold) >= 0; }
}
import java.time.Instant;
public record LogMessage(Instant time, LogLevel level, String logger, String thread, String text) {}
interface Appender {
void append(LogMessage m);
default void close() { }
}
class ConsoleAppender extends LevelAppender {
ConsoleAppender(LogLevel threshold, Formatter f) { super(threshold, f); }
protected void write(String line) { System.out.println(line); }
}
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
class MemoryAppender extends LevelAppender { // for tests: keeps lines in order
final List<String> lines = new CopyOnWriteArrayList<>();
MemoryAppender(LogLevel threshold, Formatter f) { super(threshold, f); }
protected void write(String line) { lines.add(line); }
}
interface Formatter {
String format(LogMessage m);
}
class PlainFormatter implements Formatter {
public String format(LogMessage m) {
return m.time() + " " + m.level() + " [" + m.thread() + "] " + m.logger() + " - " + m.text();
}
}
class JsonFormatter implements Formatter {
public String format(LogMessage m) {
return "{\"time\":\"" + m.time() + "\",\"level\":\"" + m.level() + "\",\"logger\":\"" + m.logger()
+ "\",\"msg\":\"" + m.text().replace("\\", "\\\\").replace("\"", "\\\"") + "\"}";
}
}
Clock fixed = Clock.fixed(Instant.parse("2026-10-21T10:15:30Z"), ZoneOffset.UTC);
MemoryAppender all = new MemoryAppender(LogLevel.DEBUG, new PlainFormatter());
MemoryAppender alerts = new MemoryAppender(LogLevel.ERROR, new JsonFormatter());
LogManager.configure(fixed, LogLevel.INFO, List.of(all, alerts));
Logger log = LogManager.getLogger("payments");
AtomicInteger expensiveCalls = new AtomicInteger();
log.debug(() -> "state dump " + expensiveCalls.incrementAndGet()); // DEBUG < INFO: supplier never runs
log.info("charge started for o-42");
log.error("charge failed for o-42: \"card declined\"");
Code that ranDemo.java: the complete code behind the output below
public class Demo {
public static void main(String[] args) throws Exception {
Clock fixed = Clock.fixed(Instant.parse("2026-10-21T10:15:30Z"), ZoneOffset.UTC);
MemoryAppender all = new MemoryAppender(LogLevel.DEBUG, new PlainFormatter());
MemoryAppender alerts = new MemoryAppender(LogLevel.ERROR, new JsonFormatter());
LogManager.configure(fixed, LogLevel.INFO, List.of(all, alerts));
Logger log = LogManager.getLogger("payments");
AtomicInteger expensiveCalls = new AtomicInteger();
log.debug(() -> "state dump " + expensiveCalls.incrementAndGet()); // DEBUG < INFO: supplier never runs
log.info("charge started for o-42");
log.error("charge failed for o-42: \"card declined\"");
all.lines.forEach(System.out::println);
alerts.lines.forEach(System.out::println);
System.out.println("expensive debug message built " + expensiveCalls.get() + " times");
check(all.lines.size() == 2 && alerts.lines.size() == 1 && expensiveCalls.get() == 0, "levels");
check(LogManager.getLogger("payments") == log, "same logger");
// async: 4 threads log 2,500 lines each through one AsyncAppender; shutdown drains everything
MemoryAppender sink = new MemoryAppender(LogLevel.DEBUG, new PlainFormatter());
AsyncAppender async = new AsyncAppender(sink, 20_000);
LogManager.configure(fixed, LogLevel.DEBUG, List.of(async));
Logger busy = LogManager.getLogger("orders");
List<Thread> threads = new ArrayList<>();
for (int t = 0; t < 4; t++) {
Thread th = new Thread(() -> { for (int i = 0; i < 2_500; i++) busy.info("line " + i); }, "worker-" + t);
threads.add(th);
th.start();
}
for (Thread th : threads) th.join();
LogManager.shutdown();
System.out.println("async wrote " + sink.lines.size() + " lines, dropped " + async.dropped());
check(sink.lines.size() + async.dropped() == 10_000, "every line written or counted");
}
static void check(boolean ok, String what) { if (!ok) throw new AssertionError(what); }
}
2026-10-21T10:15:30Z INFO [main] payments - charge started for o-42
2026-10-21T10:15:30Z ERROR [main] payments - charge failed for o-42: "card declined"
{"time":"2026-10-21T10:15:30Z","level":"ERROR","logger":"payments","msg":"charge failed for o-42: \"card declined\""}
expensive debug message built 0 times
async wrote 10000 lines, dropped 0
The last line comes from four threads logging 10,000 lines through one async appender; shutdown drains the queue before returning.
P5.6Concurrency
| Concern | Handled by |
|---|---|
| Two threads ask for the same logger name | ConcurrentHashMap.computeIfAbsent: one instance |
| Reconfiguration while threads log | Each logger holds one volatile immutable Settings record (clock, level, appenders), replaced as a whole, so a log call never mixes old and new settings |
A logger created during configure |
Creation and configure share one lock, so every logger either sees the new settings or is updated by configure |
| Many threads writing to one appender | The async appender serializes writes on one worker thread; a synchronous file appender would lock around each write |
| A slow disk | Bounded queue; offer never blocks; drops are counted |
| Shutdown | A sentinel message and join, so lines logged before shutdown are written; close is idempotent and later lines are counted as dropped |
This is how SLF4J with Logback works: LoggerFactory.getLogger(...) returns a cached logger; log.debug("order {}", id) uses placeholders so the string is built only if DEBUG is enabled (the same goal as the Supplier here); appenders and encoders are configured per destination; AsyncAppender wraps another appender with a bounded queue and, by default, discards lower-level events when the queue is nearly full. Log4j 2 adds lock-free async loggers using the LMAX Disruptor.
P5.7Deep dives
Guided designExtensions interviewers ask forAnswer each before revealing
- 1Hierarchical levels
com.shopat INFO,com.shop.paymentsat DEBUG; loggers inherit the nearest configured ancestor's level.Write your answer, then reveal
Store configured levels by name prefix. When creating or reconfiguring a logger, walk its name from most specific to least (
com.shop.payments.Gateway,com.shop.payments,com.shop, root) and take the first configured level. Cache the result in the logger and recompute all loggers when configuration changes. - 2Rolling log files
Start a new file every day or at 100 MB, keeping the last 7.
Write your answer, then reveal
A
RollingFileAppenderwith aRollingPolicystrategy (by time, by size, or both) that decides when to roll, and a retention policy that deletes old files. Rolling happens under the appender's lock so no line is split across files. - 3Request IDs in every line
Every log line for one request should include its request ID without passing it to each call.
Write your answer, then reveal
A mapped diagnostic context: a
ThreadLocal<Map<String, String>>set at the start of a request and cleared at the end (in afinally).Loggercopies it intoLogMessage, and formatters print it. With async work, copy the context into tasks explicitly, because thread pools reuse threads. - 4Never lose ERROR lines
The queue may drop lines under load, but errors must always be written.
Write your answer, then reveal
Make dropping level-aware: when the queue is above, say, 80% full, drop DEBUG and INFO but block briefly (or write synchronously) for WARN and ERROR. Keep the drop counter per level and log it periodically.
P5.8Machine coding plan
| Minutes | Do |
|---|---|
| 0-8 | Clarify levels, destinations, formats, async |
| 8-25 | LogLevel, LogMessage, Formatter with two implementations, Appender, LevelAppender, memory and console appenders |
| 25-45 | Logger with level check and lazy messages; LogManager.getLogger and configure |
| 45-65 | AsyncAppender with bounded queue, drop counter, draining shutdown |
| 65-80 | Demo and checks: filtering, per-appender levels, lazy debug, multi-threaded async |
| 80-90 | Walk through; hierarchical levels as the extension |
P5.9What a strong answer shows
- SDE-2 bar: clear separation of logger, appender, and formatter; level filtering before work; thread-safe logger registry; a working async appender with shutdown.
- Above the bar: lazy messages; bounded queue with counted drops and level-aware dropping; reconfiguration of existing loggers; MDC; can explain how Logback and SLF4J map to the design.
- Why should
log.debug("x=" + compute())worry you, and what fixes it?Reveal
The string and
compute()run even when DEBUG is disabled. Use aSupplier(log.debug(() -> ...)), placeholders (log.debug("x={}", value)), or anisDebugEnabled()guard for expensive work. - Why does
LevelAppender.appendusefinal?Reveal
So subclasses cannot skip the level check or formatting; they only supply
write. That is the template method's guarantee. - Is
LogManagera singleton? Is that a problem for tests?Reveal
It is a static registry, effectively a singleton. Tests stay manageable because
configurereplaces the clock and appenders for all loggers; in application code, loggers are typically obtained statically by convention, which is an accepted exception to "inject everything". - What happens to a log line if the process crashes with lines still in the async queue?
Reveal
They are lost. That is the price of non-blocking logging; for audit trails that must never be lost, write synchronously to durable storage instead of a log.
Problem 6: Rate limiter
The LLD version of a rate limiter is about classes and concurrency inside one process: interchangeable algorithms behind one interface, one limiter per key created safely, time injected so behavior is testable, and a check-and-decrement that stays atomic under load. The distributed version (Redis, Lua) is in the HLD handbook.
Design an in-process rate limiter: each API key gets N requests per time window; premium keys get more; the algorithm should be swappable; it must be correct with many threads. Then compare.
Reveal the answer
Strong answer: a Limiter interface with token bucket, sliding window log, and fixed window implementations; a keyed limiter holding a ConcurrentHashMap<String, Limiter> filled with computeIfAbsent and a factory that applies per-tier rules; lazy token refill computed from elapsed time (no timer thread); per-limiter synchronization so check and decrement are atomic; an injected time source for tests; and a plan for idle-key cleanup and multi-instance limits.
P6.1Requirements
| Question | Assumed answer |
|---|---|
| Limit by? | API key (could be user ID or IP) |
| Rules? | Free: 10 requests with 5 per second refill; premium: 100 with 50 per second |
| Burst behavior? | Short bursts are fine up to the capacity |
| Response when limited? | tryAcquire returns false; the caller returns HTTP 429 |
| Scope? | One process; many threads |
| Algorithm? | Token bucket by default, swappable |
P6.2Entities and responsibilities
| Class | Responsibility |
|---|---|
Limiter |
One decision: may this request proceed? |
TokenBucket |
Capacity and refill rate; allows bursts, smooth long-term rate |
SlidingWindowLog |
Exact limit over any trailing window; one timestamp per request |
FixedWindow |
Counter per aligned window; cheap; boundary bursts |
KeyedRateLimiter |
One limiter per key, created by a factory with tier rules |
TimeSource |
Current time, injected |
P6.3Class diagram
P6.4Key flows
| Algorithm | Memory per key | Accuracy | Bursts | Use when |
|---|---|---|---|---|
| Token bucket | Two numbers | Smooth average rate | Up to capacity | Default for APIs |
| Leaky bucket (queue) | A queue | Constant output rate | Smoothed away | Shaping traffic to a fixed rate |
| Fixed window counter | Two numbers | Up to 2x at boundaries | At boundaries | Cheap, approximate quotas |
| Sliding window log | One timestamp per request | Exact | None beyond the limit | Low limits where exactness matters |
| Sliding window counter | Two counters | Close approximation | Small | High volume with near-exact limits |
P6.5Code
/** Holds up to `capacity` tokens, refilled continuously at `perSecond`; each request takes one. Allows bursts. */
class TokenBucket implements Limiter {
private final long capacity;
private final double refillPerNano;
private final TimeSource time;
private double tokens; // guarded by this
private long lastRefill; // guarded by this
TokenBucket(long capacity, double perSecond, TimeSource time) {
if (capacity <= 0 || perSecond < 0) throw new IllegalArgumentException("bad bucket config");
this.capacity = capacity;
this.refillPerNano = perSecond / 1_000_000_000.0;
this.time = time;
this.tokens = capacity; // start full
this.lastRefill = time.nanos();
}
public synchronized boolean tryAcquire() {
long now = time.nanos();
tokens = Math.min(capacity, tokens + (now - lastRefill) * refillPerNano); // lazy refill: no timer thread
lastRefill = now;
if (tokens < 1) return false;
tokens -= 1;
return true;
}
}
/** At most `limit` requests in any window of `windowNanos`. Exact, but stores one timestamp per allowed request. */
class SlidingWindowLog implements Limiter {
private final int limit;
private final long windowNanos;
private final TimeSource time;
private final Deque<Long> accepted = new ArrayDeque<>(); // guarded by this
SlidingWindowLog(int limit, long windowNanos, TimeSource time) {
this.limit = limit;
this.windowNanos = windowNanos;
this.time = time;
}
public synchronized boolean tryAcquire() {
long now = time.nanos();
while (!accepted.isEmpty() && now - accepted.peekFirst() >= windowNanos) accepted.pollFirst(); // drop expired
if (accepted.size() >= limit) return false;
accepted.addLast(now);
return true;
}
}
/** Counts requests per aligned window. Cheap, but allows up to 2x the limit across a window boundary. */
class FixedWindow implements Limiter {
private final int limit;
private final long windowNanos;
private final TimeSource time;
private long windowStart = Long.MIN_VALUE; // guarded by this
private int count; // guarded by this
FixedWindow(int limit, long windowNanos, TimeSource time) {
this.limit = limit;
this.windowNanos = windowNanos;
this.time = time;
}
public synchronized boolean tryAcquire() {
long start = Math.floorDiv(time.nanos(), windowNanos) * windowNanos;
if (start != windowStart) { windowStart = start; count = 0; } // a new window resets the count
if (count >= limit) return false;
count++;
return true;
}
}
/** One limiter per key (user, API key, IP), created on first use by a factory that can apply per-tier rules. */
class KeyedRateLimiter {
private final Map<String, Limiter> limiters = new ConcurrentHashMap<>();
private final Function<String, Limiter> factory;
KeyedRateLimiter(Function<String, Limiter> factory) { this.factory = factory; }
boolean tryAcquire(String key) {
return limiters.computeIfAbsent(key, factory).tryAcquire(); // one limiter per key, even under races
}
}
Go deeperInterfaces
interface Limiter {
boolean tryAcquire();
}
/** Injected time, so tests control it. Production: System::nanoTime. */
@FunctionalInterface
interface TimeSource {
long nanos();
}
TokenBucket bucket = new TokenBucket(5, 1.0, clock); // burst of 5, then 1 per second
int burst = count(bucket, 10); // 5 allowed at t = 0
now.addAndGet(3 * SECOND);
int later = count(bucket, 10); // 3 tokens refilled by t = 3 s
now.set(0);
Limiter fixed = new FixedWindow(10, 60 * SECOND, clock);
Limiter sliding = new SlidingWindowLog(10, 60 * SECOND, clock);
now.set(59 * SECOND); // end of the first minute
int f1 = count(fixed, 10), s1 = count(sliding, 10);
now.set(61 * SECOND); // two seconds later, a new fixed window
int f2 = count(fixed, 10), s2 = count(sliding, 10);
KeyedRateLimiter api = new KeyedRateLimiter(key ->
key.startsWith("premium:") ? new TokenBucket(100, 50, clock) : new TokenBucket(10, 5, clock));
Code that ranDemo.java: the complete code behind the output below
public class Demo {
static final long SECOND = 1_000_000_000L;
public static void main(String[] args) throws Exception {
AtomicLong now = new AtomicLong(0);
TimeSource clock = now::get; // manual time for the demo
TokenBucket bucket = new TokenBucket(5, 1.0, clock); // burst of 5, then 1 per second
int burst = count(bucket, 10); // 5 allowed at t = 0
now.addAndGet(3 * SECOND);
int later = count(bucket, 10); // 3 tokens refilled by t = 3 s
System.out.println("token bucket: " + burst + " allowed in a burst, " + later + " allowed 3 s later");
check(burst == 5 && later == 3, "bucket");
now.set(0);
Limiter fixed = new FixedWindow(10, 60 * SECOND, clock);
Limiter sliding = new SlidingWindowLog(10, 60 * SECOND, clock);
now.set(59 * SECOND); // end of the first minute
int f1 = count(fixed, 10), s1 = count(sliding, 10);
now.set(61 * SECOND); // two seconds later, a new fixed window
int f2 = count(fixed, 10), s2 = count(sliding, 10);
System.out.println("within 2 seconds: fixed window allowed " + (f1 + f2) + ", sliding log allowed " + (s1 + s2));
check(f1 + f2 == 20 && s1 + s2 == 10, "boundary");
KeyedRateLimiter api = new KeyedRateLimiter(key ->
key.startsWith("premium:") ? new TokenBucket(100, 50, clock) : new TokenBucket(10, 5, clock));
int free = 0, premium = 0;
for (int i = 0; i < 200; i++) { if (api.tryAcquire("free:u1")) free++; if (api.tryAcquire("premium:u2")) premium++; }
System.out.println("per-key tiers: free key got " + free + ", premium key got " + premium);
check(free == 10 && premium == 100, "tiers");
// 8 threads, one key, 100 tokens, no refill: exactly 100 succeed
KeyedRateLimiter shared = new KeyedRateLimiter(k -> new TokenBucket(100, 0, clock));
AtomicInteger ok = new AtomicInteger();
ExecutorService pool = Executors.newFixedThreadPool(8);
for (int t = 0; t < 8; t++) pool.submit(() -> { for (int i = 0; i < 1_000; i++) if (shared.tryAcquire("hot")) ok.incrementAndGet(); });
pool.shutdown();
check(pool.awaitTermination(20, TimeUnit.SECONDS), "finished");
System.out.println("8 threads, 8,000 attempts, 100 tokens: " + ok.get() + " allowed");
check(ok.get() == 100, "concurrency");
}
static int count(Limiter l, int attempts) {
int ok = 0;
for (int i = 0; i < attempts; i++) if (l.tryAcquire()) ok++;
return ok;
}
static void check(boolean ok, String what) { if (!ok) throw new AssertionError(what); }
}
token bucket: 5 allowed in a burst, 3 allowed 3 s later
within 2 seconds: fixed window allowed 20, sliding log allowed 10
per-key tiers: free key got 10, premium key got 100
8 threads, 8,000 attempts, 100 tokens: 100 allowed
P6.6Concurrency
Two levels of shared state, two tools:
- The map of limiters.
computeIfAbsentguarantees one limiter per key even when the first two requests for a key race. Agetfollowed byputwould let both create their own bucket, doubling the limit. - Each limiter's counters. Refill-then-take is a read-modify-write, so
tryAcquireissynchronizedon the limiter. Different keys never contend; the same hot key serializes, which is correct.
A lock-free alternative for the token bucket stores tokens and the last refill time in one immutable object inside an AtomicReference and updates it with a compare-and-set loop. It avoids blocking but retries under heavy contention on one key; the synchronized version is easier to get right in an interview.
Use a monotonic clock (System.nanoTime) for elapsed time, not wall-clock time: wall clocks can jump backward with NTP adjustments, which would produce negative refills or stuck windows.
P6.7Deep dives
Guided designExtensions interviewers ask forAnswer each before revealing
- 1Idle keys fill memory
Millions of IPs hit the API once each. The map grows forever.
Write your answer, then reveal
Expire idle limiters: store the last access time and periodically remove entries idle long enough to be back at their starting state (for a token bucket,
capacity / rateseconds, when it is full again; for windows, one window length). Dropping such an entry changes nothing, because a fresh limiter behaves the same. Remove withmap.remove(key, limiter)so a limiter replaced in the meantime is not deleted by mistake. Or use a bounded cache with expire-after-access (Caffeine) as the map. - 2Several application instances
The service runs on 10 servers behind a load balancer.
Write your answer, then reveal
Giving each instance a tenth of the limit works only if the load balancer spreads each key's traffic evenly, which it usually does not. For a global limit, keep counters in Redis and make the check atomic there (a Lua script for the token bucket, or
INCRwithEXPIREfor fixed windows). Keep a small local limiter as a first line of defense to protect Redis. - 3Different limits per endpoint
/searchis cheap,/exportis expensive.Write your answer, then reveal
Key the limiter by
apiKey + endpoint, or give requests a cost:tryAcquire(cost)takescosttokens. Rules come from a configuration map from (tier, endpoint) to (capacity, rate), applied by the factory. - 4Tell the client when to retry
Return a
Retry-Afterheader.Write your answer, then reveal
Return a decision object instead of a boolean:
allowed,remaining, andretryAfter. For a token bucket,retryAfteris the time until one token refills:(1 - tokens) / refillRate.
P6.8Machine coding plan
| Minutes | Do |
|---|---|
| 0-7 | Clarify keys, rules, burst behavior, threads |
| 7-15 | Limiter, TimeSource |
| 15-35 | TokenBucket with lazy refill; manual-time test of burst and refill |
| 35-50 | SlidingWindowLog or FixedWindow; the boundary test |
| 50-62 | KeyedRateLimiter with tier factory |
| 62-75 | Thread-pool test on one key; walk through Redis for multi-instance |
P6.9What a strong answer shows
- SDE-2 bar: two algorithms behind one interface; per-key limiters created atomically; synchronized decisions; injected time; correct concurrency test.
- Above the bar: compares algorithms on memory, accuracy, and bursts; explains the boundary problem; monotonic time; idle-key cleanup; how the design moves to Redis across instances.
- Why is lazy refill better than a scheduled task that adds tokens every second?
Reveal
No background thread per key, no timer drift, and exact refill at any granularity: tokens are computed from elapsed time whenever a request arrives.
- Why does the bucket start full?
Reveal
So a new client can make a burst immediately. Starting empty would reject the first requests of every new key, which feels broken. Choose deliberately and say it.
- What would go wrong with
if (!map.containsKey(k)) map.put(k, newBucket())?Reveal
Two concurrent first requests can both create buckets; one overwrites the other, and requests already counted against the lost bucket do not count.
computeIfAbsentmakes creation atomic. - How do you test a rate limiter without sleeping?
Reveal
Inject a manual time source, advance it explicitly between calls, and assert exact counts.
Problem 7: Expense sharing (Splitwise)
The most common machine coding problem at Indian product companies, and a test of three things: split types as strategies, money that never loses a paisa, and a balance model that nets debts in both directions. Debt simplification is the classic follow-up.
Users add expenses paid by one person and shared by several, split equally, by exact amounts, or by percentages. Show who owes whom, support settle-up, and simplify debts. Then compare.
Reveal the answer
Strong answer: SplitStrategy per split type chosen by a factory; amounts in paise (long) with explicit handling of leftover paise; Expense validated so shares add up to the total; a BalanceSheet of pairwise net debts that never stores both directions; show(user) and settle-up; and a simplification that computes each user's net position and matches largest debtor with largest creditor, giving at most n - 1 transfers.
P7.1Requirements
| Question | Assumed answer |
|---|---|
| Split types? | Equal, exact amounts, percentages; more may come |
| Money? | Rupees with paise; no floating point; shares must add up exactly |
| Who can be in an expense? | Any registered users, including the payer |
| Output? | Lines like "Ravi owes Asha: 216.66"; only non-zero balances |
| Groups? | Out of scope at first; a likely follow-up |
| Settle up? | A payment that reduces a debt; cannot exceed what is owed |
Invariants: the shares of an expense add up to exactly its amount; between two users, at most one direction of debt is stored; the sum of all net positions is zero.
P7.2Entities and responsibilities
| Class | Responsibility |
|---|---|
User (record) |
ID and name |
Split (record) |
One user's share in paise |
Expense (record) |
Payer, amount, shares; rejects shares that do not add up |
SplitStrategy |
Turns an amount, users, and inputs into shares |
EqualSplit, ExactSplit, PercentSplit |
One rule each, including leftover paise |
SplitFactory |
Maps a SplitType to its strategy |
BalanceSheet |
Pairwise net debts; settle-up; net positions |
DebtSimplifier |
Net positions to a short list of transfers |
ExpenseService |
Use cases: add user, add expense, settle, show, simplify |
P7.3Class diagram
P7.4Key flows
Adding an expense: validate users and amount, ask the strategy for shares, build the Expense (which re-checks the total), then for every share record "share owner owes payer that amount" in the balance sheet. The payer's own share nets to nothing because addDebt ignores a debt to oneself.
P7.5Code
interface SplitStrategy {
/** @param values per-user inputs: unused for EQUAL, paise for EXACT, percent for PERCENT */
List<Split> split(long amountPaise, List<String> users, List<Long> values);
}
class EqualSplit implements SplitStrategy {
public List<Split> split(long amount, List<String> users, List<Long> ignored) {
long base = amount / users.size();
long leftover = amount % users.size(); // e.g. 100.00 / 3 leaves 1 paisa
List<Split> out = new ArrayList<>();
for (int i = 0; i < users.size(); i++) {
out.add(new Split(users.get(i), base + (i < leftover ? 1 : 0))); // first users absorb the leftover paise
}
return out;
}
}
class PercentSplit implements SplitStrategy {
public List<Split> split(long amount, List<String> users, List<Long> percents) {
if (percents.size() != users.size()) throw new IllegalArgumentException("one percent per user");
if (percents.stream().mapToLong(Long::longValue).sum() != 100) throw new IllegalArgumentException("percents must add up to 100");
List<Split> out = new ArrayList<>();
long assigned = 0;
for (int i = 0; i < users.size(); i++) {
long share = amount * percents.get(i) / 100; // floor
out.add(new Split(users.get(i), share));
assigned += share;
}
// hand out the paise lost to flooring, one each, in order
for (int i = 0; assigned < amount; i = (i + 1) % out.size(), assigned++) {
Split s = out.get(i);
out.set(i, new Split(s.userId(), s.amountPaise() + 1));
}
return out;
}
}
/** Pairwise net debts: owes.get(a).get(b) > 0 means a owes b that many paise. Never both directions at once. */
class BalanceSheet {
private final Map<String, Map<String, Long>> owes = new TreeMap<>(); // sorted: stable output
synchronized void addDebt(String debtor, String creditor, long amount) {
if (debtor.equals(creditor) || amount == 0) return;
long reverse = get(creditor, debtor); // creditor already owes debtor?
if (reverse >= amount) {
put(creditor, debtor, reverse - amount); // net it off
} else {
put(creditor, debtor, 0);
put(debtor, creditor, get(debtor, creditor) + amount - reverse);
}
}
/** A settle-up payment from `payer` to `payee` is a debt in the opposite direction. */
synchronized void recordPayment(String payer, String payee, long amount) {
if (amount > get(payer, payee)) throw new IllegalArgumentException(payer + " owes " + payee + " only " + get(payer, payee));
addDebt(payee, payer, amount);
}
synchronized long get(String debtor, String creditor) {
return owes.getOrDefault(debtor, Map.of()).getOrDefault(creditor, 0L);
}
/** Net position per user: positive = is owed money, negative = owes money. */
synchronized Map<String, Long> netPositions() {
Map<String, Long> net = new TreeMap<>();
owes.forEach((d, m) -> m.forEach((c, amt) -> {
net.merge(d, -amt, Long::sum);
net.merge(c, amt, Long::sum);
}));
return net;
}
synchronized Map<String, Long> debtsOf(String user) {
return new LinkedHashMap<>(owes.getOrDefault(user, Map.of()));
}
private void put(String d, String c, long amt) {
Map<String, Long> m = owes.computeIfAbsent(d, k -> new TreeMap<>());
if (amt == 0) m.remove(c); else m.put(c, amt);
if (m.isEmpty()) owes.remove(d);
}
}
class DebtSimplifier {
/** Settles net positions with at most n - 1 transfers: largest debtor pays largest creditor, repeat. */
List<Transfer> simplify(Map<String, Long> net) {
Comparator<long[]> byAmount = Comparator.comparingLong((long[] a) -> a[1]).reversed();
PriorityQueue<long[]> creditors = new PriorityQueue<>(byAmount); // [userIndex, amount]
PriorityQueue<long[]> debtors = new PriorityQueue<>(byAmount);
List<String> ids = new ArrayList<>(net.keySet());
for (int i = 0; i < ids.size(); i++) {
long v = net.get(ids.get(i));
if (v > 0) creditors.add(new long[] {i, v});
else if (v < 0) debtors.add(new long[] {i, -v});
}
List<Transfer> out = new ArrayList<>();
while (!creditors.isEmpty() && !debtors.isEmpty()) {
long[] c = creditors.poll(), d = debtors.poll();
long x = Math.min(c[1], d[1]);
out.add(new Transfer(ids.get((int) d[0]), ids.get((int) c[0]), x));
if (c[1] > x) creditors.add(new long[] {c[0], c[1] - x});
if (d[1] > x) debtors.add(new long[] {d[0], d[1] - x});
}
return out;
}
}
class ExpenseService {
private final Map<String, User> users = new ConcurrentHashMap<>(); // users may be added while expenses are
private final List<Expense> expenses = new ArrayList<>();
private final SplitFactory splits;
private final BalanceSheet balances;
private final AtomicLong ids = new AtomicLong(1);
ExpenseService(SplitFactory splits, BalanceSheet balances) { this.splits = splits; this.balances = balances; }
void addUser(User u) {
if (users.putIfAbsent(u.id(), u) != null) throw new IllegalArgumentException("duplicate user " + u.id());
}
Expense addExpense(String paidBy, long amountPaise, SplitType type, List<String> participants, List<Long> values, String note) {
requireUser(paidBy);
participants.forEach(this::requireUser);
if (amountPaise <= 0) throw new IllegalArgumentException("amount must be positive");
List<Split> shares = splits.forType(type).split(amountPaise, participants, values);
Expense e = new Expense("E" + ids.getAndIncrement(), paidBy, amountPaise, note, shares);
synchronized (this) {
expenses.add(e);
for (Split s : shares) balances.addDebt(s.userId(), paidBy, s.amountPaise()); // everyone owes the payer
}
return e;
}
void settle(String payer, String payee, long amountPaise) { balances.recordPayment(payer, payee, amountPaise); }
List<String> show(String userId) {
List<String> out = new ArrayList<>();
balances.debtsOf(userId).forEach((c, amt) -> out.add(name(userId) + " owes " + name(c) + ": " + rupees(amt)));
return out;
}
List<String> simplified() {
List<String> out = new ArrayList<>();
for (Transfer t : new DebtSimplifier().simplify(balances.netPositions()))
out.add(name(t.from()) + " pays " + name(t.to()) + ": " + rupees(t.amountPaise()));
return out;
}
private void requireUser(String id) { if (!users.containsKey(id)) throw new IllegalArgumentException("unknown user " + id); }
private String name(String id) { return users.get(id).name(); }
static String rupees(long paise) { return paise / 100 + "." + String.format("%02d", paise % 100); }
}
Go deeperThe remaining files
record User(String id, String name) {}
/** One participant's share of an expense, in paise. */
record Split(String userId, long amountPaise) {}
import java.util.List;
record Expense(String id, String paidBy, long amountPaise, String description, List<Split> splits) {
Expense {
long sum = splits.stream().mapToLong(Split::amountPaise).sum();
if (sum != amountPaise) throw new IllegalArgumentException("splits add up to " + sum + ", not " + amountPaise);
splits = List.copyOf(splits);
}
}
enum SplitType { EQUAL, EXACT, PERCENT }
import java.util.ArrayList;
import java.util.List;
class ExactSplit implements SplitStrategy {
public List<Split> split(long amount, List<String> users, List<Long> exact) {
if (exact.size() != users.size()) throw new IllegalArgumentException("one amount per user");
long sum = exact.stream().mapToLong(Long::longValue).sum();
if (sum != amount) throw new IllegalArgumentException("exact amounts add up to " + sum + " paise, not " + amount);
List<Split> out = new ArrayList<>();
for (int i = 0; i < users.size(); i++) out.add(new Split(users.get(i), exact.get(i)));
return out;
}
}
import java.util.EnumMap;
import java.util.Map;
class SplitFactory {
private final Map<SplitType, SplitStrategy> strategies = new EnumMap<>(SplitType.class);
SplitFactory() {
strategies.put(SplitType.EQUAL, new EqualSplit());
strategies.put(SplitType.EXACT, new ExactSplit());
strategies.put(SplitType.PERCENT, new PercentSplit());
}
SplitStrategy forType(SplitType type) { return strategies.get(type); }
}
record Transfer(String from, String to, long amountPaise) {}
app.addExpense("u1", 100_000, SplitType.EQUAL, List.of("u1", "u2", "u3", "u4"), List.of(), "dinner"); // 1,000 / 4
app.addExpense("u2", 10_000, SplitType.EQUAL, List.of("u1", "u2", "u3"), List.of(), "cab"); // 100 / 3
app.addExpense("u3", 120_000, SplitType.EXACT, List.of("u1", "u2", "u3", "u4"),
List.of(30_000L, 20_000L, 40_000L, 30_000L), "groceries");
app.addExpense("u4", 50_000, SplitType.PERCENT, List.of("u1", "u4"), List.of(40L, 60L), "tickets");
Code that ranDemo.java: the complete code behind the output below
public class Demo {
public static void main(String[] args) {
ExpenseService app = new ExpenseService(new SplitFactory(), new BalanceSheet());
for (String[] u : new String[][] {{"u1", "Asha"}, {"u2", "Ravi"}, {"u3", "Meera"}, {"u4", "Kabir"}}) app.addUser(new User(u[0], u[1]));
app.addExpense("u1", 100_000, SplitType.EQUAL, List.of("u1", "u2", "u3", "u4"), List.of(), "dinner"); // 1,000 / 4
app.addExpense("u2", 10_000, SplitType.EQUAL, List.of("u1", "u2", "u3"), List.of(), "cab"); // 100 / 3
app.addExpense("u3", 120_000, SplitType.EXACT, List.of("u1", "u2", "u3", "u4"),
List.of(30_000L, 20_000L, 40_000L, 30_000L), "groceries");
app.addExpense("u4", 50_000, SplitType.PERCENT, List.of("u1", "u4"), List.of(40L, 60L), "tickets");
for (String u : List.of("u1", "u2", "u3", "u4")) app.show(u).forEach(System.out::println);
System.out.println("-- simplified --");
List<String> plan = app.simplified();
plan.forEach(System.out::println);
check(plan.size() <= 3, "at most n - 1 transfers");
try { app.addExpense("u1", 1_000, SplitType.EXACT, List.of("u1", "u2"), List.of(500L, 400L), "bad"); throw new AssertionError(); }
catch (IllegalArgumentException e) { System.out.println("rejected: " + e.getMessage()); }
check(new EqualSplit().split(10_000, List.of("a", "b", "c"), List.of()).stream().mapToLong(Split::amountPaise).sum() == 10_000, "no paisa lost");
}
static void check(boolean ok, String what) { if (!ok) throw new AssertionError(what); }
}
Asha owes Meera: 50.00
Ravi owes Asha: 216.66
Ravi owes Meera: 166.67
Kabir owes Asha: 50.00
Kabir owes Meera: 300.00
-- simplified --
Ravi pays Meera: 383.33
Kabir pays Asha: 216.66
Kabir pays Meera: 133.34
rejected: exact amounts add up to 900 paise, not 1000
Check one line by hand: Ravi owes Asha 250.00 for dinner, Asha owes Ravi 33.34 for the cab (she absorbed the leftover paisa as the first participant), so Ravi owes Asha 216.66 net.
P7.6Hard parts
Money. long paise everywhere; conversion to rupees only when printing. Every division (equal and percent) produces leftover paise, and the strategy must assign them deterministically so shares add up. Expense re-validates the sum, so a buggy new strategy fails loudly instead of corrupting balances.
Netting. Without netting, "Asha owes Ravi 33.34" and "Ravi owes Asha 250.00" both exist and the display is confusing. addDebt subtracts from the reverse direction first.
Simplification. Minimizing the number of transfers exactly is NP-hard in general (it hides a subset-sum problem). The greedy on net positions is the standard answer: it never needs more than n - 1 transfers and runs in O(n log n). Say both facts.
Concurrency. Two users adding expenses at once both update the balance sheet: its methods are synchronized, and the service adds the expense and its debts in one synchronized block so readers never see half an expense. With a database, an expense and its ledger rows go in one transaction.
P7.7Deep dives
Guided designExtensions interviewers ask forAnswer each before revealing
- 1Groups
Expenses belong to groups (a trip, a flat), and balances are shown per group and overall.
Write your answer, then reveal
Add a
Group(ID, name, members) and agroupIdonExpense. Keep oneBalanceSheetper group plus an overall one (or derive overall by summing group sheets). Validate that every participant is a member of the group. - 2A new split type: by shares (2:1:1)
How does it fit?
Write your answer, then reveal
A
ShareSplitstrategy: total shares = sum of inputs, each user getsamount * share / totalfloored, leftover paise handed out in order. Register it in the factory. Nothing else changes, which is the point of the strategy. - 3Edit or delete an expense
A user fixes a wrong amount.
Write your answer, then reveal
Never edit balances in place. Reverse the old expense (add the opposite debts) and apply the new one, or recompute balances from the expense list. Keep an audit trail of versions. Simplest correct approach: balances are derived data, rebuildable from expenses.
- 4Multiple currencies
A trip abroad mixes rupees and dollars.
Write your answer, then reveal
Store amount and currency on each expense, keep a balance sheet per currency (do not convert silently), and convert only at settlement using a rate captured at that time. Money becomes a value object with a currency (Module 1).
P7.8Machine coding plan
| Minutes | Do |
|---|---|
| 0-8 | Clarify split types, rounding, output format, settle-up |
| 8-20 | User, Split, Expense with validation, SplitType |
| 20-40 | Three strategies with leftover handling; factory; quick checks that shares add up |
| 40-60 | BalanceSheet with netting; ExpenseService.addExpense and show |
| 60-75 | Driver for the input commands; settle-up |
| 75-90 | Simplify debts; edge cases (bad exact sum, unknown user, overpaying a settle-up) |
P7.9What a strong answer shows
- SDE-2 bar: strategies for split types; paise as
long; shares that always add up; netted pairwise balances; clear output; validation errors. - Above the bar: explains why exact minimization is NP-hard and why greedy gives at most n - 1 transfers; treats balances as derived data for edits; per-currency sheets; synchronized updates or a transaction around expense plus ledger.
- Why
longpaise instead ofBigDecimal?Reveal
All amounts are whole paise and sums fit easily in a
long, so integer arithmetic is exact and fast.BigDecimalworks too; choose one and never mix indouble. - Who gets the leftover paisa in an equal split, and does it matter?
Reveal
Any deterministic rule works (first participants, or the payer); what matters is that it is consistent, documented, and the shares add up exactly.
- Why does
Expensevalidate the sum when the strategy already did?Reveal
Defense in depth: the record's invariant holds regardless of which strategy produced the shares, including future ones.
- How would you show "you are owed 216.66 in total"?
Reveal
Read the user's net position from
netPositions(): positive means owed, negative means owing.
Problem 8: Elevator system
The elevator problem tests whether you can model behavior over time: each car is a state machine that serves stops in sweeps (LOOK), a dispatcher strategy picks a car for each hall call, and a single-writer controller turns button presses from many threads into safe, ordered updates. A tick-based simulation makes it all testable.
Design a controller for N elevator cars in a building with F floors: hall buttons (up and down at each floor), car buttons (destination floors), and a policy for which car answers a call. Show how a car decides where to go next and how concurrent button presses are handled.
Reveal the answer
Strong answer: an Elevator that keeps up and down stop sets and runs LOOK (continue while there are stops ahead, then reverse); hall calls carry the direction the passenger wants; car buttons derive direction from the car's position; a DispatchStrategy (nearest car, with a cost for cars that must finish a sweep first); an ElevatorSystem that queues commands from any thread and applies them on one controller thread per tick; a state diagram for idle, moving, doors open, and out of service.
P8.1Requirements
| Question | Assumed answer |
|---|---|
| Cars and floors? | N cars, floors 0 to F - 1 |
| Buttons? | Hall: up or down at each floor. Car: any floor |
| Scheduling? | Serve in sweeps; do not change direction while stops remain ahead |
| Car choice? | Minimize the estimated time to reach the caller |
| Concurrency? | Buttons are pressed at any time from many places |
| Out of scope | Weight limits, door sensors, express zones (likely follow-ups) |
Invariants: a car never moves beyond the top or bottom floor; doors open only when stopped at a floor; every accepted request is eventually served while the car is in service; a passenger who wants to go down is not picked up by a car that is going up (unless that floor is where it turns).
P8.2Entities and responsibilities
| Class | Responsibility |
|---|---|
Direction (enum) |
UP, DOWN, IDLE |
Elevator |
Position, direction, up and down stop sets; one simulation step at a time using LOOK |
DispatchStrategy |
Chooses a car for a hall call |
NearestCarStrategy |
Cost = distance if the caller is on the way, else finish the sweep and come back |
ElevatorSystem |
Command inbox, cars, dispatch, the tick loop, the event log |
P8.3Class diagram
P8.4Key flows
P8.5Code
/** One car running LOOK: keep going while there are stops ahead, then reverse. Not thread-safe: the controller owns it. */
class Elevator {
final String id;
private final int minFloor, maxFloor;
private int floor;
private Direction dir = Direction.IDLE;
private final TreeSet<Integer> upStops = new TreeSet<>(); // pick-ups going up, and drop-offs above while going up
private final TreeSet<Integer> downStops = new TreeSet<>();
Elevator(String id, int startFloor, int minFloor, int maxFloor) {
this.id = id; this.floor = startFloor; this.minFloor = minFloor; this.maxFloor = maxFloor;
}
/** `wanted` is the direction the passenger travels from `stop`. */
void addStop(int stop, Direction wanted) {
if (stop < minFloor || stop > maxFloor) throw new IllegalArgumentException("no floor " + stop);
(wanted == Direction.DOWN ? downStops : upStops).add(stop);
}
/** A button inside the car: the direction follows from where the car is. */
void pressInside(int target) {
if (target != floor) addStop(target, target > floor ? Direction.UP : Direction.DOWN);
}
/** Advance one tick: open doors here, or move one floor. Returns an event for the log, or null. */
String step() {
if (upStops.isEmpty() && downStops.isEmpty()) { dir = Direction.IDLE; return null; }
if (dir == Direction.IDLE) dir = towardNearest();
if (serveHere()) return id + " doors open at " + floor;
if (!stopsAhead()) { // LOOK: nothing further this way, so turn around
dir = dir == Direction.UP ? Direction.DOWN : Direction.UP;
if (serveHere()) return id + " turns, doors open at " + floor;
}
floor += dir == Direction.UP ? 1 : -1;
return null;
}
private boolean serveHere() {
boolean served = (dir == Direction.UP ? upStops : downStops).remove(floor);
if (!served && !stopsAhead()) served = (dir == Direction.UP ? downStops : upStops).remove(floor); // last stop this way
return served;
}
private boolean stopsAhead() {
return dir == Direction.UP
? upStops.higher(floor) != null || downStops.higher(floor) != null
: upStops.lower(floor) != null || downStops.lower(floor) != null;
}
private Direction towardNearest() {
int best = Integer.MAX_VALUE, target = floor;
for (TreeSet<Integer> s : List.of(upStops, downStops))
for (int f : s) if (Math.abs(f - floor) < best) { best = Math.abs(f - floor); target = f; }
if (target > floor) return Direction.UP;
if (target < floor) return Direction.DOWN;
return upStops.contains(floor) ? Direction.UP : Direction.DOWN;
}
int floor() { return floor; }
Direction direction() { return dir; }
boolean idle() { return upStops.isEmpty() && downStops.isEmpty(); }
/** Farthest stop in the current direction: where the car will turn around. */
int turnaround() {
if (dir == Direction.UP) return Math.max(upStops.isEmpty() ? floor : upStops.last(), downStops.isEmpty() ? floor : downStops.last());
if (dir == Direction.DOWN) return Math.min(upStops.isEmpty() ? floor : upStops.first(), downStops.isEmpty() ? floor : downStops.first());
return floor;
}
}
/** Estimated floors travelled before reaching the caller; ties go to the earlier car. */
class NearestCarStrategy implements DispatchStrategy {
public Elevator choose(List<Elevator> cars, int floor, Direction wanted) {
return cars.stream().min(Comparator.comparingInt(c -> cost(c, floor, wanted))).orElseThrow();
}
static int cost(Elevator c, int floor, Direction wanted) {
int here = c.floor();
if (c.idle()) return Math.abs(here - floor);
boolean onTheWay = (c.direction() == Direction.UP && wanted == Direction.UP && floor >= here)
|| (c.direction() == Direction.DOWN && wanted == Direction.DOWN && floor <= here);
if (onTheWay) return Math.abs(floor - here);
int turn = c.turnaround(); // finish this sweep, then come back
return Math.abs(turn - here) + Math.abs(turn - floor);
}
}
/** Single-writer controller: buttons enqueue commands from any thread; only step() touches the cars. */
class ElevatorSystem {
private final Map<String, Elevator> cars = new LinkedHashMap<>();
private final DispatchStrategy dispatch;
private final Queue<Runnable> inbox = new ConcurrentLinkedQueue<>();
private final List<String> log = new ArrayList<>();
private int tick = 0;
ElevatorSystem(List<Elevator> cars, DispatchStrategy dispatch) {
cars.forEach(c -> this.cars.put(c.id, c));
this.dispatch = dispatch;
}
/** Hall button: safe from any thread. */
void call(int floor, Direction wanted) {
inbox.add(() -> {
Elevator c = dispatch.choose(new ArrayList<>(cars.values()), floor, wanted);
c.addStop(floor, wanted);
log.add("t" + tick + ": call " + floor + " " + wanted + " -> car " + c.id);
});
}
/** Car button: safe from any thread. */
void press(String carId, int floor) {
inbox.add(() -> cars.get(carId).pressInside(floor));
}
/** One tick of the simulation, run by the controller thread. */
void step() {
for (Runnable cmd; (cmd = inbox.poll()) != null; ) cmd.run();
for (Elevator c : cars.values()) {
String e = c.step();
if (e != null) log.add("t" + tick + ": " + e);
}
tick++;
}
boolean allIdle() { return inbox.isEmpty() && cars.values().stream().allMatch(Elevator::idle); }
List<String> log() { return log; }
Elevator car(String id) { return cars.get(id); }
}
Go deeperInterfaces and enums
interface DispatchStrategy {
Elevator choose(List<Elevator> cars, int floor, Direction wanted);
}
enum Direction { UP, DOWN, IDLE }
Elevator a = new Elevator("A", 0, 0, 9), b = new Elevator("B", 9, 0, 9);
ElevatorSystem sys = new ElevatorSystem(List.of(a, b), new NearestCarStrategy());
sys.call(3, Direction.UP); // A is 3 floors away, B is 6
sys.step();
sys.step();
sys.call(7, Direction.DOWN); // B (idle at 9) is closer than A (going up, then back)
for (int i = 0; i < 2; i++) sys.step();
sys.press("A", 6); // passenger boarded A at 3, going to 6
sys.press("B", 1); // passenger boarded B at 7, going to 1
while (!sys.allIdle()) sys.step();
Code that ranDemo.java: the complete code behind the output below
public class Demo {
public static void main(String[] args) {
Elevator a = new Elevator("A", 0, 0, 9), b = new Elevator("B", 9, 0, 9);
ElevatorSystem sys = new ElevatorSystem(List.of(a, b), new NearestCarStrategy());
sys.call(3, Direction.UP); // A is 3 floors away, B is 6
sys.step();
sys.step();
sys.call(7, Direction.DOWN); // B (idle at 9) is closer than A (going up, then back)
for (int i = 0; i < 2; i++) sys.step();
sys.press("A", 6); // passenger boarded A at 3, going to 6
sys.press("B", 1); // passenger boarded B at 7, going to 1
while (!sys.allIdle()) sys.step();
sys.log().forEach(System.out::println);
System.out.println("final: A at " + a.floor() + ", B at " + b.floor());
check(a.floor() == 6 && b.floor() == 1, "final floors");
// LOOK: a car going up keeps going up before serving a call behind it
Elevator c = new Elevator("C", 4, 0, 9);
c.pressInside(8);
c.step(); // now moving up, at floor 5
c.addStop(2, Direction.UP); // a call below, wanting to go up
StringBuilder path = new StringBuilder();
for (int i = 0; i < 20 && !c.idle(); i++) { String e = c.step(); if (e != null) path.append(e.substring(2)).append("; "); }
System.out.println("LOOK order: " + path);
check(path.toString().indexOf("8") < path.toString().indexOf("2"), "serves 8 before 2");
}
static void check(boolean ok, String what) { if (!ok) throw new AssertionError(what); }
}
t0: call 3 UP -> car A
t2: call 7 DOWN -> car B
t3: A doors open at 3
t4: B doors open at 7
t7: A doors open at 6
t11: B doors open at 1
final: A at 6, B at 1
LOOK order: doors open at 8; doors open at 2;
P8.6Concurrency
Buttons are pressed from many threads, but the car logic is not thread-safe, on purpose. Instead of locking every car method, the controller uses the single-writer principle: call and press only add a command to a ConcurrentLinkedQueue; one controller thread drains the queue at the start of each tick and is the only thread that touches cars. This removes races by construction, keeps the car code simple, and makes the order of events deterministic.
The alternative, a lock per car plus a lock for dispatch decisions, works but must take care: dispatch reads every car's state, so it needs a consistent snapshot, and lock ordering between cars matters if one operation touches two cars.
Real controllers are event-driven embedded systems, with safety interlocks in hardware. The software pattern above (one event loop that owns state, fed by a queue) is also how actor systems, Redis, and Node.js avoid locks.
P8.7Deep dives
Guided designExtensions interviewers ask forAnswer each before revealing
- 1Destination dispatch
Passengers enter their destination in the lobby instead of pressing up or down.
Write your answer, then reveal
The hall call now carries a destination, so the dispatcher can group passengers going to nearby floors into the same car. The cost function changes (stops added to the car's route, not just distance); the
Elevatorgets both the pick-up and the drop-off at once. A newDispatchStrategyimplementation; the car is unchanged. - 2Weight limit
A full car should not stop for more pick-ups.
Write your answer, then reveal
Track load (sensor input) on the car. When full, the car skips hall pick-ups but still serves drop-offs; the dispatcher reassigns skipped hall calls to another car. The skip is a guard on serving an up or down hall stop, not on car stops.
- 3Peak hours
Morning traffic is mostly lobby to upper floors.
Write your answer, then reveal
Swap the strategy by time of day: an up-peak strategy parks idle cars at the lobby and assigns them in rotation. Strategies selected by a schedule are the strategy plus factory pattern from Module 6.
- 4A car breaks down
What happens to its pending stops?
Write your answer, then reveal
The car moves to OutOfService (after stopping at the nearest floor). Its hall calls are re-dispatched to other cars; its car calls belong to passengers inside, who are handled by safety procedures. Dispatch skips out-of-service cars.
P8.8Machine coding plan
| Minutes | Do |
|---|---|
| 0-10 | Clarify buttons, scheduling, dispatch; draw the LOOK sweep and the car states |
| 10-20 | Direction, Elevator fields, addStop, pressInside |
| 20-45 | Elevator.step with LOOK; tests with one car (keeps direction, turns at the last stop) |
| 45-60 | DispatchStrategy and NearestCarStrategy |
| 60-75 | ElevatorSystem with the inbox and tick loop; the demo |
| 75-90 | Edge cases (stop at current floor, top and bottom bounds); walk through extensions |
P8.9What a strong answer shows
- SDE-2 bar: LOOK with direction-aware hall calls; pluggable dispatch with a sensible cost; a clear state diagram; a concurrency answer.
- Above the bar: single-writer controller with a command queue, explained; simulation by ticks for deterministic tests; extensions (destination dispatch, peak strategies, failures) placed in the design without rewrites.
- Why keep separate up and down stop sets instead of one?
Reveal
A hall call carries the passenger's direction. A car going up should stop for "going up" calls on the way but not for "going down" calls, which it serves on the way back. One set loses that information.
- What is the difference between SCAN and LOOK?
Reveal
SCAN travels to the end of the shaft before reversing; LOOK reverses at the last requested floor in the current direction. LOOK wastes fewer trips.
- Why not run each car on its own thread?
Reveal
You can, but then dispatch reads cars that other threads are changing, which needs locks and consistent snapshots. One controller thread with a queue is simpler and deterministic; scale it by giving each bank of elevators its own controller.
- How do you test that a car never overshoots?
Reveal
Drive the simulation with scripted calls and assert after every tick that each car's floor stays within bounds and that each requested stop appears in the log exactly once.
Problem 9: Movie ticket booking
BookMyShow in LLD form. The modeling insight is that seat availability belongs to a show, not to a seat; the concurrency insight is an all-or-nothing hold on several seats with locks taken in a fixed order; the money insight is that payment happens between hold and confirm, so confirmation must re-check the hold.
Design movie ticket booking: cities, theatres, screens, movies, shows; a user picks seats on a seat map, holds them for 10 minutes while paying, then confirms. Two users must never get the same seat. Then compare.
Reveal the answer
Strong answer: Theatre, Screen, Seat (physical) vs Show and ShowSeat (per-show status); seat states AVAILABLE, HELD, BOOKED with an expiry on holds; hold(show, seats) that locks the requested seats in sorted order and holds all or none; lazy expiry so stuck holds free themselves; confirm(hold, payment) that succeeds only if the same hold still owns every seat, otherwise refunds; pricing per seat category per show; and a plan for the database version (conditional updates, unique constraints).
P9.1Requirements
| Question | Assumed answer |
|---|---|
| Catalog? | Cities, theatres, screens, movies, shows (movie + screen + start time) |
| Seat map? | Seats with a category (silver, gold, recliner); price per category per show |
| Booking flow? | Select seats, hold for 10 minutes, pay, confirm |
| Limits? | 1 to 10 seats per booking |
| Concurrency? | Many users on the same show at once; no seat sold twice |
| Payment? | External; we receive a payment reference or a failure |
Invariants: a show seat is held by at most one hold and booked by at most one booking; a hold covers all its seats or none; a booking only confirms seats its hold still owns.
P9.2Entities and responsibilities
| Class | Responsibility |
|---|---|
Theatre, Screen, Seat |
Physical layout; a seat has an ID and a category |
Movie (record) |
Title, runtime |
Show |
Movie on a screen at a time; prices; one ShowSeat per seat |
ShowSeat |
Status for this show, hold ID and expiry, and its lock |
Hold (record) |
Seats, amount, expiry, user |
Booking (record) |
Confirmed seats, amount, payment reference |
BookingService |
Hold, confirm, cancel; enforces the invariants |
The key move, from Module 8: Seat is the type (it exists every day), ShowSeat is the instance with state (it exists per show). Putting a status on Seat breaks the moment a screen runs two shows.
The code below leaves Theatre and Screen as plain containers and passes a screen as its list of seats: they matter for search and the seat map, but they carry no rules in the booking flow, so they are the first thing to cut in a time box.
P9.3Class diagram
P9.4Key flows
P9.5Code
/** One seat for one show: the unit of contention. Callers lock it before reading or changing status. */
class ShowSeat {
final String seatId;
final SeatCategory category;
final ReentrantLock lock = new ReentrantLock();
private SeatStatus status = SeatStatus.AVAILABLE;
private String holdId;
private Instant holdExpiresAt;
ShowSeat(Seat seat) { this.seatId = seat.id(); this.category = seat.category(); }
/** Available, or held by a hold that has expired (lazy expiry: no sweeper needed for correctness). */
boolean isFree(Instant now) {
return status == SeatStatus.AVAILABLE || (status == SeatStatus.HELD && !now.isBefore(holdExpiresAt));
}
void hold(String holdId, Instant until) {
status = SeatStatus.HELD;
this.holdId = holdId;
this.holdExpiresAt = until;
}
boolean isHeldBy(String holdId, Instant now) {
return status == SeatStatus.HELD && holdId.equals(this.holdId) && now.isBefore(holdExpiresAt);
}
void book() {
status = SeatStatus.BOOKED;
holdId = null;
holdExpiresAt = null;
}
void release(String holdId) {
if (status == SeatStatus.HELD && holdId.equals(this.holdId)) {
status = SeatStatus.AVAILABLE;
this.holdId = null;
holdExpiresAt = null;
}
}
SeatStatus status() { return status; }
}
class BookingService {
private static final Duration HOLD_FOR = Duration.ofMinutes(10);
private final Map<String, Show> shows = new ConcurrentHashMap<>();
private final Map<String, Hold> holds = new ConcurrentHashMap<>();
private final Map<String, Booking> bookings = new ConcurrentHashMap<>();
private final AtomicLong holdSeq = new AtomicLong(1), bookingSeq = new AtomicLong(1);
void addShow(Show s) { shows.put(s.id, s); }
/** All-or-nothing hold on several seats. Locks are taken in seat-id order, so two holds never deadlock. */
Hold hold(String showId, String userId, List<String> seatIds, Instant now) {
if (seatIds.isEmpty() || seatIds.size() > 10) throw new BookingException("choose 1 to 10 seats");
Show show = show(showId);
List<ShowSeat> seats = new ArrayList<>();
for (String id : new TreeSet<>(seatIds)) seats.add(show.seat(id)); // sorted, de-duplicated
lockAll(seats);
try {
for (ShowSeat s : seats)
if (!s.isFree(now)) throw new BookingException("seat " + s.seatId + " is not available");
String holdId = "H" + holdSeq.getAndIncrement();
Instant until = now.plus(HOLD_FOR);
long amount = 0;
for (ShowSeat s : seats) { s.hold(holdId, until); amount += show.price(s.category); }
Hold h = new Hold(holdId, showId, userId, seats.stream().map(s -> s.seatId).toList(), amount, until);
holds.put(holdId, h);
return h;
} finally {
unlockAll(seats);
}
}
/** Called after payment succeeds. Fails if the hold expired: a late payment is refunded, even if the seats are still free. */
Booking confirm(String holdId, String paymentRef, Instant now) {
Hold h = holds.get(holdId);
if (h == null) throw new BookingException("unknown hold " + holdId);
Show show = show(h.showId());
List<ShowSeat> seats = h.seatIds().stream().map(show::seat).toList(); // already in sorted order
lockAll(seats);
try {
for (ShowSeat s : seats)
if (!s.isHeldBy(holdId, now)) {
holds.remove(holdId); // a dead hold is never confirmable
throw new BookingException("hold " + holdId + " expired; refund " + paymentRef);
}
seats.forEach(ShowSeat::book);
holds.remove(holdId);
Booking b = new Booking("B" + bookingSeq.getAndIncrement(), h.showId(), h.userId(), h.seatIds(), h.amountPaise(), paymentRef);
bookings.put(b.id(), b);
return b;
} finally {
unlockAll(seats);
}
}
/** Seat map counts for display; expired holds count as available. */
int count(String showId, SeatStatus status, Instant now) { return show(showId).count(status, now); }
void cancelHold(String holdId) {
Hold h = holds.remove(holdId);
if (h == null) return;
List<ShowSeat> seats = h.seatIds().stream().map(show(h.showId())::seat).toList();
lockAll(seats);
try { seats.forEach(s -> s.release(holdId)); } finally { unlockAll(seats); }
}
private static void lockAll(List<ShowSeat> seats) { seats.forEach(s -> s.lock.lock()); }
private static void unlockAll(List<ShowSeat> seats) {
for (int i = seats.size() - 1; i >= 0; i--) seats.get(i).lock.unlock();
}
private Show show(String id) {
Show s = shows.get(id);
if (s == null) throw new BookingException("unknown show " + id);
return s;
}
}
Go deeperThe remaining files
import java.time.Instant;
import java.util.EnumMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
class Show {
final String id;
final Movie movie;
final Instant start;
private final Map<SeatCategory, Long> pricePaise;
private final Map<String, ShowSeat> seats = new LinkedHashMap<>();
Show(String id, Movie movie, Instant start, List<Seat> screenSeats, Map<SeatCategory, Long> prices) {
this.id = id;
this.movie = movie;
this.start = start;
this.pricePaise = new EnumMap<>(prices);
for (Seat s : screenSeats) seats.put(s.id(), new ShowSeat(s)); // seat state is per show
}
ShowSeat seat(String seatId) {
ShowSeat s = seats.get(seatId);
if (s == null) throw new IllegalArgumentException("no seat " + seatId + " in show " + id);
return s;
}
long price(SeatCategory c) { return pricePaise.get(c); }
int count(SeatStatus st, Instant now) {
int n = 0;
for (ShowSeat s : seats.values()) {
s.lock.lock();
try {
SeatStatus eff = s.isFree(now) ? SeatStatus.AVAILABLE : s.status();
if (eff == st) n++;
} finally { s.lock.unlock(); }
}
return n;
}
}
/** A physical seat in a screen. Its availability belongs to a show, not to the seat. */
record Seat(String id, SeatCategory category) {}
enum SeatCategory { SILVER, GOLD, RECLINER }
enum SeatStatus { AVAILABLE, HELD, BOOKED }
record Movie(String id, String title, int minutes) {}
import java.time.Instant;
import java.util.List;
record Hold(String id, String showId, String userId, List<String> seatIds, long amountPaise, Instant expiresAt) {}
import java.util.List;
record Booking(String id, String showId, String userId, List<String> seatIds, long amountPaise, String paymentRef) {}
class BookingException extends RuntimeException {
BookingException(String message) { super(message); }
}
Hold asha = svc.hold("S1", "asha", List.of("B5", "B6"), t0);
System.out.println("asha holds " + asha.seatIds() + " for " + asha.amountPaise() / 100 + " rupees until " + asha.expiresAt());
try {
svc.hold("S1", "ravi", List.of("B6", "B7"), t0.plusSeconds(60)); // B6 is held
} catch (BookingException e) {
System.out.println("ravi: " + e.getMessage());
}
Booking b = svc.confirm(asha.id(), "pay_123", t0.plusSeconds(240)); // paid within 10 minutes
System.out.println("booked " + b.id() + " " + b.seatIds());
Hold meera = svc.hold("S1", "meera", List.of("A1"), t0);
Hold kabir = svc.hold("S1", "kabir", List.of("A1"), t0.plusSeconds(11 * 60)); // meera's hold expired
System.out.println("kabir got A1 after meera's hold expired: " + kabir.seatIds());
try {
svc.confirm(meera.id(), "pay_456", t0.plusSeconds(12 * 60));
} catch (BookingException e) {
System.out.println("meera: " + e.getMessage());
}
Code that ranDemo.java: the complete code behind the output below
public class Demo {
public static void main(String[] args) throws Exception {
List<Seat> screen = new ArrayList<>();
for (char row = 'A'; row <= 'B'; row++)
for (int n = 1; n <= 10; n++) screen.add(new Seat("" + row + n, row == 'A' ? SeatCategory.SILVER : SeatCategory.GOLD));
Map<SeatCategory, Long> prices = new EnumMap<>(Map.of(SeatCategory.SILVER, 20_000L, SeatCategory.GOLD, 35_000L, SeatCategory.RECLINER, 60_000L));
Movie movie = new Movie("M1", "Interstellar", 169);
Instant t0 = Instant.parse("2026-10-21T12:00:00Z");
BookingService svc = new BookingService();
svc.addShow(new Show("S1", movie, t0.plus(Duration.ofHours(6)), screen, prices));
Hold asha = svc.hold("S1", "asha", List.of("B5", "B6"), t0);
System.out.println("asha holds " + asha.seatIds() + " for " + asha.amountPaise() / 100 + " rupees until " + asha.expiresAt());
try {
svc.hold("S1", "ravi", List.of("B6", "B7"), t0.plusSeconds(60)); // B6 is held
} catch (BookingException e) {
System.out.println("ravi: " + e.getMessage());
}
Booking b = svc.confirm(asha.id(), "pay_123", t0.plusSeconds(240)); // paid within 10 minutes
System.out.println("booked " + b.id() + " " + b.seatIds());
Hold meera = svc.hold("S1", "meera", List.of("A1"), t0);
Hold kabir = svc.hold("S1", "kabir", List.of("A1"), t0.plusSeconds(11 * 60)); // meera's hold expired
System.out.println("kabir got A1 after meera's hold expired: " + kabir.seatIds());
try {
svc.confirm(meera.id(), "pay_456", t0.plusSeconds(12 * 60));
} catch (BookingException e) {
System.out.println("meera: " + e.getMessage());
}
check(b.seatIds().equals(List.of("B5", "B6")), "booking");
Instant later = t0.plusSeconds(12 * 60);
System.out.println("seat map S1: " + svc.count("S1", SeatStatus.AVAILABLE, later) + " available, "
+ svc.count("S1", SeatStatus.HELD, later) + " held, " + svc.count("S1", SeatStatus.BOOKED, later) + " booked");
check(svc.count("S1", SeatStatus.HELD, later) == 1 && svc.count("S1", SeatStatus.BOOKED, later) == 2, "seat map");
// 200 users race for random pairs of 20 seats on a new show: no seat is ever held twice
svc.addShow(new Show("S2", movie, t0.plus(Duration.ofHours(9)), screen, prices));
Set<String> taken = ConcurrentHashMap.newKeySet();
CountDownLatch go = new CountDownLatch(1);
ExecutorService pool = Executors.newFixedThreadPool(16);
for (int u = 0; u < 200; u++) {
String user = "u" + u;
pool.submit(() -> {
ThreadLocalRandom r = ThreadLocalRandom.current();
String s1 = screen.get(r.nextInt(20)).id(), s2 = screen.get(r.nextInt(20)).id();
try { go.await(); } catch (InterruptedException e) { return; }
try {
Hold h = svc.hold("S2", user, List.of(s1, s2), t0);
for (String s : h.seatIds()) check(taken.add(s), "seat held twice: " + s);
svc.confirm(h.id(), "pay-" + user, t0.plusSeconds(30));
} catch (BookingException ignored) { /* lost the race: normal */ }
});
}
go.countDown();
pool.shutdown();
check(pool.awaitTermination(30, TimeUnit.SECONDS), "finished");
check(taken.size() <= 20, "seats");
System.out.println("race on S2: 200 users, no seat held or sold twice");
}
static void check(boolean ok, String what) { if (!ok) throw new AssertionError(what); }
}
asha holds [B5, B6] for 700 rupees until 2026-10-21T12:10:00Z
ravi: seat B6 is not available
booked B1 [B5, B6]
kabir got A1 after meera's hold expired: [A1]
meera: hold H2 expired; refund pay_456
seat map S1: 17 available, 1 held, 2 booked
race on S2: 200 users, no seat held or sold twice
The last line comes from 200 threads racing for random pairs of seats on another show; every successful hold is checked against all others.
P9.6Concurrency
| Decision | Why |
|---|---|
Lock per ShowSeat |
Users on different seats never wait for each other |
| Lock seats in sorted ID order | Two users holding overlapping sets (B6, B7 and B7, B6) cannot deadlock |
| Check all, then change all, under the locks | All-or-nothing: no partial holds to clean up |
| Lazy expiry checked under the lock | A stale hold never blocks a seat, with no sweeper race |
confirm re-checks ownership |
Payment can finish after the hold expired and another user took the seat |
A single lock per show would also be correct and simpler; it serializes all bookings for one show, which is fine at a few requests per second per show and worth saying as the simpler alternative.
With a database, the same rules become: UPDATE show_seat SET status = 'HELD', hold_id = ?, expires_at = ? WHERE show_id = ? AND seat_id IN (...) AND (status = 'AVAILABLE' OR (status = 'HELD' AND expires_at < now())) inside a transaction, and commit only if the updated row count equals the number of seats. Confirmation is another conditional update WHERE hold_id = ? AND expires_at > now(). The HLD handbook's ticket booking case covers the spike (waiting rooms, caching the seat map).
P9.7Deep dives
Guided designExtensions interviewers ask forAnswer each before revealing
- 1Payment succeeds but confirmation fails
The hold expired by one second and someone else took a seat. The user has paid.
Write your answer, then reveal
Confirmation fails and the payment is refunded automatically (a compensation). Better: authorize the payment first, confirm the seats, then capture; a failed confirmation only voids the authorization, so the user is never charged. Use the hold ID as the payment idempotency key so retries never charge twice.
- 2Do not leave single empty seats
The theatre does not want isolated single seats left between bookings.
Write your answer, then reveal
A
SeatSelectionRulechecked during hold: for each row, simulate the hold and reject it if it leaves a lone available seat between two unavailable ones (or at the aisle). Rules are a chain of validators, so more can be added (no wheelchair seats unless requested). - 3Dynamic pricing
Prices rise as the show fills up, and weekends cost more.
Write your answer, then reveal
A
PricingStrategythat receives the show, the seat category, and the occupancy, chosen per show or per city. Price is computed at hold time and stored in theHold, so the user pays what they saw even if the price changes before confirmation. - 4Cancel a booking
Users can cancel up to 2 hours before the show for a partial refund.
Write your answer, then reveal
Add a BOOKED to AVAILABLE transition with the guard "now is more than 2 hours before start", a
RefundPolicystrategy for the amount, and a booking status (CONFIRMED, CANCELLED). Released seats become available to holds again.
P9.8Machine coding plan
| Minutes | Do |
|---|---|
| 0-10 | Clarify flow, limits, expiry; draw seat states |
| 10-25 | Enums, Seat, Movie, Show, ShowSeat with lazy expiry |
| 25-50 | BookingService.hold with sorted locking and all-or-nothing; tests for conflicts |
| 50-65 | confirm and cancelHold; expired-hold test with an injected time |
| 65-80 | Search (shows by movie and city) if required; driver commands |
| 80-90 | Concurrency test with a thread pool; walk through the database version |
P9.9What a strong answer shows
- SDE-2 bar:
ShowSeatseparate fromSeat; holds with expiry; all-or-nothing multi-seat holds; no double booking under concurrency; confirm re-checks the hold. - Above the bar: ordered locking explained with the deadlock it prevents; lazy expiry; authorize-then-capture payment; the conditional-update database version; pricing captured at hold time.
- Why sort the seat IDs before locking?
Reveal
So every thread acquires locks in the same global order. Two users holding overlapping seats in different orders could otherwise each hold one lock and wait for the other's, a deadlock.
- Why is a sweeper thread not required for correctness?
Reveal
Every read treats an expired hold as free (lazy expiry). A sweeper only tidies the seat map for display; correctness never depends on it running on time.
- Where does the price live, and why there?
Reveal
Prices per category belong to the show (same seat, different show, different price). The amount is captured in the hold so later price changes do not affect a user mid-checkout.
- How do you prevent one user from holding 200 seats and blocking a show?
Reveal
A per-booking seat limit, a per-user limit on active holds, and rate limiting on hold requests.
Problem 10: Digital wallet
The fintech problem, and the one closest to payments work: wallets that never go negative, transfers that move money exactly once even when clients retry, locks that cannot deadlock when transfers cross, an append-only ledger that explains every balance, and notifications that never undo a transfer.
Design a digital wallet: users top up from a bank, transfer to other users, see their balance and statement. Clients retry on timeouts. Many transfers run at once, including A to B while B pays A. Then compare.
Reveal the answer
Strong answer: Wallet that guards its balance; transfer(idempotencyKey, from, to, amount) that returns the stored result for a repeated key and moves money once; locks on both wallets taken in a global order; every rule on both sides (funds, the receiver's limit) checked under the locks before either balance changes; a double-entry ledger with a debit and a credit row per transfer; failed transfers recorded as results, not exceptions; listeners called after the money moves and isolated from it; and a conservation check (total money equals total top-ups).
P10.1Requirements
| Question | Assumed answer |
|---|---|
| Operations? | Open wallet, top up from outside, transfer between wallets, balance, statement |
| Money? | Paise as long; no overdraft; per-transfer limit of 1 lakh rupees; a wallet holds at most 2 lakh (the cap on full-KYC wallets in India) |
| Retries? | Clients retry on timeout with the same idempotency key; the bank may repeat a top-up callback |
| Concurrency? | Many transfers at once, in both directions between the same wallets |
| Notifications? | Notify the receiver after a transfer; a notification failure must not affect the transfer |
| Out of scope | KYC, currency conversion, refunds to bank (likely follow-ups) |
Invariants: no balance is ever negative or above the cap; each idempotency key moves money at most once; each top-up reference is credited at most once; every transfer writes one debit and one credit of the same amount; the sum of all balances equals the sum of all top-ups.
P10.2Entities and responsibilities
| Class | Responsibility |
|---|---|
Wallet |
Balance and its lock; canDebit and canCredit answer the rules; debit and credit refuse to break them |
LedgerEntry (record) |
One signed movement with the balance after it; never changed |
Transaction (record) |
The result of a transfer request: completed or failed with a reason |
TransactionListener |
Reacts to completed transfers (notifications, fraud scoring) |
WalletService |
Idempotency for transfers and top-ups, ordered locking, ledger writes, listener calls |
P10.3Class diagram
P10.4Key flows
The ledger after the scenario below, in double-entry form:
| Transaction | Wallet | Amount | Balance after |
|---|---|---|---|
| upi-topup-1 | W1 (Asha) | +1,000 | 1,000 |
| T1 | W1 (Asha) | -250 | 750 |
| T1 | W2 (Ravi) | +250 | 250 |
Every internal transfer sums to zero; only top-ups (and later, withdrawals) change the total. A balance can always be rebuilt by summing a wallet's entries, which is how money bugs are found.
P10.5Code
class Wallet {
static final long MAX_BALANCE = 20_000_000; // 2 lakh rupees: the cap on a full-KYC wallet in India
final String id;
final String ownerId;
final ReentrantLock lock = new ReentrantLock();
private long balancePaise; // guarded by lock
Wallet(String id, String ownerId) { this.id = id; this.ownerId = ownerId; }
boolean canDebit(long amount) { return amount > 0 && amount <= balancePaise; }
boolean canCredit(long amount) { return amount > 0 && amount <= MAX_BALANCE - balancePaise; }
void debit(long amount) {
if (!canDebit(amount)) throw new IllegalStateException("debit of " + amount + " not allowed on " + id);
balancePaise -= amount;
}
void credit(long amount) {
if (!canCredit(amount)) throw new IllegalStateException("credit of " + amount + " not allowed on " + id);
balancePaise += amount;
}
long balance() { return balancePaise; }
}
class WalletService {
private static final long MAX_TRANSFER = 10_000_000; // 1 lakh rupees
private final Map<String, Wallet> wallets = new ConcurrentHashMap<>();
private final Map<String, CompletableFuture<Transaction>> byKey = new ConcurrentHashMap<>();
private final List<LedgerEntry> ledger = Collections.synchronizedList(new ArrayList<>());
private final List<TransactionListener> listeners = new CopyOnWriteArrayList<>();
private final Set<String> seenTopUps = ConcurrentHashMap.newKeySet();
private final AtomicLong walletSeq = new AtomicLong(1), txnSeq = new AtomicLong(1);
private final Clock clock;
WalletService(Clock clock) { this.clock = clock; }
Wallet open(String ownerId) {
Wallet w = new Wallet("W" + walletSeq.getAndIncrement(), ownerId);
wallets.put(w.id, w);
return w;
}
void subscribe(TransactionListener l) { listeners.add(l); }
/**
* Moves money between wallets exactly once per idempotency key.
* A retry with the same key returns the first result instead of moving money again.
*/
Transaction transfer(String key, String fromId, String toId, long amount) {
CompletableFuture<Transaction> mine = new CompletableFuture<>();
CompletableFuture<Transaction> first = byKey.putIfAbsent(key, mine);
if (first != null) { // a retry, or a concurrent duplicate
Transaction t = first.join(); // waits if the first is still running
if (!t.sameRequest(fromId, toId, amount)) throw new IllegalArgumentException("idempotency key reused for a different transfer");
return t;
}
try {
Transaction t = execute(key, fromId, toId, amount);
mine.complete(t);
if (t.status() == TxnStatus.COMPLETED) notifyListeners(t);
return t;
} catch (RuntimeException unexpected) { // not a business failure: let the client retry
byKey.remove(key, mine);
mine.completeExceptionally(unexpected);
throw unexpected;
}
}
private Transaction execute(String key, String fromId, String toId, long amount) {
String txnId = "T" + txnSeq.getAndIncrement();
if (amount <= 0 || amount > MAX_TRANSFER) return failed(txnId, key, fromId, toId, amount, "amount out of range");
if (fromId.equals(toId)) return failed(txnId, key, fromId, toId, amount, "same wallet");
Wallet from = wallets.get(fromId), to = wallets.get(toId);
if (from == null || to == null) return failed(txnId, key, fromId, toId, amount, "unknown wallet");
Wallet first = from.id.compareTo(to.id) < 0 ? from : to; // global lock order: no deadlock
Wallet second = first == from ? to : from;
first.lock.lock();
second.lock.lock();
try {
// check every rule on both sides before changing either: nothing can fail halfway
if (!from.canDebit(amount)) return failed(txnId, key, fromId, toId, amount, "insufficient funds in " + fromId);
if (!to.canCredit(amount)) return failed(txnId, key, fromId, toId, amount, "wallet limit reached for " + toId);
from.debit(amount);
to.credit(amount);
synchronized (ledger) { // both rows appear together
ledger.add(new LedgerEntry(txnId, from.id, -amount, from.balance(), clock.instant()));
ledger.add(new LedgerEntry(txnId, to.id, amount, to.balance(), clock.instant()));
}
} finally {
second.lock.unlock();
first.lock.unlock();
}
return new Transaction(txnId, key, fromId, toId, amount, TxnStatus.COMPLETED, null);
}
/** Money enters from outside (a bank or card top-up). The bank's reference makes a retried top-up a no-op. */
boolean topUp(String walletId, long amount, String externalRef) {
Wallet w = wallet(walletId);
if (!seenTopUps.add(externalRef)) return false; // already credited
w.lock.lock();
try {
if (!w.canCredit(amount)) {
seenTopUps.remove(externalRef); // rejected: the bank may retry or refund
throw new IllegalArgumentException("top-up of " + amount + " not allowed on " + walletId);
}
w.credit(amount);
ledger.add(new LedgerEntry(externalRef, w.id, amount, w.balance(), clock.instant()));
return true;
} finally {
w.lock.unlock();
}
}
long balance(String walletId) {
Wallet w = wallet(walletId);
w.lock.lock();
try { return w.balance(); } finally { w.lock.unlock(); }
}
List<LedgerEntry> statement(String walletId) {
synchronized (ledger) {
return ledger.stream().filter(e -> e.walletId().equals(walletId)).toList();
}
}
private Wallet wallet(String id) {
Wallet w = wallets.get(id);
if (w == null) throw new IllegalArgumentException("unknown wallet " + id);
return w;
}
private Transaction failed(String id, String key, String from, String to, long amt, String why) {
return new Transaction(id, key, from, to, amt, TxnStatus.FAILED, why);
}
private void notifyListeners(Transaction t) { // after the money moved, outside the locks
for (TransactionListener l : listeners) {
try { l.onCompleted(t); } catch (RuntimeException ignored) { /* a failing listener never undoes a transfer */ }
}
}
}
Go deeperThe remaining files
import java.time.Instant;
/** Immutable, append-only. Every transfer writes exactly one debit and one credit with the same transaction id. */
record LedgerEntry(String txnId, String walletId, long amountPaise, long balanceAfter, Instant at) {}
record Transaction(String id, String idempotencyKey, String from, String to, long amountPaise, TxnStatus status, String reason) {
boolean sameRequest(String from, String to, long amount) {
return this.from.equals(from) && this.to.equals(to) && this.amountPaise == amount;
}
}
enum TxnStatus { COMPLETED, FAILED }
interface TransactionListener {
void onCompleted(Transaction t);
}
Wallet asha = svc.open("asha"), ravi = svc.open("ravi");
svc.topUp(asha.id, 100_000, "upi-topup-1"); // 1,000 rupees
boolean again = svc.topUp(asha.id, 100_000, "upi-topup-1"); // the bank retries its callback
Transaction t1 = svc.transfer("req-1", asha.id, ravi.id, 25_000);
Transaction retry = svc.transfer("req-1", asha.id, ravi.id, 25_000); // network retry: same key
Transaction t2 = svc.transfer("req-2", ravi.id, asha.id, 90_000); // more than ravi has
Code that ranDemo.java: the complete code behind the output below
public class Demo {
public static void main(String[] args) throws Exception {
WalletService svc = new WalletService(Clock.fixed(Instant.parse("2026-10-21T09:30:00Z"), ZoneOffset.UTC));
List<String> notified = new CopyOnWriteArrayList<>();
svc.subscribe(t -> notified.add("notify " + t.to() + ": received " + t.amountPaise() / 100));
Wallet asha = svc.open("asha"), ravi = svc.open("ravi");
svc.topUp(asha.id, 100_000, "upi-topup-1"); // 1,000 rupees
boolean again = svc.topUp(asha.id, 100_000, "upi-topup-1"); // the bank retries its callback
Transaction t1 = svc.transfer("req-1", asha.id, ravi.id, 25_000);
Transaction retry = svc.transfer("req-1", asha.id, ravi.id, 25_000); // network retry: same key
Transaction t2 = svc.transfer("req-2", ravi.id, asha.id, 90_000); // more than ravi has
System.out.println(t1.id() + " " + t1.status() + "; retry returned " + retry.id() + " (no second debit)");
System.out.println(t2.id() + " " + t2.status() + ": " + t2.reason());
System.out.println("retried top-up credited again: " + again);
System.out.println("balances: asha " + svc.balance(asha.id) / 100 + ", ravi " + svc.balance(ravi.id) / 100);
System.out.println(notified);
svc.statement(asha.id).forEach(e -> System.out.println(" " + e.txnId() + " " + e.amountPaise() / 100 + " -> " + e.balanceAfter() / 100));
check(!again && retry == t1 && svc.balance(asha.id) == 75_000 && svc.balance(ravi.id) == 25_000, "idempotent transfer");
// 10 wallets, 16 threads, 20,000 random transfers in both directions: money is conserved, no deadlock
List<Wallet> ws = new ArrayList<>();
for (int i = 0; i < 10; i++) { Wallet w = svc.open("u" + i); svc.topUp(w.id, 1_000_000, "seed-" + i); ws.add(w); }
ExecutorService pool = Executors.newFixedThreadPool(16);
for (int t = 0; t < 16; t++) {
int tid = t;
pool.submit(() -> {
ThreadLocalRandom r = ThreadLocalRandom.current();
for (int i = 0; i < 1_250; i++) {
Wallet a = ws.get(r.nextInt(10)), b = ws.get(r.nextInt(10));
if (a != b) svc.transfer("k-" + tid + "-" + i, a.id, b.id, 1 + r.nextInt(50_000));
}
});
}
pool.shutdown();
check(pool.awaitTermination(60, TimeUnit.SECONDS), "no deadlock");
long total = 0;
for (Wallet w : ws) { long b = svc.balance(w.id); check(b >= 0, "negative balance"); total += b; }
System.out.println("after 20,000 concurrent transfers: total " + total / 100 + " rupees (started with 100000)");
check(total == 10_000_000, "money conserved");
}
static void check(boolean ok, String what) { if (!ok) throw new AssertionError(what); }
}
T1 COMPLETED; retry returned T1 (no second debit)
T2 FAILED: insufficient funds in W2
retried top-up credited again: false
balances: asha 750, ravi 250
[notify W2: received 250]
upi-topup-1 1000 -> 1000
T1 -250 -> 750
after 20,000 concurrent transfers: total 100000 rupees (started with 100000)
The last line comes from 16 threads making 20,000 random transfers among 10 wallets in both directions: it finishes (no deadlock), no balance goes negative, and the total is unchanged.
P10.6Concurrency and correctness
| Risk | Defense |
|---|---|
| A retry moves money twice | putIfAbsent(key, future): the first request claims the key; duplicates wait for and return its result |
| Concurrent duplicates with the same key | The same map: the second caller blocks on the first caller's future instead of executing |
| Key reused for a different transfer | Compare the stored request; reject a mismatch |
| A to B and B to A deadlock | Lock both wallets in wallet-ID order |
| Half a transfer | Check funds and the receiver's limit under both locks first; then debit and credit cannot fail between them |
| A repeated top-up callback | The bank's reference is recorded; a second callback with the same reference is a no-op |
| A failing notification | Listeners run after the locks are released, each in its own try/catch |
| A crash during processing | In memory, the process is gone. With a database, the debit, the credit, both ledger rows, and the idempotency record commit in one transaction |
Business failures (insufficient funds) are stored as results, so a retry returns the same failure. Unexpected failures (a bug, a timeout from a dependency) remove the key, so the client can retry. Mixing these up either double-charges or blocks a customer forever.
This is the shape of real payment ledgers: append-only entries, balances derived or cached from them, idempotency keys stored with request fingerprints and results (Stripe's API keeps keys for 24 hours), and reconciliation jobs that compare the ledger against bank statements. In a database, the ordered locking becomes SELECT ... FOR UPDATE on both wallet rows in ID order, or a single conditional update per wallet.
P10.7Deep dives
Guided designExtensions interviewers ask forAnswer each before revealing
- 1Daily transfer limits
Each user may send at most 50,000 rupees per day.
Write your answer, then reveal
A limit check inside the locked section (so two concurrent transfers cannot both pass), using a per-wallet daily counter keyed by date. Put limit rules in a chain of validators (Module 6): KYC level, per-transfer max, daily max, velocity.
- 2Withdraw to a bank account
Money leaves the system through an external payout that can fail or time out.
Write your answer, then reveal
A two-step saga: debit the wallet into a "pending payout" state (a ledger entry against a payout clearing account), call the bank with an idempotency key, then mark the payout completed or reverse the debit on failure. Uncertain outcomes (timeouts) stay pending until a status check or reconciliation resolves them.
- 3Statements for a month
Users ask for statements with opening and closing balances.
Write your answer, then reveal
Query ledger entries by wallet and time range (indexed by wallet and time in a database). Opening balance = the
balanceAfterof the last entry before the range, or the sum of all earlier entries. Store monthly snapshots so old statements do not scan all history. - 4Detect a ledger bug
How would you know if balances drifted from the ledger?
Write your answer, then reveal
A reconciliation job: for each wallet, sum ledger entries and compare with the stored balance; for each transaction, check that its entries sum to zero; globally, check that total balances equal total top-ups minus withdrawals. Alert on any mismatch.
P10.8Machine coding plan
| Minutes | Do |
|---|---|
| 0-10 | Clarify money units, limits, retries, concurrency; write the four invariants |
| 10-25 | Wallet, LedgerEntry, Transaction, exceptions |
| 25-50 | transfer with ordered locks and the ledger; top-up; balance and statement |
| 50-65 | Idempotency map with the future-based single flight |
| 65-80 | Listeners after commit; demo of retry and failure |
| 80-90 | Concurrency test with conservation check; walk through the database version |
P10.9What a strong answer shows
- SDE-2 bar: no negative balances; ordered locking with the deadlock explained; idempotency keys; a ledger with both sides of each transfer; failed transfers as results.
- Above the bar: concurrent duplicates handled (not just sequential retries); business vs unexpected failures distinguished; listeners after commit; reconciliation and conservation checks; the database and saga versions.
- Why check both wallets before changing either?
Reveal
Either side can refuse: the sender may be short, the receiver may be at the wallet limit. If the debit ran and then the credit failed, money would vanish or need compensation. Checking every rule under both locks first means the two writes cannot fail halfway.
- Why store the whole request with the idempotency key?
Reveal
To detect a client bug that reuses a key for a different transfer. Returning the old result for a different request would silently lose the new transfer.
- Could you use optimistic locking instead of wallet locks?
Reveal
Yes: a version per wallet, and a transaction that updates both wallets only if both versions are unchanged, retrying on conflict. It suits low contention; a hot merchant wallet would retry a lot, where pessimistic locks are steadier.
- Why are listeners called outside the locks?
Reveal
They may be slow or fail. Holding wallet locks while notifying would block other transfers, and a failing listener must not undo money that already moved.
Problem 11: In-memory pub-sub message queue
A small Kafka in one process. The design turns on three ideas: a partition is an append-only log where an offset is just a position; a consumer group shares partitions so each message is processed once per group; and committing after processing gives at-least-once delivery, so handlers must tolerate repeats.
Design an in-memory message queue: producers publish to topics; several independent services each consume every message; one service can run several consumer threads to share the load; per-user ordering must hold; a crashed consumer must not lose messages. Then compare.
Reveal the answer
Strong answer: Topic split into Partitions, each an append-only list with a read-write lock; the key's hash chooses the partition, which preserves per-key order; ConsumerGroup with one committed offset per partition and an assignment of partitions to members, recomputed on join and leave; poll reads from the committed offset, commit moves it forward after processing (at-least-once); worker threads that poll, process, and commit; lag per partition; and a clear answer to "what if a consumer crashes mid-batch".
P11.1Requirements
| Question | Assumed answer |
|---|---|
| Publish? | publish(topic, key, value); the key is optional |
| Who receives a message? | Every consumer group receives every message; within a group, exactly one member processes it |
| Ordering? | Messages with the same key are consumed in publish order |
| Delivery guarantee? | At-least-once: nothing is lost; a crash may cause a repeat |
| Push or pull? | Pull: consumers poll at their own pace, so a slow consumer never slows the producer |
| Replay? | A group can move its offset back and reprocess |
| Out of scope | Retention and deletion, persistence, replication (say how they would fit) |
Invariants: offsets in a partition are dense and never change; each partition has at most one owner in a group at a time; a group's committed offset per partition only moves forward, except by an explicit seek.
P11.2Entities and responsibilities
| Class | Responsibility |
|---|---|
Message (record) |
Topic, partition, offset, key, value |
Partition |
Append-only log; append returns the offset; read(from, max) returns a copy |
Topic |
Owns partitions; chooses one per message (hash of key, or round-robin) |
ConsumerGroup |
Members, the partition assignment, committed offsets; poll, commit, seek, lag |
ConsumerWorker |
One thread per member: poll, process, commit, repeat |
Broker |
Creates topics, routes publishes, creates groups |
P11.3Class diagram
P11.4How it works
Three ideas carry the whole design:
- The log is the queue. Consuming does not remove anything. A consumer is only a position in the log, so many groups can read the same data at different speeds, and replay is moving a number back.
- Partitions give order and parallelism. Order is guaranteed only inside one partition. Hashing the key sends every message for one user to the same partition, so that user's events stay in order, while different users spread across partitions that different members process in parallel.
- A group shares partitions, not messages. Each partition has one owner in the group, so a group can have at most as many busy members as partitions. Extra members sit idle; that is why the partition count is a capacity decision.
| Commit when | Guarantee | Failure mode |
|---|---|---|
| Before processing | At-most-once | A crash after the commit loses the message |
| After processing | At-least-once | A crash before the commit repeats the message |
| In the same transaction as the result | Effectively exactly-once | Needs the output and the offset in one store |
At-least-once plus idempotent handlers is the practical answer. Make the handler safe to repeat: dedupe by message ID, use upserts, or check the state before acting (the wallet's idempotency keys from Problem 10 are exactly this).
P11.5Code
/** Append-only log. Offsets are positions; messages are never changed or removed (no retention here). */
class Partition {
private final String topic;
private final int id;
private final List<Message> log = new ArrayList<>();
private final ReentrantReadWriteLock rw = new ReentrantReadWriteLock(); // many readers, one appender
Partition(String topic, int id) { this.topic = topic; this.id = id; }
long append(String key, String value) {
rw.writeLock().lock();
try {
long offset = log.size();
log.add(new Message(topic, id, offset, key, value));
return offset;
} finally {
rw.writeLock().unlock();
}
}
List<Message> read(long fromOffset, int max) {
rw.readLock().lock();
try {
int from = (int) Math.min(fromOffset, log.size());
int to = Math.min(log.size(), from + max);
return new ArrayList<>(log.subList(from, to)); // a copy: safe after the lock is released
} finally {
rw.readLock().unlock();
}
}
long endOffset() {
rw.readLock().lock();
try { return log.size(); } finally { rw.readLock().unlock(); }
}
int id() { return id; }
}
class Topic {
final String name;
final List<Partition> partitions = new ArrayList<>();
private final AtomicInteger roundRobin = new AtomicInteger();
Topic(String name, int partitionCount) {
this.name = name;
for (int i = 0; i < partitionCount; i++) partitions.add(new Partition(name, i));
}
/** Same key, same partition: per-key order is preserved. No key: spread evenly. */
long publish(String key, String value) {
int p = key == null
? Math.floorMod(roundRobin.getAndIncrement(), partitions.size())
: Math.floorMod(key.hashCode(), partitions.size());
return partitions.get(p).append(key, value);
}
}
/** Members share the topic's partitions: each partition has one owner, so per-partition order holds. */
class ConsumerGroup {
final String name;
private final Topic topic;
private final Map<Integer, AtomicLong> committed = new ConcurrentHashMap<>(); // partition -> next offset to read
private final List<String> members = new ArrayList<>();
private Map<String, List<Integer>> assignment = Map.of();
ConsumerGroup(String name, Topic topic) {
this.name = name;
this.topic = topic;
for (Partition p : topic.partitions) committed.put(p.id(), new AtomicLong(0));
}
synchronized void join(String consumerId) {
if (members.contains(consumerId)) return; // joining twice must not double its share
members.add(consumerId);
rebalance();
}
synchronized void leave(String consumerId) { members.remove(consumerId); rebalance(); }
/** Round-robin assignment of partitions to members, recomputed on every membership change. */
private void rebalance() {
Map<String, List<Integer>> next = new LinkedHashMap<>();
for (String m : members) next.put(m, new ArrayList<>());
for (int p = 0; p < topic.partitions.size() && !members.isEmpty(); p++)
next.get(members.get(p % members.size())).add(p);
assignment = next;
}
synchronized List<Integer> partitionsOf(String consumerId) {
return List.copyOf(assignment.getOrDefault(consumerId, List.of()));
}
/** Messages after the committed offset on this member's partitions. Nothing is marked read until commit. */
List<Message> poll(String consumerId, int maxPerPartition) {
List<Message> out = new ArrayList<>();
for (int p : partitionsOf(consumerId))
out.addAll(topic.partitions.get(p).read(committed.get(p).get(), maxPerPartition));
return out;
}
/** Commit after processing: at-least-once delivery. Only moves forward. */
void commit(String consumerId, Message m) {
if (!partitionsOf(consumerId).contains(m.partition()))
throw new IllegalStateException(consumerId + " no longer owns partition " + m.partition());
committed.get(m.partition()).accumulateAndGet(m.offset() + 1, Math::max);
}
/** Replay or skip: move this group's position on one partition. */
void seek(int partition, long offset) { committed.get(partition).set(offset); }
Map<Integer, Long> lag() {
Map<Integer, Long> lag = new HashMap<>();
for (Partition p : topic.partitions) lag.put(p.id(), p.endOffset() - committed.get(p.id()).get());
return lag;
}
}
/** One thread per group member: poll, process, commit, repeat. Membership is managed by the caller. */
class ConsumerWorker implements Runnable {
private final ConsumerGroup group;
private final String memberId;
private final Consumer<Message> handler;
private volatile boolean running = true; // written by another thread in stop()
ConsumerWorker(ConsumerGroup group, String memberId, Consumer<Message> handler) {
this.group = group;
this.memberId = memberId;
this.handler = handler;
}
void stop() { running = false; }
@Override
public void run() {
while (running) {
List<Message> batch = group.poll(memberId, 50);
if (batch.isEmpty()) { pause(); continue; } // a real broker long-polls instead of sleeping
try {
for (Message m : batch) {
handler.accept(m); // must be idempotent: it may see m again
group.commit(memberId, m);
}
} catch (IllegalStateException revoked) {
// partition moved to another member during a rebalance: drop the rest, poll again
}
}
}
private static void pause() {
try { Thread.sleep(2); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}
Go deeperThe remaining files
record Message(String topic, int partition, long offset, String key, String value) {}
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
class Broker {
private final Map<String, Topic> topics = new ConcurrentHashMap<>();
private final Map<String, ConsumerGroup> groups = new ConcurrentHashMap<>();
Topic createTopic(String name, int partitions) {
Topic t = new Topic(name, partitions);
if (topics.putIfAbsent(name, t) != null) throw new IllegalArgumentException("topic exists: " + name);
return t;
}
long publish(String topic, String key, String value) { return topic(topic).publish(key, value); }
ConsumerGroup group(String groupName, String topicName) {
return groups.computeIfAbsent(groupName + "@" + topicName, k -> new ConsumerGroup(groupName, topic(topicName)));
}
private Topic topic(String name) {
Topic t = topics.get(name);
if (t == null) throw new IllegalArgumentException("no topic " + name);
return t;
}
}
for (int i = 1; i <= 3; i++)
for (String user : List.of("asha", "ravi", "meera")) broker.publish("orders", user, user + "-order-" + i);
ConsumerGroup billing = broker.group("billing", "orders");
billing.join("c1");
billing.join("c2");
System.out.println("billing: c1 owns " + billing.partitionsOf("c1") + ", c2 owns " + billing.partitionsOf("c2"));
List<Message> got = billing.poll("c1", 10);
System.out.println("c1 got " + got.stream().map(Message::value).toList());
billing.commit("c1", got.get(0)); // c1 processed only the first message, then crashed
billing.leave("c1"); // rebalance: c2 now owns every partition
List<Message> redelivered = billing.poll("c2", 10);
System.out.println("after c1 left, c2 sees " + redelivered.size() + " messages (uncommitted ones come back)");
ConsumerGroup analytics = broker.group("analytics", "orders"); // an independent group reads everything
analytics.join("a1");
List<Message> all = analytics.poll("a1", 10);
all.forEach(m -> analytics.commit("a1", m));
analytics.seek(0, 0); // replay partition 0 from the start
System.out.println("analytics read " + all.size() + "; after seek(P0, 0) it sees " + analytics.poll("a1", 10).size() + " again");
broker.createTopic("events", 4);
ConsumerGroup g = broker.group("workers", "events");
Set<String> seen = ConcurrentHashMap.newKeySet();
Map<String, Integer> lastSeq = new ConcurrentHashMap<>();
AtomicBoolean orderBroken = new AtomicBoolean();
List<ConsumerWorker> workers = new ArrayList<>();
ExecutorService pool = Executors.newFixedThreadPool(7);
for (int c = 0; c < 3; c++) {
g.join("w" + c);
ConsumerWorker w = new ConsumerWorker(g, "w" + c, m -> {
seen.add(m.value()); // value = producer:sequence
String[] ps = m.value().split(":");
Integer prev = lastSeq.put(ps[0] + "/" + m.key(), Integer.parseInt(ps[1]));
if (prev != null && prev >= Integer.parseInt(ps[1])) orderBroken.set(true);
});
workers.add(w);
pool.submit(w);
}
List<Thread> producers = new ArrayList<>();
for (int p = 0; p < 4; p++) {
int pid = p;
Thread t = new Thread(() -> {
for (int i = 0; i < 1_000; i++) broker.publish("events", "k" + (i % 20), pid + ":" + i);
});
t.start();
producers.add(t);
}
for (Thread t : producers) t.join();
while (g.lag().values().stream().mapToLong(Long::longValue).sum() > 0) Thread.sleep(5);
workers.forEach(ConsumerWorker::stop);
pool.shutdown();
check(pool.awaitTermination(10, TimeUnit.SECONDS), "workers stopped");
System.out.println("3 workers consumed " + seen.size() + " of 4000; per-key order kept: " + !orderBroken.get() + "; lag " + g.lag());
Code that ranDemo.java: the complete code behind the output below
public class Demo {
public static void main(String[] args) throws Exception {
Broker broker = new Broker();
broker.createTopic("orders", 3);
for (int i = 1; i <= 3; i++)
for (String user : List.of("asha", "ravi", "meera")) broker.publish("orders", user, user + "-order-" + i);
ConsumerGroup billing = broker.group("billing", "orders");
billing.join("c1");
billing.join("c2");
System.out.println("billing: c1 owns " + billing.partitionsOf("c1") + ", c2 owns " + billing.partitionsOf("c2"));
List<Message> got = billing.poll("c1", 10);
System.out.println("c1 got " + got.stream().map(Message::value).toList());
billing.commit("c1", got.get(0)); // c1 processed only the first message, then crashed
billing.leave("c1"); // rebalance: c2 now owns every partition
List<Message> redelivered = billing.poll("c2", 10);
System.out.println("after c1 left, c2 sees " + redelivered.size() + " messages (uncommitted ones come back)");
ConsumerGroup analytics = broker.group("analytics", "orders"); // an independent group reads everything
analytics.join("a1");
List<Message> all = analytics.poll("a1", 10);
all.forEach(m -> analytics.commit("a1", m));
analytics.seek(0, 0); // replay partition 0 from the start
System.out.println("analytics read " + all.size() + "; after seek(P0, 0) it sees " + analytics.poll("a1", 10).size() + " again");
check(redelivered.size() == 8, "at-least-once redelivery");
broker.createTopic("events", 4);
ConsumerGroup g = broker.group("workers", "events");
Set<String> seen = ConcurrentHashMap.newKeySet();
Map<String, Integer> lastSeq = new ConcurrentHashMap<>();
AtomicBoolean orderBroken = new AtomicBoolean();
List<ConsumerWorker> workers = new ArrayList<>();
ExecutorService pool = Executors.newFixedThreadPool(7);
for (int c = 0; c < 3; c++) {
g.join("w" + c);
ConsumerWorker w = new ConsumerWorker(g, "w" + c, m -> {
seen.add(m.value()); // value = producer:sequence
String[] ps = m.value().split(":");
Integer prev = lastSeq.put(ps[0] + "/" + m.key(), Integer.parseInt(ps[1]));
if (prev != null && prev >= Integer.parseInt(ps[1])) orderBroken.set(true);
});
workers.add(w);
pool.submit(w);
}
List<Thread> producers = new ArrayList<>();
for (int p = 0; p < 4; p++) {
int pid = p;
Thread t = new Thread(() -> {
for (int i = 0; i < 1_000; i++) broker.publish("events", "k" + (i % 20), pid + ":" + i);
});
t.start();
producers.add(t);
}
for (Thread t : producers) t.join();
while (g.lag().values().stream().mapToLong(Long::longValue).sum() > 0) Thread.sleep(5);
workers.forEach(ConsumerWorker::stop);
pool.shutdown();
check(pool.awaitTermination(10, TimeUnit.SECONDS), "workers stopped");
System.out.println("3 workers consumed " + seen.size() + " of 4000; per-key order kept: " + !orderBroken.get() + "; lag " + g.lag());
check(seen.size() == 4_000 && !orderBroken.get(), "all consumed in order");
}
static void check(boolean ok, String what) { if (!ok) throw new AssertionError(what); }
}
billing: c1 owns [0, 2], c2 owns [1]
c1 got [meera-order-1, meera-order-2, meera-order-3, asha-order-1, ravi-order-1, asha-order-2, ravi-order-2, asha-order-3, ravi-order-3]
after c1 left, c2 sees 8 messages (uncommitted ones come back)
analytics read 9; after seek(P0, 0) it sees 3 again
3 workers consumed 4000 of 4000; per-key order kept: true; lag {0=0, 1=0, 2=0, 3=0}
In the demo's orders topic, Meera's orders hash to partition 0 and Asha's and Ravi's to partition 2, so partition 1 is empty and c2 has nothing to do at first: a small picture of why hot keys and partition counts matter.
P11.6Concurrency
| Shared state | Protection | Why this choice |
|---|---|---|
| A partition's log | ReentrantReadWriteLock |
Many readers (every group) and one appender at a time; reads return a copy so callers never see the list change |
| Round-robin counter | AtomicInteger |
One atomic increment per publish |
| Members and assignment | synchronized on the group |
Rare changes; a rebalance must replace the whole assignment at once |
| Committed offsets | AtomicLong per partition with accumulateAndGet(max) |
A late commit can never move an offset backwards |
| Worker stop flag | volatile boolean |
Written by one thread, read by the worker loop |
A commit from a member that lost the partition in a rebalance is rejected, and the worker drops the rest of its batch. The new owner starts from the last committed offset, so nothing is lost; some messages repeat, which the idempotent handler absorbs.
This design uses one number per partition for both "where to read next" and "where to restart after a crash", so a member must commit before it polls again. Kafka keeps two: the in-memory position (advanced by every poll) and the committed offset (advanced by commits). Mention it if asked how to process the next batch while the previous one is still being committed.
Kafka, Redpanda, and Pulsar work this way, with the log on disk, replicated across brokers, and deleted by time or size (retention). Kafka fences stale members with a generation number: every rebalance increments it, and commits carrying an old generation are rejected, the same rule as the ownership check here. RabbitMQ and SQS are the other family: messages are removed once acknowledged, with no replay. The HLD handbook covers choosing between them.
P11.7Deep dives
Guided designExtensions interviewers ask forAnswer each before revealing
- 1Push to subscribers instead
The interviewer wants subscribers to receive messages through a callback, without polling.
Write your answer, then reveal
Observer (Module 7) with one queue and one delivery thread per subscriber:
publishappends to each subscriber'sBlockingQueueand returns; each subscriber's thread takes from its queue and calls the callback. A slow subscriber then only delays itself. Bound each queue and decide what happens when it is full: block the publisher, drop the oldest, or disconnect the subscriber. - 2Retention
Memory grows forever.
Write your answer, then reveal
Split each partition's log into segments (for example every 10,000 messages) and delete whole segments older than the retention time or beyond the size limit. Offsets stay the same; a group whose offset points into a deleted segment is moved to the earliest remaining offset (Kafka calls this the offset reset policy).
- 3Dead-letter queue
One message always makes the handler throw, and the partition is stuck.
Write your answer, then reveal
Retry a few times with backoff, then publish the message to a
topic.dlqtopic with the error, commit it, and move on. A separate process inspects and replays the dead letters. Without this, one bad message blocks every message behind it in that partition. - 4More consumers than partitions
The team adds 10 workers to a group on a 4-partition topic, and throughput does not change.
Write your answer, then reveal
Only 4 members can own partitions; the other 6 are idle. Raise the partition count (planned up front, because changing it moves keys to different partitions and breaks per-key order during the change), or process messages from one partition in parallel inside a worker when order does not matter.
P11.8Machine coding plan
| Minutes | Do |
|---|---|
| 0-15 | Clarify group semantics, ordering, delivery; draw the log with offsets and two groups |
| 15-35 | Message, Partition (append, read, lock), Topic (key hashing), Broker |
| 35-60 | ConsumerGroup: join, leave, round-robin assignment, poll, commit |
| 60-80 | ConsumerWorker threads; demo with producers and workers; verify count and per-key order |
| 80-100 | Rebalance test (a member leaves mid-way, nothing is lost); lag; seek for replay |
| 100-120 | Walk through push delivery, retention, dead letters, and the Kafka differences |
P11.9What a strong answer shows
- SDE-2 bar: offsets instead of deleting messages; per-key ordering through partitions; groups with one owner per partition; commit after processing, with the repeat explained; a thread-safe log.
- Above the bar: rebalance with stale-commit rejection; lag as the health metric; position vs committed offset; idempotent handlers; dead letters; why partitions cap a group's parallelism.
- Why not delete a message once it is consumed?
Reveal
Several groups read the same topic at different speeds, and replay needs old data. An offset per group lets every group keep its own place in a single shared log.
- How is order preserved for one user while still processing in parallel?
Reveal
Hash the user ID to a partition, so all of that user's messages land in one partition, and give each partition to one member of the group. Different users are spread across partitions processed in parallel.
- A consumer crashes after processing a message but before committing. What happens?
Reveal
After the rebalance, the new owner reads from the last committed offset and processes that message again. The handler must be idempotent.
- What does lag tell you?
Reveal
End offset minus committed offset per partition: how far behind a group is. Rising lag means consumers are slower than producers; it is the main alert for a queue.
Problem 12: Task scheduler
Build a small ScheduledExecutorService: a priority queue of tasks ordered by run time, one dispatcher thread that sleeps exactly until the next task is due and wakes early when a sooner one arrives, a worker pool that runs tasks, recurring tasks at a fixed rate or with a fixed delay, and cancellation that is safe at any moment.
Design a task scheduler: run a task once after a delay, or repeatedly every N seconds; cancel a task; run many tasks in parallel; a failing task must not break the scheduler. Show how the scheduler knows when to wake up. Then compare.
Reveal the answer
Strong answer: ScheduledTask with a next run time and a state machine (scheduled, running, done, failed, cancelled); a PriorityQueue ordered by next run time (and a sequence number for ties) under a lock; one dispatcher thread that waits on a Condition with awaitNanos(head - now) and is signalled when a new task becomes the head; a fixed thread pool that runs due tasks; recurring tasks re-queued after each run with fixed-rate or fixed-delay timing; lazy cancellation with an atomic state; task exceptions caught and recorded; and a shutdown that cancels waiting tasks.
P12.1Requirements
| Question | Assumed answer |
|---|---|
| Kinds of task? | Once after a delay; fixed rate (every N by the clock); fixed delay (N after each run ends) |
| Precision? | Milliseconds; a task never runs before its time and starts promptly after it |
| Parallelism? | A pool of K worker threads; a slow task must not delay others' start times |
| Overlap? | A recurring task never runs twice at the same time |
| Failures? | A throwing task is recorded; a recurring task keeps its schedule |
| Cancel? | Any time; a running task finishes its current run and is not rescheduled |
| Out of scope | Persistence across restarts, cron expressions, several machines (deep dives) |
Invariants: no task runs before its due time; each run is claimed by exactly one worker; a cancelled task never starts again; a recurring task has at most one run in flight.
P12.2Entities and responsibilities
| Class | Responsibility |
|---|---|
TaskState (enum) |
SCHEDULED, RUNNING, DONE, FAILED, CANCELLED |
ScheduledTask |
The action, kind, period, next run time; state changes by compare-and-set; run count, last error |
TaskScheduler |
Priority queue, lock and condition, dispatcher thread, worker pool; schedule, cancel, shutdown |
P12.3Class diagram
P12.4How it works
The dispatcher's loop is the heart of it, and it is worth saying out loud in the interview:
- Look at the head of the queue. Empty: wait until signalled.
- Not due yet: wait for exactly
due - now, but wake early if signalled. A new task that becomes the head signals, so a task due in 100 ms is not stuck behind one due in 300 ms. - Due: remove it and hand it to the worker pool. Never run it on the dispatcher thread, or one slow task would delay every other start.
The dispatcher is a hotel night porter with a list of wake-up calls sorted by time. He sets one alarm for the earliest call and sleeps. If a guest asks for an earlier call, the porter resets the alarm. When the alarm rings, he hands the call to a free receptionist and sets the next alarm. He never makes the calls himself, or a long conversation would make him miss the next one.
P12.5Code
class TaskScheduler {
private final PriorityQueue<ScheduledTask> queue = new PriorityQueue<>(
Comparator.comparingLong(ScheduledTask::nextRunNanos).thenComparingLong(ScheduledTask::seq));
private final ReentrantLock lock = new ReentrantLock();
private final Condition changed = lock.newCondition(); // signalled when a new earliest task arrives
private final Map<Long, ScheduledTask> tasks = new ConcurrentHashMap<>();
private final AtomicLong ids = new AtomicLong();
private final ExecutorService workers;
private final Thread dispatcher = new Thread(this::dispatchLoop, "dispatcher");
private long seq; // guarded by lock
private boolean running = true; // guarded by lock
TaskScheduler(int workerThreads) { workers = Executors.newFixedThreadPool(workerThreads); }
void start() { dispatcher.start(); } // separate from the constructor: build first, then run
long schedule(String name, Runnable r, Duration delay) {
return submit(name, r, ScheduledTask.Kind.ONCE, delay, Duration.ZERO);
}
long scheduleAtFixedRate(String name, Runnable r, Duration initialDelay, Duration period) {
return submit(name, r, ScheduledTask.Kind.FIXED_RATE, initialDelay, period);
}
long scheduleWithFixedDelay(String name, Runnable r, Duration initialDelay, Duration delay) {
return submit(name, r, ScheduledTask.Kind.FIXED_DELAY, initialDelay, delay);
}
boolean cancel(long id) {
ScheduledTask t = tasks.get(id);
return t != null && t.cancel(); // lazy: the dispatcher skips it when it reaches the head
}
TaskState state(long id) { return tasks.get(id).state(); }
int runs(long id) { return tasks.get(id).runs(); }
private long submit(String name, Runnable r, ScheduledTask.Kind kind, Duration delay, Duration period) {
if (delay.isNegative()) throw new IllegalArgumentException("negative delay");
if (kind != ScheduledTask.Kind.ONCE && (period.isZero() || period.isNegative()))
throw new IllegalArgumentException("period must be positive");
ScheduledTask t = new ScheduledTask(ids.incrementAndGet(), name, r, kind,
System.nanoTime() + delay.toNanos(), period.toNanos());
tasks.put(t.id, t);
if (!enqueue(t)) throw new IllegalStateException("scheduler is shut down");
return t.id;
}
private boolean enqueue(ScheduledTask t) {
lock.lock();
try {
if (!running) return false;
t.setSeq(seq++);
queue.add(t);
if (queue.peek() == t) changed.signal(); // earlier than what the dispatcher waits for: re-plan
return true;
} finally {
lock.unlock();
}
}
private void dispatchLoop() {
lock.lock();
try {
while (running) {
ScheduledTask head = queue.peek();
if (head == null) { changed.await(); continue; }
long wait = head.nextRunNanos() - System.nanoTime();
if (wait > 0) { changed.awaitNanos(wait); continue; } // re-check: a sooner task may have arrived
queue.poll();
if (head.state() == TaskState.CANCELLED) continue;
workers.execute(() -> runOnce(head));
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.unlock();
}
}
private void runOnce(ScheduledTask t) {
if (!t.tryStart()) return; // cancelled after it was handed over
t.runAction(); // a failing task cannot kill the worker
if (t.finishRun(System.nanoTime()) && !enqueue(t)) t.cancel();
}
/** Stops accepting tasks, cancels waiting ones, lets running ones finish. */
void shutdown() throws InterruptedException {
lock.lock();
try {
running = false;
queue.forEach(ScheduledTask::cancel);
queue.clear();
changed.signal();
} finally {
lock.unlock();
}
dispatcher.join();
workers.shutdown();
if (!workers.awaitTermination(5, TimeUnit.SECONDS)) workers.shutdownNow();
}
}
final class ScheduledTask {
enum Kind { ONCE, FIXED_RATE, FIXED_DELAY }
final long id;
final String name;
private final Runnable action;
private final Kind kind;
private final long periodNanos;
private long nextRunNanos; // changed only while the task is out of the queue
private long seq; // tie-breaker: equal times run in submission order
private final AtomicReference<TaskState> state = new AtomicReference<>(TaskState.SCHEDULED);
private final AtomicInteger runs = new AtomicInteger();
private volatile RuntimeException lastError;
ScheduledTask(long id, String name, Runnable action, Kind kind, long firstRunNanos, long periodNanos) {
this.id = id;
this.name = name;
this.action = action;
this.kind = kind;
this.nextRunNanos = firstRunNanos;
this.periodNanos = periodNanos;
}
/** Claim one run. Fails if the task was cancelled while it waited in the queue. */
boolean tryStart() {
if (!state.compareAndSet(TaskState.SCHEDULED, TaskState.RUNNING)) return false;
runs.incrementAndGet();
return true;
}
void runAction() {
try {
action.run();
lastError = null;
} catch (RuntimeException e) {
lastError = e; // recorded, never thrown into the worker thread
}
}
/** After a run: true if the task should go back into the queue. */
boolean finishRun(long nowNanos) {
if (kind == Kind.ONCE) {
state.compareAndSet(TaskState.RUNNING, lastError == null ? TaskState.DONE : TaskState.FAILED);
return false;
}
nextRunNanos = kind == Kind.FIXED_RATE ? nextRunNanos + periodNanos : nowNanos + periodNanos;
return state.compareAndSet(TaskState.RUNNING, TaskState.SCHEDULED); // false if cancelled during the run
}
boolean cancel() {
while (true) {
TaskState s = state.get();
if (s == TaskState.DONE || s == TaskState.FAILED || s == TaskState.CANCELLED) return false;
if (state.compareAndSet(s, TaskState.CANCELLED)) return true;
}
}
long nextRunNanos() { return nextRunNanos; }
long seq() { return seq; }
void setSeq(long seq) { this.seq = seq; }
TaskState state() { return state.get(); }
int runs() { return runs.get(); }
}
Go deeperThe remaining file
enum TaskState { SCHEDULED, RUNNING, DONE, FAILED, CANCELLED }
s.schedule("report", () -> log.add("report"), ms(300));
s.schedule("reminder", () -> log.add("reminder"), ms(100)); // sooner than the head: the dispatcher re-plans
s.schedule("cleanup", () -> log.add("cleanup"), ms(200));
long broken = s.schedule("broken", () -> { throw new IllegalStateException("db down"); }, ms(50));
long beat = s.scheduleAtFixedRate("heartbeat", () -> { }, ms(0), ms(40));
AtomicInteger syncCalls = new AtomicInteger();
long sync = s.scheduleWithFixedDelay("sync", () -> {
if (syncCalls.incrementAndGet() == 2) throw new IllegalStateException("timeout"); // fails once
}, ms(0), ms(60));
Thread.sleep(400);
s.cancel(beat);
s.cancel(sync);
int beatsAtCancel = s.runs(beat);
Thread.sleep(150);
System.out.println("one-shots ran in time order: " + log);
System.out.println("broken: " + s.state(broken) + "; sync kept running after its failure: " + (s.runs(sync) >= 4));
System.out.println("heartbeat ran every 40 ms: " + (beatsAtCancel >= 5) + "; state " + s.state(beat)
+ "; runs after cancel: " + (s.runs(beat) - beatsAtCancel));
long never = s.schedule("never", () -> log.add("never"), ms(10_000));
s.shutdown();
System.out.println("after shutdown, the waiting task is " + s.state(never));
TaskScheduler s2 = new TaskScheduler(4);
s2.start();
int n = 2_000;
CountDownLatch done = new CountDownLatch(n);
AtomicIntegerArray ranTimes = new AtomicIntegerArray(n);
AtomicInteger early = new AtomicInteger();
List<Thread> submitters = new ArrayList<>();
for (int k = 0; k < 4; k++) {
int part = k;
Thread t = new Thread(() -> {
for (int i = part; i < n; i += 4) {
int task = i;
long delay = ThreadLocalRandom.current().nextLong(0, 100);
long due = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(delay);
s2.schedule("t" + task, () -> {
if (System.nanoTime() < due) early.incrementAndGet();
ranTimes.incrementAndGet(task);
done.countDown();
}, ms(delay));
}
});
t.start();
submitters.add(t);
}
for (Thread t : submitters) t.join();
check(done.await(10, TimeUnit.SECONDS), "all ran");
s2.shutdown();
boolean once = true;
for (int i = 0; i < n; i++) once &= ranTimes.get(i) == 1;
System.out.println(n + " tasks from 4 threads: each ran exactly once: " + once + "; ran early: " + early.get());
Code that ranDemo.java: the complete code behind the output below
public class Demo {
public static void main(String[] args) throws Exception {
TaskScheduler s = new TaskScheduler(2);
s.start();
List<String> log = Collections.synchronizedList(new ArrayList<>());
s.schedule("report", () -> log.add("report"), ms(300));
s.schedule("reminder", () -> log.add("reminder"), ms(100)); // sooner than the head: the dispatcher re-plans
s.schedule("cleanup", () -> log.add("cleanup"), ms(200));
long broken = s.schedule("broken", () -> { throw new IllegalStateException("db down"); }, ms(50));
long beat = s.scheduleAtFixedRate("heartbeat", () -> { }, ms(0), ms(40));
AtomicInteger syncCalls = new AtomicInteger();
long sync = s.scheduleWithFixedDelay("sync", () -> {
if (syncCalls.incrementAndGet() == 2) throw new IllegalStateException("timeout"); // fails once
}, ms(0), ms(60));
Thread.sleep(400);
s.cancel(beat);
s.cancel(sync);
int beatsAtCancel = s.runs(beat);
Thread.sleep(150);
System.out.println("one-shots ran in time order: " + log);
System.out.println("broken: " + s.state(broken) + "; sync kept running after its failure: " + (s.runs(sync) >= 4));
System.out.println("heartbeat ran every 40 ms: " + (beatsAtCancel >= 5) + "; state " + s.state(beat)
+ "; runs after cancel: " + (s.runs(beat) - beatsAtCancel));
long never = s.schedule("never", () -> log.add("never"), ms(10_000));
s.shutdown();
System.out.println("after shutdown, the waiting task is " + s.state(never));
check(log.equals(List.of("reminder", "cleanup", "report")), "order");
check(s.state(broken) == TaskState.FAILED && s.runs(beat) == beatsAtCancel, "states");
TaskScheduler s2 = new TaskScheduler(4);
s2.start();
int n = 2_000;
CountDownLatch done = new CountDownLatch(n);
AtomicIntegerArray ranTimes = new AtomicIntegerArray(n);
AtomicInteger early = new AtomicInteger();
List<Thread> submitters = new ArrayList<>();
for (int k = 0; k < 4; k++) {
int part = k;
Thread t = new Thread(() -> {
for (int i = part; i < n; i += 4) {
int task = i;
long delay = ThreadLocalRandom.current().nextLong(0, 100);
long due = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(delay);
s2.schedule("t" + task, () -> {
if (System.nanoTime() < due) early.incrementAndGet();
ranTimes.incrementAndGet(task);
done.countDown();
}, ms(delay));
}
});
t.start();
submitters.add(t);
}
for (Thread t : submitters) t.join();
check(done.await(10, TimeUnit.SECONDS), "all ran");
s2.shutdown();
boolean once = true;
for (int i = 0; i < n; i++) once &= ranTimes.get(i) == 1;
System.out.println(n + " tasks from 4 threads: each ran exactly once: " + once + "; ran early: " + early.get());
check(once && early.get() == 0, "load");
}
static Duration ms(long millis) { return Duration.ofMillis(millis); }
static void check(boolean ok, String what) { if (!ok) throw new AssertionError(what); }
}
one-shots ran in time order: [reminder, cleanup, report]
broken: FAILED; sync kept running after its failure: true
heartbeat ran every 40 ms: true; state CANCELLED; runs after cancel: 0
after shutdown, the waiting task is CANCELLED
2000 tasks from 4 threads: each ran exactly once: true; ran early: 0
P12.6Concurrency
| Shared state | Protection | Why |
|---|---|---|
| The priority queue | ReentrantLock |
PriorityQueue is not thread-safe; callers, the dispatcher, and workers all touch it |
| "A sooner task arrived" | Condition signalled when the new task is the head |
The dispatcher sleeps the exact time but never misses an earlier task |
| A task's state | AtomicReference with compare-and-set |
Cancel, start, and finish can race; exactly one transition wins |
| A task's next run time | Changed only while the task is out of the queue | Changing a key inside a PriorityQueue corrupts its order silently |
| Equal run times | Sequence number as a tie-breaker | Tasks due at the same moment run in submission order |
Cancellation is lazy: cancel only flips the state, and the dispatcher discards cancelled tasks when they reach the head. Removing from the middle of a heap is O(n); lazy removal is O(1) and the cost is memory until the task's time comes (the JDK's setRemoveOnCancelPolicy exists for when that memory matters).
The JDK's ScheduledExecutorService silently stops a periodic task forever after one run throws, with no log line. This is a classic production bug: a nightly job stops running and nobody notices. Wrap periodic task bodies in try/catch, or use a scheduler that, like this one, records the failure and keeps the schedule.
The JDK shortcut for the queue and the waiting is DelayQueue: elements implement Delayed, and take() blocks until the head is due. A dispatcher thread that loops on take() and submits to a pool is a complete one-screen scheduler. Write the lock and condition version when the interviewer wants to see the mechanism; mention DelayQueue either way.
ScheduledThreadPoolExecutor is built the same way: a heap ordered by time and then sequence number, and periodic tasks re-queued after each run. One difference: it has no separate dispatcher; one idle worker at a time (the "leader") sleeps until the head is due, and the others wait behind it. Spring's @Scheduled runs on it. Jobs that must survive restarts or run on one machine out of many use Quartz with a database job store, ShedLock, or a workflow engine; the HLD handbook covers the distributed version.
P12.7Deep dives
Guided designExtensions interviewers ask forAnswer each before revealing
- 1Cron expressions
Run at 9:00 every weekday.
Write your answer, then reveal
A
Triggerstrategy withnextRunAfter(Instant): one implementation for fixed rate, one for fixed delay, one for cron (parse the expression into allowed minutes, hours, and days, then search forward for the next match).ScheduledTaskasks its trigger for the next time instead of adding a period. Use wall-clock time with a time zone for cron, and decide what happens across daylight-saving changes. - 2Survive a restart
Tasks must not be lost if the process dies.
Write your answer, then reveal
Store tasks in a table (id, next_run_at, kind, payload, status) indexed by next_run_at. The dispatcher queries due rows instead of a heap, and claims each with a conditional update (
status = 'RUNNING' WHERE status = 'SCHEDULED'), which also makes several scheduler instances safe. Runs may repeat after a crash, so task bodies must be idempotent. - 3Priorities
Two tasks are due at the same time; one is more important.
Write your answer, then reveal
Add priority to the comparator after the due time, or give high-priority tasks their own worker pool so a flood of low-priority work cannot starve them. Separate pools are usually the better answer: ordering only helps when tasks are waiting, not when all workers are busy with long tasks.
- 4Timeouts
A task hangs forever and holds a worker.
Write your answer, then reveal
Submit through
Futureand cancel it after a deadline withfuture.cancel(true), which interrupts the thread; the task must respond to interruption (blocking calls do; tight loops must checkThread.interrupted()). Record a TIMED_OUT result. Without cooperative code, a hung thread can only be abandoned, so size the pool with that in mind.
P12.8Machine coding plan
| Minutes | Do |
|---|---|
| 0-15 | Clarify kinds, overlap, failures, cancel; draw the dispatcher loop and the task states |
| 15-35 | TaskState, ScheduledTask with the compare-and-set transitions |
| 35-65 | TaskScheduler: queue, lock, condition, dispatcher loop, worker pool, schedule |
| 65-85 | Recurring kinds, cancel, shutdown; demo of order and cancellation |
| 85-105 | Load test from several threads: every task runs once, none early |
| 105-120 | Walk through cron triggers, persistence, timeouts |
P12.9What a strong answer shows
- SDE-2 bar: priority queue by time; a dispatcher that waits for the head instead of polling; a worker pool separate from the dispatcher; fixed rate vs fixed delay explained; cancellation; task failures contained.
- Above the bar: wake-up on a new earliest task; atomic state transitions for cancel races; the "never mutate a key inside a heap" rule; tie-breaking by sequence; the JDK periodic-task exception trap;
DelayQueueas the shortcut; the persistent, multi-instance version.
- Why not have the dispatcher check the queue every 10 ms?
Reveal
Polling either wastes CPU or adds up to 10 ms of lateness. Waiting exactly until the head is due, and being signalled when a sooner task arrives, gives prompt starts with an idle thread.
- Why does the dispatcher not run the task itself?
Reveal
A task that takes 5 seconds would delay every task due during those 5 seconds. The dispatcher only decides when; the workers do the running.
- How do you stop a recurring task from overlapping with itself?
Reveal
Re-queue it only after the current run finishes. The next run time is computed then, so there is never a second copy in the queue while one is running.
- What happens if you cancel a task while it is running?
Reveal
The state changes from RUNNING to CANCELLED. The current run completes (it is not interrupted), and the attempt to move back to SCHEDULED fails, so it is never re-queued.
Problem 13: Tic-tac-toe (N x N)
The warm-up that every game prompt (chess, Ludo, Connect Four) grows from: a board that validates moves, a game that owns turns and status, players whose moves come from a strategy (human, script, or bot), undo through a move history, and the one trick worth knowing, a win check in O(1) per move with counters instead of a board scan.
Design tic-tac-toe for an N x N board and two or more players, where a line of N marks wins. Support a computer player and undo. Then compare.
Reveal the answer
Strong answer: Board that rejects off-board and taken cells; Game that owns the turn, the status (in progress, won, draw), and a move history; Player with a mark and a PlayerStrategy (scripted for tests, a bot, or console input); a WinTracker with per-player counters for each row, column, and the two diagonals, so each move is checked in O(1); undo that pops the last move and reverses every counter; and moves after the end rejected.
P13.1Requirements
| Question | Assumed answer |
|---|---|
| Board size? | N x N, N at least 3; a full row, column, or diagonal of one mark wins |
| Players? | Two or more, each with a distinct mark, in a fixed order |
| Draw? | The board fills with no line |
| Computer player? | Yes, a simple one; humans and bots must be interchangeable |
| Undo? | Take back the last move, even a winning one |
| Out of scope | Networking, ranking, time limits (likely follow-ups) |
Invariants: a cell holds at most one mark; only the current player moves; nothing moves after a win or a draw; undo restores the board, the counters, the turn, and the status together.
P13.2Entities and responsibilities
| Class | Responsibility |
|---|---|
Board |
Cells; place validates bounds and emptiness; knows when it is full |
WinTracker |
Per-player counts per row, column, and diagonal; reports a completed line |
Move (record) |
Player, row, column: one entry in the history |
Player (record) |
Name, mark, and the strategy that picks cells |
PlayerStrategy |
Picks a cell: ScriptedStrategy for tests, SimpleBot, or console input |
Game |
Turn, status, winner, history; play, playTurn, undo |
BoardRenderer |
Prints a board; the model knows nothing about output |
P13.3Class diagram
P13.4Key flows
P13.5Code
/** Counts each player's marks per row, column, and diagonal, so a win is detected in O(1) per move. */
class WinTracker {
private final int n;
private final int[][] rows, cols; // [player][index]
private final int[] diag, anti; // [player]
WinTracker(int n, int players) {
this.n = n;
rows = new int[players][n];
cols = new int[players][n];
diag = new int[players];
anti = new int[players];
}
/** Records a mark; true if it completes a line. */
boolean add(int p, int r, int c) {
rows[p][r]++;
cols[p][c]++;
if (r == c) diag[p]++;
if (r + c == n - 1) anti[p]++;
return rows[p][r] == n || cols[p][c] == n || diag[p] == n || anti[p] == n;
}
void remove(int p, int r, int c) { // for undo
rows[p][r]--;
cols[p][c]--;
if (r == c) diag[p]--;
if (r + c == n - 1) anti[p]--;
}
/** Would a mark here complete a line? Lets a bot look one move ahead without changing anything. */
boolean wouldWin(int p, int r, int c) {
return rows[p][r] + 1 == n || cols[p][c] + 1 == n
|| (r == c && diag[p] + 1 == n) || (r + c == n - 1 && anti[p] + 1 == n);
}
}
class Game {
private final Board board;
private final WinTracker tracker;
private final List<Player> players;
private final Deque<Move> history = new ArrayDeque<>();
private int turn = 0; // index of the player to move
private GameStatus status = GameStatus.IN_PROGRESS;
private Player winner;
Game(int n, List<Player> players) {
if (players.size() < 2) throw new IllegalArgumentException("need at least 2 players");
if (players.stream().map(Player::mark).distinct().count() != players.size())
throw new IllegalArgumentException("marks must be distinct");
this.board = new Board(n);
this.tracker = new WinTracker(n, players.size());
this.players = List.copyOf(players);
}
/** The current player's strategy picks a cell; the game validates and applies it. */
GameStatus playTurn() {
int[] cell = players.get(turn).strategy().chooseCell(this, turn);
return play(cell[0], cell[1]);
}
GameStatus play(int r, int c) {
if (status != GameStatus.IN_PROGRESS) throw new IllegalStateException("game is over: " + status);
Player p = players.get(turn);
board.place(r, c, p.mark()); // validates bounds and emptiness
history.push(new Move(turn, r, c));
if (tracker.add(turn, r, c)) { status = GameStatus.WON; winner = p; }
else if (board.isFull()) status = GameStatus.DRAW;
else turn = (turn + 1) % players.size();
return status;
}
/** Takes back the last move, including a winning one. */
void undo() {
Move m = history.poll();
if (m == null) throw new IllegalStateException("nothing to undo");
board.clear(m.row(), m.col());
tracker.remove(m.player(), m.row(), m.col());
status = GameStatus.IN_PROGRESS;
winner = null;
turn = m.player();
}
GameStatus playToEnd() {
while (status == GameStatus.IN_PROGRESS) playTurn();
return status;
}
boolean wouldWin(int player, int r, int c) { return tracker.wouldWin(player, r, c); }
Board board() { return board; }
int playerCount() { return players.size(); }
GameStatus status() { return status; }
Optional<Player> winner() { return Optional.ofNullable(winner); }
}
class Board {
static final char EMPTY = '.';
private final int n;
private final char[][] cells;
private int filled;
Board(int n) {
if (n < 3) throw new IllegalArgumentException("board must be at least 3 x 3");
this.n = n;
this.cells = new char[n][n];
for (char[] row : cells) java.util.Arrays.fill(row, EMPTY);
}
void place(int r, int c, char mark) {
if (r < 0 || r >= n || c < 0 || c >= n) throw new IllegalArgumentException("cell (" + r + ", " + c + ") is off the board");
if (cells[r][c] != EMPTY) throw new IllegalArgumentException("cell (" + r + ", " + c + ") is taken");
cells[r][c] = mark;
filled++;
}
void clear(int r, int c) {
if (cells[r][c] == EMPTY) throw new IllegalStateException("cell (" + r + ", " + c + ") is already empty");
cells[r][c] = EMPTY;
filled--;
}
boolean isEmpty(int r, int c) { return cells[r][c] == EMPTY; }
boolean isFull() { return filled == n * n; }
char at(int r, int c) { return cells[r][c]; }
int size() { return n; }
}
/** Where a player moves next: a human reading input, a scripted test, or a bot. */
interface PlayerStrategy {
int[] chooseCell(Game game, int me);
}
/** Win if possible, else block the next player's win, else center, else the first free cell. */
class SimpleBot implements PlayerStrategy {
public int[] chooseCell(Game game, int me) {
int n = game.board().size();
int next = (me + 1) % game.playerCount();
for (int target : new int[] {me, next}) // first my win, then their threat
for (int r = 0; r < n; r++)
for (int c = 0; c < n; c++)
if (game.board().isEmpty(r, c) && game.wouldWin(target, r, c)) return new int[] {r, c};
if (game.board().isEmpty(n / 2, n / 2)) return new int[] {n / 2, n / 2};
for (int r = 0; r < n; r++)
for (int c = 0; c < n; c++)
if (game.board().isEmpty(r, c)) return new int[] {r, c};
throw new IllegalStateException("no free cell");
}
}
Go deeperThe remaining files
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.List;
/** Plays a fixed list of moves: stands in for a human in tests and demos. */
class ScriptedStrategy implements PlayerStrategy {
private final Deque<int[]> moves;
ScriptedStrategy(List<int[]> moves) { this.moves = new ArrayDeque<>(moves); }
public int[] chooseCell(Game game, int me) {
if (moves.isEmpty()) throw new IllegalStateException("script ran out of moves");
return moves.poll();
}
}
record Player(String name, char mark, PlayerStrategy strategy) {}
record Move(int player, int row, int col) {}
enum GameStatus { IN_PROGRESS, WON, DRAW }
/** Console output lives here, not in Board: the model does not know about printing. */
final class BoardRenderer {
private BoardRenderer() { }
static String render(Board b) {
StringBuilder sb = new StringBuilder();
for (int r = 0; r < b.size(); r++) {
for (int c = 0; c < b.size(); c++) sb.append(b.at(r, c)).append(c + 1 < b.size() ? " " : "");
sb.append('\n');
}
return sb.toString();
}
}
Player asha = new Player("Asha", 'X', new ScriptedStrategy(List.of(
new int[] {1, 1}, new int[] {0, 0}, new int[] {2, 2})));
Player ravi = new Player("Ravi", 'O', new ScriptedStrategy(List.of(
new int[] {0, 1}, new int[] {0, 2})));
Game game = new Game(3, List.of(asha, ravi));
GameStatus end = game.playToEnd(); // X takes the main diagonal on move 5
System.out.print(BoardRenderer.render(game.board()));
System.out.println(end + ": " + game.winner().map(Player::name).orElse("nobody"));
try { game.play(1, 0); } catch (IllegalStateException e) { System.out.println("rejected: " + e.getMessage()); }
game.undo(); // take back the winning move
System.out.println("after undo: " + game.status() + ", cell (2, 2) empty: " + game.board().isEmpty(2, 2));
try { game.play(0, 0); } catch (IllegalArgumentException e) { System.out.println("rejected: " + e.getMessage()); }
Game bots = new Game(3, List.of(new Player("Bot X", 'X', new SimpleBot()), new Player("Bot O", 'O', new SimpleBot())));
System.out.println("bot vs bot on 3 x 3: " + bots.playToEnd() + " " + bots.winner().map(Player::name).orElse(""));
Game four = new Game(4, List.of(new Player("A", 'A', new SimpleBot()), new Player("B", 'B', new SimpleBot()),
new Player("C", 'C', new SimpleBot())));
System.out.println("three bots on 4 x 4: " + four.playToEnd() + " " + four.winner().map(Player::name).orElse(""));
Code that ranDemo.java: the complete code behind the output below
public class Demo {
public static void main(String[] args) {
Player asha = new Player("Asha", 'X', new ScriptedStrategy(List.of(
new int[] {1, 1}, new int[] {0, 0}, new int[] {2, 2})));
Player ravi = new Player("Ravi", 'O', new ScriptedStrategy(List.of(
new int[] {0, 1}, new int[] {0, 2})));
Game game = new Game(3, List.of(asha, ravi));
GameStatus end = game.playToEnd(); // X takes the main diagonal on move 5
System.out.print(BoardRenderer.render(game.board()));
System.out.println(end + ": " + game.winner().map(Player::name).orElse("nobody"));
try { game.play(1, 0); } catch (IllegalStateException e) { System.out.println("rejected: " + e.getMessage()); }
game.undo(); // take back the winning move
System.out.println("after undo: " + game.status() + ", cell (2, 2) empty: " + game.board().isEmpty(2, 2));
try { game.play(0, 0); } catch (IllegalArgumentException e) { System.out.println("rejected: " + e.getMessage()); }
check(end == GameStatus.WON && game.status() == GameStatus.IN_PROGRESS, "win and undo");
Game bots = new Game(3, List.of(new Player("Bot X", 'X', new SimpleBot()), new Player("Bot O", 'O', new SimpleBot())));
System.out.println("bot vs bot on 3 x 3: " + bots.playToEnd() + " " + bots.winner().map(Player::name).orElse(""));
Game four = new Game(4, List.of(new Player("A", 'A', new SimpleBot()), new Player("B", 'B', new SimpleBot()),
new Player("C", 'C', new SimpleBot())));
System.out.println("three bots on 4 x 4: " + four.playToEnd() + " " + four.winner().map(Player::name).orElse(""));
System.out.print(BoardRenderer.render(four.board()));
check(bots.status() != GameStatus.IN_PROGRESS && four.status() != GameStatus.IN_PROGRESS, "games end");
}
static void check(boolean ok, String what) { if (!ok) throw new AssertionError(what); }
}
X O O
. X .
. . X
WON: Asha
rejected: game is over: WON
after undo: IN_PROGRESS, cell (2, 2) empty: true
rejected: cell (0, 0) is taken
bot vs bot on 3 x 3: WON Bot O
three bots on 4 x 4: DRAW
B C A B
C A B C
A B A C
A B C A
The simple bot lost to itself: playing "first free cell" let its opponent build a fork (two threats at once). That is the expected limit of a one-move look-ahead; the deep dive below shows the unbeatable version.
P13.6Hard parts
The O(1) check. Scanning after every move is O(N^2) per move, which interviewers accept for 3 x 3 but ask you to improve. A mark at (r, c) can only complete row r, column c, and the diagonals through it, so per-player counters for those lines are enough. For two players a common variant keeps one signed counter per line (+1 for X, -1 for O) and wins at +N or -N.
Undo. The history stores moves, not board copies (the command idea from Module 7). Undo must reverse everything the move changed: the cell, four counters, the turn, and the status. Forgetting the counters makes the game declare wins that no longer exist.
Strategy for players. The game asks the current player's strategy for a cell and then validates it like any other move. Bots get no special path into the board, so a buggy bot cannot cheat, and tests replace humans with scripts.
Concurrency. None in the local game. An online version runs each game on one thread at a time (a per-game lock or a single-threaded actor), and rejects a move whose player is not the one to move, which also handles a double click arriving twice.
P13.7Deep dives
Guided designExtensions interviewers ask forAnswer each before revealing
- 1K in a row on a large board
Gomoku: a 15 x 15 board where 5 in a row wins.
Write your answer, then reveal
Counters per whole row no longer work, because a row can hold five marks without five in a row. After a move, walk from the placed cell in each of four directions (horizontal, vertical, two diagonals), counting consecutive marks both ways. That is O(K) per move, still independent of the board size.
- 2An unbeatable bot
The simple bot loses to forks.
Write your answer, then reveal
Minimax: try every legal move, recurse with the other player, score wins +1, losses -1, draws 0, and pick the best guaranteed outcome. 3 x 3 has under 6,000 reachable positions, so it runs instantly; alpha-beta pruning and a memo of seen positions make larger boards feasible. It is just another
PlayerStrategy. - 3Chess from this skeleton
What stays and what changes?
Write your answer, then reveal
Game,Player, turns, history, undo, and the status machine stay. The board holds pieces; each piece type has a move-generation strategy (polymorphism); validation adds "does not leave your own king in check"; the status gains CHECKMATE and STALEMATE; moves become richer commands (captures, castling, promotion) that know how to undo themselves. - 4Replay a game for a spectator
How would you support watching a finished game move by move?
Write your answer, then reveal
The move history already is the game record. Store it, then rebuild a fresh
Gameby replaying the moves throughplay, which re-validates each one. This is event sourcing in miniature.
P13.8Machine coding plan
| Minutes | Do |
|---|---|
| 0-5 | Clarify N, players, draw, undo, bot; write the invariants |
| 5-15 | Board with validation, Move, GameStatus, Player |
| 15-30 | WinTracker and Game.play with turn and status; scripted test of a win and a draw |
| 30-40 | PlayerStrategy, ScriptedStrategy, SimpleBot; playToEnd |
| 40-50 | undo; renderer; driver that reads moves from input |
| 50-60 | Edge cases (taken cell, off board, play after end, undo on empty); walk through extensions |
P13.9What a strong answer shows
- SDE-2 bar: validated moves; turn and status owned by the game; O(1) win check explained; draw detection; strategies for players; undo that reverses everything.
- Above the bar: N players and N x N without special cases; K-in-a-row variant; minimax as a strategy; the skeleton mapped to chess; replay from the move history.
- Why does the win check only look at four lines?
Reveal
A new mark can only complete lines that pass through its cell: its row, its column, and the main or anti-diagonal if it lies on them. Every other line was already checked by earlier moves.
- Why is
Boardnot responsible for detecting a win?Reveal
The board knows cells; winning is a rule of the game, and rules vary (K in a row, misere variants). Keeping the rule in the tracker and the game lets the board stay simple and reusable.
- What must undo restore?
Reveal
The cell, the counters for its row, column, and diagonals, the player to move, and the status (a winning move undone means the game is in progress again).
- How do you test a bot?
Reveal
Set up a board where the bot must win in one move, and one where it must block; assert the chosen cell. Scripted strategies drive the other player.
Problem 14: Meeting room scheduler
Calendar and room booking is an interval problem dressed as a design problem. The data structure is a sorted map per room where only two neighbors can clash; the design is a selection strategy that keeps big rooms for big meetings; the correctness is a per-room lock so two people never get the same slot, and all-or-nothing booking for recurring series.
Design a meeting room scheduler: rooms with capacities, book a room for a time range (any suitable room, or a specific one), weekly recurring meetings, cancellation, and a query for free rooms. Many people book at once. Then compare.
Reveal the answer
Strong answer: Interval as a half-open [start, end) value object with overlaps; RoomCalendar per room with a TreeMap from start to booking, checked with floorEntry and ceilingEntry in O(log n); a RoomSelectionStrategy (smallest room that fits); SchedulerService that tries candidates in order and books each atomically under that room's lock; recurring meetings checked completely, then inserted, under the same lock; cancellation; and a clear note that "free rooms" is a snapshot that a later booking may still lose.
P14.1Requirements
| Question | Assumed answer |
|---|---|
| Rooms? | ID and capacity; fixed set |
| Booking? | Any room that fits the attendees, or a named room |
| Boundaries? | A meeting ending at 11:00 and one starting at 11:00 do not clash |
| Recurring? | Weekly for N weeks; all occurrences or none |
| Queries? | Free rooms for a time range and head count; a room's bookings |
| Concurrency? | Many people book at the same time |
| Out of scope | Invitations and responses, time zones per user, room features (follow-ups) |
Invariants: bookings in one room never overlap; a recurring series is either fully booked or not at all; a cancelled booking frees its slot.
P14.2Entities and responsibilities
| Class | Responsibility |
|---|---|
Interval (record) |
Validated [start, end); overlaps; shifting for recurrences |
Room (record) |
ID and capacity |
Booking (record) |
Room, organizer, title, interval, and series ID for recurring ones |
RoomCalendar |
Sorted bookings of one room; isFree in O(log n); its lock |
RoomSelectionStrategy |
Order in which rooms are tried for a head count |
SchedulerService |
Book any room, book a room, book weekly, cancel, free rooms |
P14.3Class diagram
P14.4Key flows
P14.5Code
/** Half-open [start, end): a meeting ending at 11:00 does not clash with one starting at 11:00. */
record Interval(LocalDateTime start, LocalDateTime end) {
Interval {
if (!start.isBefore(end)) throw new IllegalArgumentException("start must be before end");
}
boolean overlaps(Interval o) { return start.isBefore(o.end) && o.start.isBefore(end); }
Interval plus(Duration d) { return new Interval(start.plus(d), end.plus(d)); }
}
/** One room's bookings, sorted by start. Callers hold `lock` while checking and changing. */
class RoomCalendar {
final Room room;
final ReentrantLock lock = new ReentrantLock();
private final TreeMap<java.time.LocalDateTime, Booking> byStart = new TreeMap<>();
RoomCalendar(Room room) { this.room = room; }
/** O(log n): only the booking starting just before and the one starting just after can overlap. */
boolean isFree(Interval i) {
Map.Entry<java.time.LocalDateTime, Booking> before = byStart.floorEntry(i.start());
if (before != null && before.getValue().interval().overlaps(i)) return false;
Map.Entry<java.time.LocalDateTime, Booking> after = byStart.ceilingEntry(i.start());
return after == null || !after.getValue().interval().overlaps(i);
}
void add(Booking b) {
if (!isFree(b.interval())) throw new IllegalStateException("overlap in " + room.id()); // a caller bug
byStart.put(b.interval().start(), b);
}
void remove(Booking b) { byStart.remove(b.interval().start(), b); }
List<Booking> all() { return new ArrayList<>(byStart.values()); }
}
class SchedulerService {
private final Map<String, RoomCalendar> calendars = new LinkedHashMap<>(); // fixed after construction
private final Map<String, Booking> bookings = new ConcurrentHashMap<>();
private final RoomSelectionStrategy selection;
private final AtomicLong ids = new AtomicLong(1), seriesIds = new AtomicLong(1);
SchedulerService(List<Room> rooms, RoomSelectionStrategy selection) {
rooms.forEach(r -> calendars.put(r.id(), new RoomCalendar(r)));
this.selection = selection;
}
/** Any suitable room: try candidates in strategy order; each try is atomic for that room. */
Booking book(String organizer, String title, int attendees, Interval when) {
List<Room> rooms = calendars.values().stream().map(c -> c.room).toList();
for (Room r : selection.candidates(rooms, attendees)) {
Optional<Booking> b = tryBook(r.id(), organizer, title, when, null);
if (b.isPresent()) return b.get();
}
throw new SchedulingException("no room for " + attendees + " people at " + when.start().toLocalTime());
}
/** A specific room, or a clear error. */
Booking bookRoom(String roomId, String organizer, String title, Interval when) {
return tryBook(roomId, organizer, title, when, null)
.orElseThrow(() -> new SchedulingException(roomId + " is busy at " + when.start().toLocalTime()));
}
/** Every occurrence or none: all checks and inserts happen under the room's lock. */
List<Booking> bookWeekly(String roomId, String organizer, String title, Interval first, int weeks) {
RoomCalendar cal = calendar(roomId);
List<Interval> occurrences = new ArrayList<>();
for (int w = 0; w < weeks; w++) occurrences.add(first.plus(Duration.ofDays(7L * w)));
String series = "S" + seriesIds.getAndIncrement();
cal.lock.lock();
try {
for (Interval i : occurrences)
if (!cal.isFree(i)) throw new SchedulingException(roomId + " is busy on " + i.start().toLocalDate() + "; nothing booked");
List<Booking> out = new ArrayList<>();
for (Interval i : occurrences) out.add(insert(cal, organizer, title, i, series));
return out;
} finally {
cal.lock.unlock();
}
}
void cancel(String bookingId) {
Booking b = bookings.remove(bookingId);
if (b == null) throw new SchedulingException("no booking " + bookingId);
RoomCalendar cal = calendar(b.roomId());
cal.lock.lock();
try { cal.remove(b); } finally { cal.lock.unlock(); }
}
/** Rooms free for the whole interval with enough seats. A snapshot: booking may still lose a race. */
List<String> freeRooms(Interval when, int attendees) {
List<String> out = new ArrayList<>();
for (RoomCalendar cal : calendars.values()) {
if (cal.room.capacity() < attendees) continue;
cal.lock.lock();
try { if (cal.isFree(when)) out.add(cal.room.id()); } finally { cal.lock.unlock(); }
}
return out;
}
List<Booking> bookingsFor(String roomId) {
RoomCalendar cal = calendar(roomId);
cal.lock.lock();
try { return cal.all(); } finally { cal.lock.unlock(); }
}
private Optional<Booking> tryBook(String roomId, String organizer, String title, Interval when, String series) {
RoomCalendar cal = calendar(roomId);
cal.lock.lock();
try {
if (!cal.isFree(when)) return Optional.empty();
return Optional.of(insert(cal, organizer, title, when, series));
} finally {
cal.lock.unlock();
}
}
private Booking insert(RoomCalendar cal, String organizer, String title, Interval when, String series) {
Booking b = new Booking("B" + ids.getAndIncrement(), cal.room.id(), organizer, title, when, series);
cal.add(b);
bookings.put(b.id(), b);
return b;
}
private RoomCalendar calendar(String roomId) {
RoomCalendar c = calendars.get(roomId);
if (c == null) throw new SchedulingException("no room " + roomId);
return c;
}
}
Go deeperThe remaining files
record Room(String id, int capacity) {
Room {
if (capacity <= 0) throw new IllegalArgumentException("capacity must be positive");
}
}
record Booking(String id, String roomId, String organizer, String title, Interval interval, String seriesId) {}
import java.util.List;
/** Orders the rooms worth trying for a meeting; the service does the atomic booking. */
interface RoomSelectionStrategy {
List<Room> candidates(List<Room> rooms, int attendees);
}
import java.util.Comparator;
import java.util.List;
/** Big rooms stay free for big meetings: try the smallest room that fits first. */
class SmallestFittingRoom implements RoomSelectionStrategy {
public List<Room> candidates(List<Room> rooms, int attendees) {
return rooms.stream()
.filter(r -> r.capacity() >= attendees)
.sorted(Comparator.comparingInt(Room::capacity).thenComparing(Room::id))
.toList();
}
}
class SchedulingException extends RuntimeException {
SchedulingException(String message) { super(message); }
}
SchedulerService s = new SchedulerService(
List.of(new Room("R1", 4), new Room("R2", 8), new Room("R3", 20)), new SmallestFittingRoom());
Booking a = s.book("asha", "design review", 6, at(0, 10, 0, 11, 0)); // R1 too small: R2
Booking b = s.book("ravi", "sprint plan", 6, at(0, 10, 30, 11, 30)); // R2 busy: R3
Booking c = s.book("meera", "1:1", 2, at(0, 11, 0, 12, 0)); // R1, touching is fine
System.out.println(a.roomId() + " " + b.roomId() + " " + c.roomId());
try { s.bookRoom("R2", "kabir", "sync", at(0, 10, 45, 11, 15)); }
catch (SchedulingException e) { System.out.println("rejected: " + e.getMessage()); }
List<Booking> standups = s.bookWeekly("R1", "team", "standup", at(0, 9, 0, 9, 15), 4);
System.out.println("weekly standup: " + standups.size() + " occurrences, series " + standups.get(0).seriesId());
try { s.bookWeekly("R1", "team", "retro", at(0, 11, 30, 12, 30), 3); } // week 1 clashes with the 1:1
catch (SchedulingException e) { System.out.println("rejected: " + e.getMessage()); }
System.out.println("R1 bookings: " + s.bookingsFor("R1").size() + " (the retro added none)");
System.out.println("free for 3 people at 10:15: " + s.freeRooms(at(0, 10, 15, 10, 45), 3));
s.cancel(a.id());
System.out.println("after cancelling " + a.id() + ": " + s.freeRooms(at(0, 10, 15, 10, 45), 3));
Code that ranDemo.java: the complete code behind the output below
public class Demo {
static final LocalDateTime MON = LocalDateTime.of(2026, 10, 26, 0, 0);
static Interval at(int day, int h1, int m1, int h2, int m2) {
return new Interval(MON.plusDays(day).withHour(h1).withMinute(m1), MON.plusDays(day).withHour(h2).withMinute(m2));
}
public static void main(String[] args) throws Exception {
SchedulerService s = new SchedulerService(
List.of(new Room("R1", 4), new Room("R2", 8), new Room("R3", 20)), new SmallestFittingRoom());
Booking a = s.book("asha", "design review", 6, at(0, 10, 0, 11, 0)); // R1 too small: R2
Booking b = s.book("ravi", "sprint plan", 6, at(0, 10, 30, 11, 30)); // R2 busy: R3
Booking c = s.book("meera", "1:1", 2, at(0, 11, 0, 12, 0)); // R1, touching is fine
System.out.println(a.roomId() + " " + b.roomId() + " " + c.roomId());
try { s.bookRoom("R2", "kabir", "sync", at(0, 10, 45, 11, 15)); }
catch (SchedulingException e) { System.out.println("rejected: " + e.getMessage()); }
List<Booking> standups = s.bookWeekly("R1", "team", "standup", at(0, 9, 0, 9, 15), 4);
System.out.println("weekly standup: " + standups.size() + " occurrences, series " + standups.get(0).seriesId());
try { s.bookWeekly("R1", "team", "retro", at(0, 11, 30, 12, 30), 3); } // week 1 clashes with the 1:1
catch (SchedulingException e) { System.out.println("rejected: " + e.getMessage()); }
System.out.println("R1 bookings: " + s.bookingsFor("R1").size() + " (the retro added none)");
System.out.println("free for 3 people at 10:15: " + s.freeRooms(at(0, 10, 15, 10, 45), 3));
s.cancel(a.id());
System.out.println("after cancelling " + a.id() + ": " + s.freeRooms(at(0, 10, 15, 10, 45), 3));
check(a.roomId().equals("R2") && b.roomId().equals("R3") && c.roomId().equals("R1"), "rooms");
check(s.bookingsFor("R1").size() == 5, "all or nothing");
// 16 threads, 2,000 random 30-60 minute requests for R2 on one day: no two bookings overlap
SchedulerService busy = new SchedulerService(List.of(new Room("R2", 8)), new SmallestFittingRoom());
AtomicInteger booked = new AtomicInteger();
ExecutorService pool = Executors.newFixedThreadPool(16);
for (int t = 0; t < 16; t++) pool.submit(() -> {
ThreadLocalRandom r = ThreadLocalRandom.current();
for (int i = 0; i < 125; i++) {
int startMin = 8 * 60 + r.nextInt(10 * 60);
LocalDateTime st = MON.plusMinutes(startMin);
try { busy.bookRoom("R2", "u", "m", new Interval(st, st.plusMinutes(30 + r.nextInt(31)))); booked.incrementAndGet(); }
catch (SchedulingException ignored) { }
}
});
pool.shutdown();
check(pool.awaitTermination(30, TimeUnit.SECONDS), "finished");
List<Booking> all = busy.bookingsFor("R2");
for (int i = 1; i < all.size(); i++)
check(!all.get(i - 1).interval().overlaps(all.get(i).interval()), "overlap");
System.out.println("2,000 concurrent requests for one room: every accepted booking is free of overlaps");
check(booked.get() == all.size(), "count");
}
static void check(boolean ok, String what) { if (!ok) throw new AssertionError(what); }
}
R2 R3 R1
rejected: R2 is busy at 10:45
weekly standup: 4 occurrences, series S1
rejected: R1 is busy on 2026-10-26; nothing booked
R1 bookings: 5 (the retro added none)
free for 3 people at 10:15: [R1]
after cancelling B1: [R1, R2]
2,000 concurrent requests for one room: every accepted booking is free of overlaps
P14.6Concurrency
| Race | Defense |
|---|---|
| Two people book overlapping slots in one room | Check and insert under that room's lock; the second sees the first's booking |
| Auto-pick races for the last free room | Each candidate is tried atomically; a loser moves to the next candidate, like parking spots in P1 |
| A series half-booked by a concurrent request | The series checks every date and inserts every date under one hold of the room's lock |
| "Free rooms" answer goes stale | It is a snapshot; booking re-checks under the lock and may still fail, which the UI must handle |
Locks are per room, so bookings in different rooms never wait for each other. A booking never needs two rooms' locks at once, so there is no lock ordering to get wrong; if a meeting ever needs two rooms together, take their locks in room-ID order (Module 9).
In a database, the same rule is an exclusion constraint. PostgreSQL can enforce "no two bookings in one room overlap" directly: EXCLUDE USING gist (room_id WITH =, tsrange(start_at, end_at) WITH &&) (the btree_gist extension lets the equality on room ID sit in the same index). Two concurrent inserts that overlap cannot both commit, which is the database doing what the room lock does here.
P14.7Deep dives
Guided designExtensions interviewers ask forAnswer each before revealing
- 1Find the earliest common free slot
Five people and a room must all be free for 30 minutes this week.
Write your answer, then reveal
Merge everyone's busy intervals (sort by start, merge overlaps), including the room's bookings, then walk the gaps between merged intervals within working hours and take the first gap of at least 30 minutes. O(k log k) for k busy intervals.
- 2Time zones and daylight saving
The series is "every Monday at 9:00 in Bengaluru" and some attendees are in London.
Write your answer, then reveal
Store each occurrence as a zoned time in the organizer's zone (
ZonedDateTimewith the zone ID), generate recurrences in that zone so "9:00 local" stays 9:00 across daylight-saving changes, and convert to each viewer's zone only for display. Adding 7 x 24 hours to an instant drifts by an hour when the clocks change. - 3Cancel one occurrence, or the rest of a series
How do you model exceptions to a recurring rule?
Write your answer, then reveal
Each occurrence is a booking with the series ID, so cancelling one is cancelling that booking. "This and following" cancels occurrences with the series ID and a start at or after the chosen one. Calendar products store the rule (RRULE) plus exceptions instead of expanding forever; expanding a bounded horizon (say a year) is the simple interview answer.
- 4Room features
Some meetings need a video unit or a whiteboard.
Write your answer, then reveal
Give
Rooma set of features and filter candidates by the requested features in the selection strategy. A different strategy can prefer the room closest to the organizer's floor. Selection stays pluggable; the booking logic does not change.
P14.8Machine coding plan
| Minutes | Do |
|---|---|
| 0-8 | Clarify boundaries, recurrence, queries; write the invariants |
| 8-20 | Interval with tests for overlap edges (touching, nested, identical) |
| 20-40 | RoomCalendar with TreeMap, isFree, add, remove |
| 40-60 | SchedulerService: book any room with the strategy, book a room, cancel |
| 60-75 | Weekly series all or nothing; free rooms query |
| 75-90 | Concurrency test on one room; walk through time zones and the database constraint |
P14.9What a strong answer shows
- SDE-2 bar: half-open intervals; O(log n) overlap check with a sorted map; per-room atomic booking; selection strategy; cancellation.
- Above the bar: all-or-nothing series; snapshot vs booking explained; time-zone-correct recurrence; the earliest common slot; the database exclusion constraint.
- Why are intervals half-open?
Reveal
So back-to-back meetings (10:00-11:00 and 11:00-12:00) do not clash, and the overlap test stays one rule:
a.start < b.end && b.start < a.end. - Why check only the floor and ceiling entries?
Reveal
Bookings in one room never overlap, so sorted by start they are also sorted by end. Any booking that starts before the floor ends before the floor starts, and any booking after the ceiling starts after it, so neither can reach the new interval.
- Why a lock per room rather than per service?
Reveal
A booking only touches one room's calendar, so different rooms are independent. A service-wide lock would make every booking in the building wait for every other.
- Why does "smallest room that fits" matter?
Reveal
Without it, a two-person meeting can take the 20-seat room and leave a large meeting with nowhere to go. A selection strategy encodes that policy and can be swapped.
Problem 15: Food delivery order lifecycle
Swiggy and Zomato in LLD form. The heart is an order state machine where every move names the actor allowed to make it (restaurant, customer, or the assigned partner), with cancellation refunds that depend on how far the order got. Around it: a bill frozen at placement, partner assignment that never gives one partner two orders, observers that update every app, and the race between "restaurant accepts" and "customer cancels".
Design the order flow of a food delivery app: place an order, the restaurant accepts or rejects, prepares, marks ready; a delivery partner is assigned, picks up, delivers; the customer can cancel for a refund that depends on timing; every party sees status changes. Then compare.
Reveal the answer
Strong answer: Order with a status changed only through one move(to, actor, actorId) that checks a transition table naming the actor for each move; a Bill computed once at placement with prices captured in the order items; a CancellationPolicy strategy for refunds by status; partner assignment by an AssignmentStrategy with an atomic claim on each partner, and a waiting queue when none is free; listeners notified after each move; and an explanation of the accept-versus-cancel race and how the order's lock decides it.
P15.1Requirements
| Question | Assumed answer |
|---|---|
| Who acts? | Customer places and cancels; restaurant accepts, rejects, prepares, marks ready; the assigned partner picks up and delivers |
| Cancellation? | Full refund before the restaurant accepts, half after; none once cooking starts |
| Bill? | Items plus 5% GST; delivery free from 499 rupees, else 30 rupees |
| Partner assignment? | When the restaurant accepts, the nearest free partner; if none, wait for the next free one |
| Notifications? | Customer, restaurant, and partner apps see every status change |
| Out of scope | Payments, ratings, live tracking, batching two orders per partner (follow-ups) |
Invariants: status changes only along the table, by the actor the table names; only the assigned partner can pick up and deliver; a partner holds at most one live order; prices on an order never change after placement; a refund is computed once, from the status the order was cancelled from.
P15.2Entities and responsibilities
| Class | Responsibility |
|---|---|
OrderStatus (enum) |
States, plus the table of moves and the actor allowed to make each |
Order |
Items, bill, status, assigned partner; move and assign under its lock |
OrderItem, Bill (records) |
Captured prices; the bill computed once |
DeliveryPartner |
Location and an atomic free flag; tryClaim, release |
AssignmentStrategy |
Orders free partners by preference (nearest first) |
CancellationPolicy |
Refund for a cancellation from a given status |
OrderListener |
Customer, restaurant, and partner apps; notified after each move |
OrderService |
Place, accept, advance, cancel; assignment and the waiting queue |
P15.3Class diagram
P15.4Key flows
P15.5Code
enum OrderStatus {
PLACED, ACCEPTED, REJECTED, PREPARING, READY, PICKED_UP, DELIVERED, CANCELLED;
/** from -> (to -> the only actor allowed to make that move). The state diagram as data. */
private static final Map<OrderStatus, Map<OrderStatus, Actor>> MOVES = new EnumMap<>(OrderStatus.class);
private static void allow(OrderStatus from, OrderStatus to, Actor who) {
MOVES.computeIfAbsent(from, k -> new EnumMap<>(OrderStatus.class)).put(to, who);
}
static {
allow(PLACED, ACCEPTED, Actor.RESTAURANT);
allow(PLACED, REJECTED, Actor.RESTAURANT);
allow(PLACED, CANCELLED, Actor.CUSTOMER);
allow(ACCEPTED, PREPARING, Actor.RESTAURANT);
allow(ACCEPTED, CANCELLED, Actor.CUSTOMER);
allow(PREPARING, READY, Actor.RESTAURANT);
allow(READY, PICKED_UP, Actor.PARTNER);
allow(PICKED_UP, DELIVERED, Actor.PARTNER);
}
/** The actor allowed to move from this status to `to`, or null if the move does not exist. */
Actor actorFor(OrderStatus to) { return MOVES.getOrDefault(this, Map.of()).get(to); }
}
class Order {
final String id, customerId, restaurantId;
final List<OrderItem> items;
final Bill bill;
private OrderStatus status = OrderStatus.PLACED; // guarded by this
private String partnerId; // guarded by this
Order(String id, String customerId, String restaurantId, List<OrderItem> items) {
if (items.isEmpty()) throw new IllegalArgumentException("empty order");
this.id = id; this.customerId = customerId; this.restaurantId = restaurantId;
this.items = List.copyOf(items);
this.bill = Bill.of(this.items);
}
/** One synchronized check-and-set: the move must exist, the actor must own it, and the partner must match. */
synchronized OrderEvent move(OrderStatus to, Actor actor, String actorId) {
Actor allowed = status.actorFor(to);
if (allowed == null) throw new IllegalStateException("order " + id + " cannot go from " + status + " to " + to);
if (allowed != actor) throw new IllegalStateException(actor + " cannot move order " + id + " to " + to);
if (actor == Actor.PARTNER && !actorId.equals(partnerId)) throw new IllegalStateException(actorId + " is not assigned to " + id);
OrderStatus from = status;
status = to;
return new OrderEvent(id, from, to, actor + " " + actorId);
}
/** False if the order no longer needs a partner (cancelled meanwhile, or already has one). */
synchronized boolean assign(String partnerId) {
if (status != OrderStatus.ACCEPTED || this.partnerId != null) return false;
this.partnerId = partnerId;
return true;
}
synchronized OrderStatus status() { return status; }
synchronized String partnerId() { return partnerId; }
}
class OrderService {
private final Map<String, Order> orders = new ConcurrentHashMap<>();
private final Map<String, Location> restaurants = new ConcurrentHashMap<>();
private final List<DeliveryPartner> partners = new CopyOnWriteArrayList<>();
private final Queue<String> waitingForPartner = new ConcurrentLinkedQueue<>();
private final List<OrderListener> listeners = new CopyOnWriteArrayList<>();
private final AssignmentStrategy assignment;
private final CancellationPolicy cancellation;
private final AtomicLong ids = new AtomicLong(1);
OrderService(AssignmentStrategy assignment, CancellationPolicy cancellation) {
this.assignment = assignment;
this.cancellation = cancellation;
}
void addRestaurant(String id, Location at) { restaurants.put(id, at); }
void addPartner(DeliveryPartner p) { partners.add(p); }
void subscribe(OrderListener l) { listeners.add(l); }
Order place(String customerId, String restaurantId, List<OrderItem> items) {
if (!restaurants.containsKey(restaurantId)) throw new IllegalArgumentException("no restaurant " + restaurantId);
Order o = new Order("O" + ids.getAndIncrement(), customerId, restaurantId, items);
orders.put(o.id, o);
publish(new OrderEvent(o.id, null, OrderStatus.PLACED, "total " + o.bill.totalPaise()));
return o;
}
/** The restaurant accepts; a partner is assigned now so they arrive while the food is cooked. */
void accept(String orderId, String restaurantId) {
Order o = order(orderId);
if (!o.restaurantId.equals(restaurantId)) throw new IllegalStateException(restaurantId + " does not own " + orderId);
publish(o.move(OrderStatus.ACCEPTED, Actor.RESTAURANT, restaurantId));
if (!tryAssign(o)) waitingForPartner.add(o.id); // assigned later, when a partner frees up
}
void advance(String orderId, OrderStatus to, Actor actor, String actorId) {
Order o = order(orderId);
publish(o.move(to, actor, actorId));
if (to == OrderStatus.DELIVERED) partnerFree(partner(actorId));
}
/** Returns the refund; the same move check stops a cancel that races with acceptance or cooking. */
long cancel(String orderId, String customerId) {
Order o = order(orderId);
if (!o.customerId.equals(customerId)) throw new IllegalStateException(customerId + " does not own " + orderId);
OrderEvent e = o.move(OrderStatus.CANCELLED, Actor.CUSTOMER, customerId);
long refund = cancellation.refundPaise(e.from(), o.bill);
String p = o.partnerId();
if (p != null) partnerFree(partner(p));
publish(new OrderEvent(e.orderId(), e.from(), e.to(), "refund " + refund));
return refund;
}
/** True when the order is settled: it got a partner, or it was cancelled and needs none. */
private boolean tryAssign(Order o) {
for (DeliveryPartner p : assignment.candidates(partners, restaurants.get(o.restaurantId))) {
if (!p.tryClaim()) continue; // atomic: no partner gets two orders
if (o.assign(p.id)) {
publish(new OrderEvent(o.id, OrderStatus.ACCEPTED, OrderStatus.ACCEPTED, "partner " + p.id));
} else {
p.release(); // cancelled meanwhile: give the partner back
}
return true;
}
return false;
}
private void partnerFree(DeliveryPartner p) {
p.release();
for (String id; (id = waitingForPartner.poll()) != null; ) { // hand the partner to a waiting order
Order w = orders.get(id);
if (w.status() != OrderStatus.ACCEPTED) continue; // cancelled while waiting
if (!tryAssign(w)) waitingForPartner.add(id); // someone else took the partner first
return;
}
}
private void publish(OrderEvent e) { // called outside order locks
for (OrderListener l : listeners) {
try { l.onEvent(e); } catch (RuntimeException ignored) { /* a failing listener never blocks an order */ }
}
}
Order order(String id) {
Order o = orders.get(id);
if (o == null) throw new IllegalArgumentException("no order " + id);
return o;
}
private DeliveryPartner partner(String id) {
return partners.stream().filter(p -> p.id.equals(id)).findFirst()
.orElseThrow(() -> new IllegalArgumentException("no partner " + id));
}
int waiting() { return waitingForPartner.size(); }
}
class DeliveryPartner {
final String id;
volatile Location location;
private final AtomicBoolean free = new AtomicBoolean(true);
DeliveryPartner(String id, Location location) { this.id = id; this.location = location; }
/** Compare-and-set: of many orders racing for this partner, exactly one wins. */
boolean tryClaim() { return free.compareAndSet(true, false); }
void release() { free.set(true); }
boolean isFree() { return free.get(); }
}
record Bill(long itemsPaise, long taxPaise, long deliveryFeePaise, long totalPaise) {
static final long FREE_DELIVERY_FROM = 49_900; // orders of 499 rupees or more
static Bill of(List<OrderItem> items) {
long subtotal = items.stream().mapToLong(OrderItem::totalPaise).sum();
long tax = (subtotal * 5 + 50) / 100; // 5% GST on restaurant food, rounded half up
long fee = subtotal >= FREE_DELIVERY_FROM ? 0 : 3_000;
return new Bill(subtotal, tax, fee, subtotal + tax + fee);
}
}
Go deeperThe remaining files
enum Actor { CUSTOMER, RESTAURANT, PARTNER }
/** Price captured at order time: a later menu price change does not change this order. */
record OrderItem(String name, int qty, long unitPaise) {
OrderItem {
if (qty <= 0) throw new IllegalArgumentException("quantity must be positive");
}
long totalPaise() { return unitPaise * qty; }
}
record Location(double x, double y) {
double distanceTo(Location o) { return Math.hypot(x - o.x, y - o.y); }
}
import java.util.List;
/** Orders the partners worth offering a pickup to; the service claims one atomically. */
interface AssignmentStrategy {
List<DeliveryPartner> candidates(List<DeliveryPartner> partners, Location restaurant);
}
import java.util.Comparator;
import java.util.List;
class NearestPartner implements AssignmentStrategy {
public List<DeliveryPartner> candidates(List<DeliveryPartner> partners, Location restaurant) {
return partners.stream()
.filter(DeliveryPartner::isFree)
.sorted(Comparator.comparingDouble((DeliveryPartner p) -> p.location.distanceTo(restaurant)).thenComparing(p -> p.id))
.toList();
}
}
/** How much of the bill a cancellation refunds, by the status it is cancelled from. */
interface CancellationPolicy {
long refundPaise(OrderStatus from, Bill bill);
}
class StandardCancellation implements CancellationPolicy {
public long refundPaise(OrderStatus from, Bill bill) {
return switch (from) {
case PLACED -> bill.totalPaise(); // the restaurant has not started: full refund
case ACCEPTED -> bill.totalPaise() / 2; // the restaurant committed: half
default -> throw new IllegalStateException("cannot refund a cancellation from " + from);
};
}
}
record OrderEvent(String orderId, OrderStatus from, OrderStatus to, String detail) {}
interface OrderListener {
void onEvent(OrderEvent e);
}
OrderService svc = new OrderService(new NearestPartner(), new StandardCancellation());
svc.addRestaurant("R1", new Location(0, 0));
svc.addPartner(new DeliveryPartner("D1", new Location(5, 5)));
svc.addPartner(new DeliveryPartner("D2", new Location(1, 1)));
List<String> feed = new CopyOnWriteArrayList<>();
svc.subscribe(e -> feed.add(e.orderId() + " " + (e.from() == e.to() ? "" : e.to() + " ") + "(" + e.detail() + ")"));
Order o1 = svc.place("asha", "R1", List.of(new OrderItem("biryani", 2, 25_000), new OrderItem("cola", 1, 4_000)));
svc.accept(o1.id, "R1"); // D2 is nearer: assigned
svc.advance(o1.id, OrderStatus.PREPARING, Actor.RESTAURANT, "R1");
try { svc.cancel(o1.id, "asha"); } catch (IllegalStateException e) { feed.add("rejected: " + e.getMessage()); }
svc.advance(o1.id, OrderStatus.READY, Actor.RESTAURANT, "R1");
try { svc.advance(o1.id, OrderStatus.PICKED_UP, Actor.PARTNER, "D1"); } catch (IllegalStateException e) { feed.add("rejected: " + e.getMessage()); }
svc.advance(o1.id, OrderStatus.PICKED_UP, Actor.PARTNER, "D2");
svc.advance(o1.id, OrderStatus.DELIVERED, Actor.PARTNER, "D2");
Order o2 = svc.place("ravi", "R1", List.of(new OrderItem("dosa", 1, 12_000)));
long refund = svc.cancel(o2.id, "ravi"); // before acceptance: full refund
// 200 orders, 5 partners; for each order the restaurant accepts while the customer cancels, at the same moment
OrderService race = new OrderService(new NearestPartner(), new StandardCancellation());
race.addRestaurant("R1", new Location(0, 0));
List<DeliveryPartner> fleet = new ArrayList<>();
for (int i = 0; i < 5; i++) { DeliveryPartner p = new DeliveryPartner("P" + i, new Location(i, i)); fleet.add(p); race.addPartner(p); }
List<Order> made = new ArrayList<>();
for (int i = 0; i < 200; i++) made.add(race.place("c" + i, "R1", List.of(new OrderItem("thali", 1, 20_000))));
ExecutorService pool = Executors.newFixedThreadPool(8);
CountDownLatch go = new CountDownLatch(1);
for (Order o : made) {
pool.submit(() -> { await(go); try { race.accept(o.id, "R1"); } catch (IllegalStateException e) { } });
pool.submit(() -> { await(go); try { race.cancel(o.id, o.customerId); } catch (IllegalStateException e) { } });
}
go.countDown();
pool.shutdown();
check(pool.awaitTermination(30, TimeUnit.SECONDS), "finished");
Code that ranDemo.java: the complete code behind the output below
public class Demo {
public static void main(String[] args) throws Exception {
OrderService svc = new OrderService(new NearestPartner(), new StandardCancellation());
svc.addRestaurant("R1", new Location(0, 0));
svc.addPartner(new DeliveryPartner("D1", new Location(5, 5)));
svc.addPartner(new DeliveryPartner("D2", new Location(1, 1)));
List<String> feed = new CopyOnWriteArrayList<>();
svc.subscribe(e -> feed.add(e.orderId() + " " + (e.from() == e.to() ? "" : e.to() + " ") + "(" + e.detail() + ")"));
Order o1 = svc.place("asha", "R1", List.of(new OrderItem("biryani", 2, 25_000), new OrderItem("cola", 1, 4_000)));
svc.accept(o1.id, "R1"); // D2 is nearer: assigned
svc.advance(o1.id, OrderStatus.PREPARING, Actor.RESTAURANT, "R1");
try { svc.cancel(o1.id, "asha"); } catch (IllegalStateException e) { feed.add("rejected: " + e.getMessage()); }
svc.advance(o1.id, OrderStatus.READY, Actor.RESTAURANT, "R1");
try { svc.advance(o1.id, OrderStatus.PICKED_UP, Actor.PARTNER, "D1"); } catch (IllegalStateException e) { feed.add("rejected: " + e.getMessage()); }
svc.advance(o1.id, OrderStatus.PICKED_UP, Actor.PARTNER, "D2");
svc.advance(o1.id, OrderStatus.DELIVERED, Actor.PARTNER, "D2");
Order o2 = svc.place("ravi", "R1", List.of(new OrderItem("dosa", 1, 12_000)));
long refund = svc.cancel(o2.id, "ravi"); // before acceptance: full refund
feed.forEach(System.out::println);
System.out.println("O1 bill: " + o1.bill + "; O2 refund " + refund);
check(o1.bill.totalPaise() == 56_700 && refund == o2.bill.totalPaise(), "bill and refund");
// 200 orders, 5 partners; for each order the restaurant accepts while the customer cancels, at the same moment
OrderService race = new OrderService(new NearestPartner(), new StandardCancellation());
race.addRestaurant("R1", new Location(0, 0));
List<DeliveryPartner> fleet = new ArrayList<>();
for (int i = 0; i < 5; i++) { DeliveryPartner p = new DeliveryPartner("P" + i, new Location(i, i)); fleet.add(p); race.addPartner(p); }
List<Order> made = new ArrayList<>();
for (int i = 0; i < 200; i++) made.add(race.place("c" + i, "R1", List.of(new OrderItem("thali", 1, 20_000))));
ExecutorService pool = Executors.newFixedThreadPool(8);
CountDownLatch go = new CountDownLatch(1);
for (Order o : made) {
pool.submit(() -> { await(go); try { race.accept(o.id, "R1"); } catch (IllegalStateException e) { } });
pool.submit(() -> { await(go); try { race.cancel(o.id, o.customerId); } catch (IllegalStateException e) { } });
}
go.countDown();
pool.shutdown();
check(pool.awaitTermination(30, TimeUnit.SECONDS), "finished");
Set<String> holders = ConcurrentHashMap.newKeySet();
for (Order o : made) {
check(o.status() == OrderStatus.ACCEPTED || o.status() == OrderStatus.CANCELLED, "one end state");
if (o.status() == OrderStatus.ACCEPTED && o.partnerId() != null) check(holders.add(o.partnerId()), "partner on two live orders");
}
long busy = fleet.stream().filter(p -> !p.isFree()).count();
check(busy == holders.size(), "a busy partner without a live order (leaked claim)");
System.out.println("accept vs cancel on 200 orders: each ended ACCEPTED or CANCELLED; every busy partner holds exactly one live order");
}
static void await(CountDownLatch l) { try { l.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }
static void check(boolean ok, String what) { if (!ok) throw new AssertionError(what); }
}
O1 PLACED (total 56700)
O1 ACCEPTED (RESTAURANT R1)
O1 (partner D2)
O1 PREPARING (RESTAURANT R1)
rejected: order O1 cannot go from PREPARING to CANCELLED
O1 READY (RESTAURANT R1)
rejected: D1 is not assigned to O1
O1 PICKED_UP (PARTNER D2)
O1 DELIVERED (PARTNER D2)
O2 PLACED (total 15600)
O2 CANCELLED (refund 15600)
O1 bill: Bill[itemsPaise=54000, taxPaise=2700, deliveryFeePaise=0, totalPaise=56700]; O2 refund 15600
accept vs cancel on 200 orders: each ended ACCEPTED or CANCELLED; every busy partner holds exactly one live order
P15.6Concurrency
| Race | Defense |
|---|---|
| Restaurant accepts while the customer cancels | Both go through Order.move, which is synchronized; the second sees the first's status and either fails or takes the legal path (cancel after accept, half refund) |
| Two orders claim the same partner | DeliveryPartner.tryClaim is a compare-and-set on the free flag; one wins |
| A partner claimed for an order cancelled a moment earlier | Order.assign succeeds only while the order is still ACCEPTED, under the order's lock; otherwise the partner is released at once |
| A freed partner and a waiting order | The service hands the freed partner to the oldest waiting order; if another order claims the partner first, the waiting order goes back in the queue |
| A slow or failing app notification | Listeners run after the move, outside the order's lock, each in its own try/catch |
The leak to look for in any assignment design: a resource (partner, seat, locker) claimed for an entity that changed state in between. Check the entity's state under its own lock at the moment of attaching the resource, and give the resource back if the check fails. The demo's final check, "every busy partner holds exactly one live order", exists to catch exactly that leak.
Real delivery platforms run each step as a separate service talking through events: the order service owns the state machine, a dispatch service offers the order to partners (who may decline, so assignment is a short negotiation with timeouts), and notifications are consumers of order events on a queue. The rules are the same: one owner per state, conditional updates (UPDATE orders SET status = 'ACCEPTED' WHERE id = ? AND status = 'PLACED'), and idempotent consumers.
P15.7Deep dives
Guided designExtensions interviewers ask forAnswer each before revealing
- 1A partner can decline
Assignment becomes an offer: the partner has 30 seconds to accept.
Write your answer, then reveal
Add an OFFERED step between free and assigned: claim the partner, send the offer, start a timer (P12). Accept attaches the partner to the order; decline or timeout releases the partner and offers the next candidate. The order records who declined so it is not offered to them again.
- 2Coupons and offers
"FLAT100 on orders above 399", "20% up to 120 for new users".
Write your answer, then reveal
A chain of discount rules applied to the bill at placement (Module 6): each rule checks eligibility and returns a discount; the order stores which rule applied and the amount. Rules that cannot combine are marked exclusive; the pipeline picks the best eligible one. Capture the discount in the bill so a later rule change does not alter placed orders.
- 3Restaurant runs out of an item after accepting
The kitchen cannot make one dish.
Write your answer, then reveal
A partial-fulfilment flow: the restaurant proposes a modified order, the customer accepts (bill recomputed, difference refunded) or cancels with a full refund because the failure is the restaurant's. Model it as a new state (AWAITING_CUSTOMER) with its own actor rules rather than an exception.
- 4Batch two orders for one partner
Two orders from the same restaurant to nearby customers.
Write your answer, then reveal
The partner's capacity becomes 2 instead of a boolean: an atomic counter with compare-and-set up to the limit, and the assignment strategy prefers partners already heading to the same restaurant. Pickups and deliveries become per order; the partner is released when the counter returns to zero.
P15.8Machine coding plan
| Minutes | Do |
|---|---|
| 0-10 | Clarify actors, cancellation rules, assignment timing; draw the state diagram with actors |
| 10-25 | OrderStatus with the move table; Actor; OrderItem, Bill |
| 25-45 | Order.move with checks; OrderService.place, accept, advance, cancel |
| 45-60 | DeliveryPartner, AssignmentStrategy, waiting queue; release on delivery |
| 60-75 | Listeners; the end-to-end demo; illegal moves rejected |
| 75-90 | The accept-versus-cancel race test; walk through offers and coupons |
P15.9What a strong answer shows
- SDE-2 bar: a transition table with actor checks; captured prices and a bill computed once; cancellation policy by status; atomic partner claims; listeners after the change.
- Above the bar: the cancel-during-assignment leak found and closed; a waiting queue for partners; offers with timeouts; the event-driven production version.
- Why put the actor in the transition table instead of checking roles in each method?
Reveal
One table answers both questions, "is this move legal" and "who may make it", for every move. Adding a state or changing who may cancel is a data change in one place, and no method can forget the check.
- Why does
OrderItemstore the price?Reveal
Menu prices change. The order must keep what the customer agreed to pay, so the bill and any refund are computed from captured prices, never from the live menu.
- Two threads call
acceptandcancelon the same order at the same instant. What are the possible outcomes?Reveal
Accept first: the order is ACCEPTED, then the cancel succeeds from ACCEPTED with a half refund (and frees any partner). Cancel first: the order is CANCELLED with a full refund, and accept fails because CANCELLED has no move to ACCEPTED. Never both effects mixed.
- Why notify listeners outside the order's lock?
Reveal
Notifications can be slow or fail. Holding the order's lock during them would block the next move (the partner's pickup), and a failure must not undo a move that already happened.
Problem 16: In-memory key-value store with transactions
The "in-memory database" question, popular in machine coding rounds because it grows in stages: GET and SET, then nested BEGIN, COMMIT and ROLLBACK, then TTL, then many clients at once. The design that survives every stage keeps uncommitted writes in a stack of layers per session, applies a whole transaction to the shared store in one step, and uses versions so two clients never silently overwrite each other.
Design an in-memory key-value store: get, put, delete; nested transactions with begin, commit, rollback (rollback undoes only the innermost level); keys with an optional time to live; many clients using it concurrently. Then compare.
Reveal the answer
Strong answer: a shared Store of committed entries (value, version, expiry) behind a read-write lock; a Session per client holding a stack of layers of pending writes, where a read checks layers innermost first and then the store; inner commit merges a layer into its parent, rollback drops it, and only the outermost commit reaches the store, all at once; deletes recorded as tombstones in layers; lazy expiry on read plus a periodic purge; and for concurrency, versions recorded when a transaction first touches a key and checked at commit, so the second of two conflicting commits fails and retries.
P16.1Requirements
| Question | Assumed answer |
|---|---|
| Operations? | get, put (with optional TTL), delete |
| Transactions? | begin, commit, rollback; nestable; rollback and commit act on the innermost open level |
| Visibility? | A client sees its own uncommitted writes; other clients see only committed data |
| Outside a transaction? | Each write commits immediately |
| TTL? | An expired key reads as absent; memory is reclaimed later |
| Concurrency? | Many clients; two transactions writing the same key must not both "win" silently |
| Out of scope | Persistence, replication, eviction by memory size (deep dives) |
Invariants: the store changes only by whole outermost transactions (all writes or none); an inner rollback leaves outer levels untouched; nothing uncommitted is visible to other clients; a commit fails if any key it touched changed since it first touched it.
P16.2Entities and responsibilities
| Class | Responsibility |
|---|---|
Store |
Committed entries with versions and expiry; read; atomic commit(writes, seen); purgeExpired |
Entry (record) |
Value, version, expiry time |
Session |
One client's stack of open transactions; read-your-writes; begin, commit, rollback |
Write (record) |
A pending value with TTL, or the DELETE tombstone |
ConflictException |
Another commit won; the caller retries from begin |
P16.3Class diagram
P16.4Key flows
P16.5Code
/** One client's view: a stack of open transactions over the shared store. Not shared between threads. */
class Session {
private final Store store;
private final Deque<Map<String, Write>> layers = new ArrayDeque<>(); // pending writes per BEGIN; top = innermost
private final Map<String, Long> seen = new HashMap<>(); // store version of each key the transaction touched
Session(Store store) { this.store = store; }
Optional<String> get(String key) {
for (Map<String, Write> layer : layers) { // innermost first
Write w = layer.get(key);
if (w != null) return w.isDelete() ? Optional.empty() : Optional.of(w.value());
}
remember(key);
return store.read(key).map(Entry::value);
}
void put(String key, String value) { write(key, new Write(value, 0)); }
void put(String key, String value, long ttlMillis) { write(key, new Write(value, ttlMillis)); }
void delete(String key) { write(key, Write.DELETE); }
void begin() {
if (layers.isEmpty()) seen.clear(); // a new outermost transaction
layers.push(new LinkedHashMap<>());
}
/** Inner commit merges into the parent; the outermost commit applies everything to the store at once. */
void commit() {
Map<String, Write> top = pop("COMMIT");
if (!layers.isEmpty()) {
layers.peek().putAll(top);
return;
}
try {
store.commit(top, seen); // may throw ConflictException: the work is discarded
} finally {
seen.clear();
}
}
void rollback() { // drops the innermost level only
pop("ROLLBACK");
if (layers.isEmpty()) seen.clear();
}
int depth() { return layers.size(); }
private void write(String key, Write w) {
if (layers.isEmpty()) { // no transaction: a one-write commit
store.commit(Map.of(key, w), Map.of());
return;
}
remember(key);
layers.peek().put(key, w);
}
/** The first time a transaction touches a key, record the version it saw; commit checks it is unchanged. */
private void remember(String key) {
if (!layers.isEmpty()) seen.computeIfAbsent(key, store::versionOf);
}
private Map<String, Write> pop(String what) {
if (layers.isEmpty()) throw new IllegalStateException(what + " with no open transaction");
return layers.pop();
}
}
/** Shared committed data. Many readers at once; commits are exclusive and all-or-nothing. */
class Store {
private final Map<String, Entry> data = new HashMap<>(); // guarded by rw
private final ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
private final LongSupplier clock;
private long nextVersion = 1; // guarded by the write lock
Store(LongSupplier clock) { this.clock = clock; }
/** The live entry, if any. An expired entry reads as absent (lazy expiry). */
Optional<Entry> read(String key) {
rw.readLock().lock();
try {
Entry e = data.get(key);
return e != null && e.isLive(clock.getAsLong()) ? Optional.of(e) : Optional.empty();
} finally {
rw.readLock().unlock();
}
}
/** Version a transaction saw for a key: 0 if absent or expired. */
long versionOf(String key) { return read(key).map(Entry::version).orElse(0L); }
/**
* Applies all writes, or none: if any key's version changed since the transaction first touched it,
* another commit won, and this one fails (first committer wins, so no update is lost).
*/
void commit(Map<String, Write> writes, Map<String, Long> seen) {
rw.writeLock().lock();
try {
long now = clock.getAsLong();
for (Map.Entry<String, Long> s : seen.entrySet()) {
Entry cur = data.get(s.getKey());
long v = cur != null && cur.isLive(now) ? cur.version() : 0;
if (v != s.getValue()) throw new ConflictException("key " + s.getKey() + " changed after this transaction first touched it");
}
for (Map.Entry<String, Write> w : writes.entrySet()) {
Write x = w.getValue();
if (x.isDelete()) data.remove(w.getKey());
else data.put(w.getKey(), new Entry(x.value(), nextVersion++, x.ttlMillis() > 0 ? now + x.ttlMillis() : 0));
}
} finally {
rw.writeLock().unlock();
}
}
/** Removes expired entries; run it on a timer to free memory. Reads never depend on it. */
int purgeExpired() {
rw.writeLock().lock();
try {
long now = clock.getAsLong();
int before = data.size();
data.values().removeIf(e -> !e.isLive(now));
return before - data.size();
} finally {
rw.writeLock().unlock();
}
}
int size() {
rw.readLock().lock();
try { return data.size(); } finally { rw.readLock().unlock(); }
}
}
Go deeperThe remaining files
/** A stored value. `version` grows on every committed write; expiresAt is 0 for no expiry. */
record Entry(String value, long version, long expiresAt) {
boolean isLive(long now) { return expiresAt == 0 || now < expiresAt; }
}
/** One pending change in a transaction: a value with an optional TTL, or a delete (value == null). */
record Write(String value, long ttlMillis) {
static final Write DELETE = new Write(null, 0);
boolean isDelete() { return value == null; }
}
class ConflictException extends RuntimeException {
ConflictException(String message) { super(message); }
}
Session s = new Session(store);
s.put("a", "10");
s.begin();
s.put("a", "20");
s.begin();
s.delete("a");
out.add("inner sees a = " + s.get("a").orElse("(none)"));
s.rollback(); // undo only the delete
out.add("after inner rollback a = " + s.get("a").orElse("(none)"));
out.add("another client still sees a = " + new Session(store).get("a").orElse("(none)"));
s.commit(); // outermost commit: applied to the store
out.add("after commit, another client sees a = " + new Session(store).get("a").orElse("(none)"));
s.put("otp:asha", "4821", 30_000); // expires in 30 seconds
now.addAndGet(31_000);
out.add("otp after 31 s: " + s.get("otp:asha").orElse("(expired)") + "; purged " + store.purgeExpired());
Session t1 = new Session(store), t2 = new Session(store);
t1.begin(); t2.begin();
t1.put("seat:A1", "ravi");
t2.put("seat:A1", "meera");
t1.commit();
try { t2.commit(); } catch (ConflictException e) { out.add("t2: " + e.getMessage()); }
out.add("seat:A1 = " + new Session(store).get("seat:A1").orElseThrow());
// 8 threads x 500 increments of one counter, each in a transaction retried on conflict
new Session(store).put("hits", "0");
AtomicInteger retries = new AtomicInteger();
ExecutorService pool = Executors.newFixedThreadPool(8);
for (int t = 0; t < 8; t++) pool.submit(() -> {
Session c = new Session(store);
for (int i = 0; i < 500; i++) {
while (true) {
c.begin();
int v = Integer.parseInt(c.get("hits").orElse("0"));
c.put("hits", String.valueOf(v + 1));
try { c.commit(); break; } catch (ConflictException e) { retries.incrementAndGet(); }
}
}
});
pool.shutdown();
check(pool.awaitTermination(60, TimeUnit.SECONDS), "finished");
Code that ranDemo.java: the complete code behind the output below
public class Demo {
public static void main(String[] args) throws Exception {
AtomicLong now = new AtomicLong(1_000); // manual clock, in milliseconds
Store store = new Store(now::get);
List<String> out = new ArrayList<>();
Session s = new Session(store);
s.put("a", "10");
s.begin();
s.put("a", "20");
s.begin();
s.delete("a");
out.add("inner sees a = " + s.get("a").orElse("(none)"));
s.rollback(); // undo only the delete
out.add("after inner rollback a = " + s.get("a").orElse("(none)"));
out.add("another client still sees a = " + new Session(store).get("a").orElse("(none)"));
s.commit(); // outermost commit: applied to the store
out.add("after commit, another client sees a = " + new Session(store).get("a").orElse("(none)"));
s.put("otp:asha", "4821", 30_000); // expires in 30 seconds
now.addAndGet(31_000);
out.add("otp after 31 s: " + s.get("otp:asha").orElse("(expired)") + "; purged " + store.purgeExpired());
Session t1 = new Session(store), t2 = new Session(store);
t1.begin(); t2.begin();
t1.put("seat:A1", "ravi");
t2.put("seat:A1", "meera");
t1.commit();
try { t2.commit(); } catch (ConflictException e) { out.add("t2: " + e.getMessage()); }
out.add("seat:A1 = " + new Session(store).get("seat:A1").orElseThrow());
out.forEach(System.out::println);
check(new Session(store).get("a").orElseThrow().equals("20"), "commit");
// 8 threads x 500 increments of one counter, each in a transaction retried on conflict
new Session(store).put("hits", "0");
AtomicInteger retries = new AtomicInteger();
ExecutorService pool = Executors.newFixedThreadPool(8);
for (int t = 0; t < 8; t++) pool.submit(() -> {
Session c = new Session(store);
for (int i = 0; i < 500; i++) {
while (true) {
c.begin();
int v = Integer.parseInt(c.get("hits").orElse("0"));
c.put("hits", String.valueOf(v + 1));
try { c.commit(); break; } catch (ConflictException e) { retries.incrementAndGet(); }
}
}
});
pool.shutdown();
check(pool.awaitTermination(60, TimeUnit.SECONDS), "finished");
String hits = new Session(store).get("hits").orElseThrow();
System.out.println("4,000 transactional increments from 8 threads: hits = " + hits + " (conflicts retried, none lost)");
check(hits.equals("4000"), "no lost update");
}
static void check(boolean ok, String what) { if (!ok) throw new AssertionError(what); }
}
inner sees a = (none)
after inner rollback a = 20
another client still sees a = 10
after commit, another client sees a = 20
otp after 31 s: (expired); purged 1
t2: key seat:A1 changed after this transaction first touched it
seat:A1 = ravi
4,000 transactional increments from 8 threads: hits = 4000 (conflicts retried, none lost)
Why a stack of write layers and not an undo log? Both work for one client. An undo log writes into the shared store at once and undoes on rollback, so other clients would see uncommitted values (a dirty read). Layers keep uncommitted writes private until the outermost commit, which is what "other clients see only committed data" requires. The undo-log version (Module 7) is the right answer only when the prompt has a single client.
P16.6Concurrency
| Race | Defense |
|---|---|
| A reader sees half of a commit | commit holds the write lock while applying every write; readers take the read lock, so they see all of it or none |
| Two transactions write the same key | Each records the key's version on first touch; commit checks every recorded version under the write lock; the second commit gets ConflictException |
| Read X, write Y, while another commit changes X | Reads are recorded too, so the commit fails; this prevents decisions made on stale data (write skew) |
| Two threads share one session | Not supported: a session is one client's state, like a database connection; give each thread its own |
| Purge runs during reads | Purge takes the write lock; reads never depend on purge because they check expiry themselves |
The classic bug in this problem: incr written as get then put in two steps, with no version check. Eight threads doing 500 increments each end with fewer than 4,000 because two threads read the same value and both write value + 1. The demo's counter is the test for exactly that, and it passes only because conflicting commits fail and retry.
This is optimistic concurrency control, the scheme behind Redis WATCH / MULTI / EXEC (a watched key changed, so EXEC returns nil and the client retries) and behind conditional writes in DynamoDB. Databases such as PostgreSQL use multiversion concurrency control instead: each transaction reads from a snapshot taken at its start, and conflicting writes to one row are detected at write time.
P16.7Deep dives
Guided designExtensions interviewers ask forAnswer each before revealing
- 1COUNT(value): how many keys hold this value
The classic follow-up, and it must stay fast inside transactions.
Write your answer, then reveal
Keep a reverse index
value -> countin the store, updated incommitfor each write (decrement the old value, increment the new). Inside a transaction, the count must include pending writes: compute it as the store's count plus a delta from the layers (for each key the layers change, subtract its committed value's count and add its pending value's). Expired entries complicate it: either purge before counting or count only live entries, and say which. - 2Snapshot reads
Reads here see the latest committed value, so a long transaction can read X before a commit and Y after it.
Write your answer, then reveal
The commit check catches it (X's version changed), so committed results are still correct, but the transaction may compute with an inconsistent view before failing. Snapshot reads fix that: keep a few versions per key and have each transaction read the newest version not newer than its start version. That is multiversion concurrency control; old versions are garbage-collected once no open transaction can read them.
- 3Durability
The process restarts and data must survive.
Write your answer, then reveal
A write-ahead log: on each outermost commit, append the transaction's writes as one record and flush before acknowledging, then apply to memory. On restart, replay the log. Periodic snapshots bound replay time; the log is truncated after a snapshot. Redis offers both (AOF and RDB).
- 4Active expiry
Millions of keys expire and are never read again.
Write your answer, then reveal
Lazy expiry alone leaks memory. Add a sweeper: a
DelayQueueor a min-heap by expiry time, polled by a background thread that removes due keys under the write lock (checking the entry is still the same version, since the key may have been rewritten). Redis samples random keys with a TTL several times a second instead of keeping a heap.
P16.8Machine coding plan
| Minutes | Do |
|---|---|
| 0-8 | Clarify nesting rules, visibility, TTL, concurrency; write the invariants |
| 8-25 | Store with get / put / delete; a command driver reading lines like SET a 10 |
| 25-50 | Session layers: begin, nested rollback, commit merging; tombstones for deletes |
| 50-65 | TTL with an injected clock; lazy expiry; purge |
| 65-80 | Versions and the commit check; the concurrent counter test |
| 80-90 | Walk through COUNT, snapshots, and the write-ahead log |
P16.9What a strong answer shows
- SDE-2 bar: layered pending writes with read-your-writes; tombstones; nested rollback and commit; outermost commit applied atomically; lazy TTL with an injected clock.
- Above the bar: versions checked at commit with a retry loop; why layers beat an undo log with many clients; COUNT with transaction deltas; snapshot reads and the write-ahead log.
- Why does a delete inside a transaction need a tombstone instead of removing the key from the layer?
Reveal
Removing it from the layer would make the read fall through to the store and return the committed value, so the delete would be invisible. The tombstone says "absent" and stops the search at that layer.
- An inner transaction commits and then the outer one rolls back. What does the store contain?
Reveal
Nothing from either. An inner commit only merges into its parent layer; the outer rollback drops that layer with everything merged into it.
- Why inject the clock?
Reveal
TTL tests must not sleep. With a clock passed in, the test moves time forward 31 seconds instantly and checks the key reads as absent.
- Why a read-write lock rather than a ConcurrentHashMap?
Reveal
A commit changes several keys and must appear all at once. ConcurrentHashMap makes each key's update atomic, not a group of them; the write lock makes the whole commit one step for every reader.
Problem 17: Ride-hailing matching
Uber and Ola in LLD form. Three ideas carry it: a spatial index so "drivers near me" does not scan the whole city, an atomic claim on the driver so two riders never get the same car, and a price locked in when the ride is booked. Everything else (matching rule, surge rule, fare formula) is a strategy you can swap.
Design the core of a ride-hailing app: drivers go online and send locations; a rider requests a ride of a vehicle type; the system assigns the nearest available driver within a radius; the driver starts and completes the trip and a fare is computed with surge pricing; the rider can cancel before pickup. Thousands of requests arrive at once. Then compare.
Reveal the answer
Strong answer: a GridIndex of cells to drivers, so a nearby search scans only the cells a radius can touch; Driver with an atomic status (OFFLINE, AVAILABLE, ON_TRIP) and a compare-and-set tryReserve; a RideService that ranks candidates with a MatchingStrategy and reserves the first one still free, moving on when one is taken; one active ride per rider via putIfAbsent; a Trip state machine (ASSIGNED, STARTED, COMPLETED, CANCELLED) with driver and rider checks; surge computed at request time and stored on the trip; PricingStrategy for the fare; and release of the driver on both end states.
P17.1Requirements
| Question | Assumed answer |
|---|---|
| Drivers? | Go online and offline; send a location every few seconds; one vehicle type each |
| Matching? | Nearest available driver of the requested type within 3 km; no driver means a clear error |
| Trip lifecycle? | Assigned, started by the driver at pickup, completed by the driver with the distance driven |
| Cancellation? | Rider may cancel before pickup; the driver becomes available again |
| Pricing? | Base fare plus a per-km rate by vehicle type, times a surge multiplier fixed when the ride is booked |
| Surge? | Fewer free drivers near the pickup means a higher multiplier |
| Out of scope | Payments, ratings, driver accept/decline, routing and ETA (deep dives) |
Invariants: a driver is on at most one trip; a rider has at most one active ride; only the trip's driver starts and completes it; the surge on a trip never changes after booking; every trip that ends frees its driver exactly once.
P17.2Entities and responsibilities
| Class | Responsibility |
|---|---|
Driver |
Atomic status, latest location; tryReserve, release, goOnline, goOffline |
GridIndex |
Drivers by 1 km cell; moves a driver between cells; near(point, radius) |
Trip |
Rider, driver, surge, fare; status changed only through checked moves |
TripStatus, DriverStatus (enums) |
The two state machines |
MatchingStrategy |
Order to try candidates in (nearest first) |
SurgePolicy, PricingStrategy |
Multiplier from supply; fare from type, distance, surge |
RideService |
Request, start, complete, cancel; one active ride per rider |
P17.3Class diagram
P17.4Key flows
P17.5Code
class RideService {
private final GridIndex index = new GridIndex(1.0); // 1 km cells
private final Map<String, Driver> drivers = new ConcurrentHashMap<>();
private final Map<String, Trip> trips = new ConcurrentHashMap<>();
private final Map<String, String> activeTripOfRider = new ConcurrentHashMap<>();
private final AtomicLong tripSeq = new AtomicLong();
private final MatchingStrategy matching;
private final SurgePolicy surge;
private final PricingStrategy pricing;
private final double radiusKm;
RideService(MatchingStrategy matching, SurgePolicy surge, PricingStrategy pricing, double radiusKm) {
this.matching = matching; this.surge = surge; this.pricing = pricing; this.radiusKm = radiusKm;
}
void addDriver(Driver d) { drivers.put(d.id(), d); index.add(d); }
void updateLocation(String driverId, Location at) { index.update(driver(driverId), at); }
Trip requestRide(String riderId, Location pickup, Location drop, VehicleType type) {
String tripId = "T" + tripSeq.incrementAndGet(); // a refused request just skips a number
if (activeTripOfRider.putIfAbsent(riderId, tripId) != null) // one live ride per rider
throw new IllegalStateException(riderId + " already has an active ride");
try {
List<Driver> ranked = matching.rank(index.near(pickup, radiusKm), pickup, type);
int surgePercent = surge.multiplierPercent(ranked.size());
for (Driver d : ranked) {
if (!d.tryReserve()) continue; // taken a moment ago: try the next
Trip t = new Trip(tripId, riderId, d, pickup, drop, surgePercent);
trips.put(tripId, t);
return t;
}
throw new NoDriverException("no " + type + " within " + radiusKm + " km of " + riderId);
} catch (RuntimeException e) {
activeTripOfRider.remove(riderId, tripId);
throw e;
}
}
void startTrip(String tripId, String driverId) { trip(tripId).start(driverId); }
long completeTrip(String tripId, String driverId, long meters) {
Trip t = trip(tripId);
long fare = t.complete(driverId, meters, pricing);
finish(t);
return fare;
}
void cancelTrip(String tripId, String riderId) {
Trip t = trip(tripId);
t.cancel(riderId); // throws once the ride has started
finish(t);
}
private void finish(Trip t) { // runs once: only one terminal move succeeds
driver(t.driverId()).release();
activeTripOfRider.remove(t.riderId(), t.id());
}
Driver driver(String id) {
Driver d = drivers.get(id);
if (d == null) throw new IllegalArgumentException("no driver " + id);
return d;
}
Trip trip(String id) {
Trip t = trips.get(id);
if (t == null) throw new IllegalArgumentException("no trip " + id);
return t;
}
}
class Driver {
private final String id;
private final VehicleType type;
private final AtomicReference<DriverStatus> status = new AtomicReference<>(DriverStatus.OFFLINE);
private volatile Location location; // written by GridIndex.update under this driver's lock
Driver(String id, VehicleType type, Location at) { this.id = id; this.type = type; this.location = at; }
/** The only way a driver gets a trip: one compare-and-set, so two requests can never both win. */
boolean tryReserve() { return status.compareAndSet(DriverStatus.AVAILABLE, DriverStatus.ON_TRIP); }
void release() {
if (!status.compareAndSet(DriverStatus.ON_TRIP, DriverStatus.AVAILABLE))
throw new IllegalStateException(id + " is not on a trip");
}
boolean goOnline() { return status.compareAndSet(DriverStatus.OFFLINE, DriverStatus.AVAILABLE); }
boolean goOffline() { return status.compareAndSet(DriverStatus.AVAILABLE, DriverStatus.OFFLINE); } // not mid-trip
boolean isAvailable() { return status.get() == DriverStatus.AVAILABLE; }
String id() { return id; }
VehicleType type() { return type; }
Location location() { return location; }
void moveTo(Location to) { location = to; }
}
/** Drivers bucketed by square cells, so "who is near" scans a few cells instead of every driver. */
class GridIndex {
private final double cellKm;
private final Map<Long, Set<Driver>> cells = new ConcurrentHashMap<>();
GridIndex(double cellKm) { this.cellKm = cellKm; }
void add(Driver d) {
synchronized (d) { cell(key(d.location())).add(d); }
}
/** Location pings for one driver are serialized by the driver's lock; the driver is never in two cells. */
void update(Driver d, Location to) {
synchronized (d) {
long from = key(d.location()), next = key(to);
if (from != next) {
cell(from).remove(d);
cell(next).add(d);
}
d.moveTo(to);
}
}
/** Drivers within radiusKm: scan the cells the circle can touch, then filter by real distance. */
List<Driver> near(Location p, double radiusKm) {
int r = (int) Math.ceil(radiusKm / cellKm);
long cx = (long) Math.floor(p.x() / cellKm), cy = (long) Math.floor(p.y() / cellKm);
List<Driver> out = new ArrayList<>();
for (long x = cx - r; x <= cx + r; x++)
for (long y = cy - r; y <= cy + r; y++) {
Set<Driver> s = cells.get(key(x, y));
if (s == null) continue;
for (Driver d : s) if (d.location().distanceTo(p) <= radiusKm) out.add(d);
}
return out;
}
private Set<Driver> cell(long key) { return cells.computeIfAbsent(key, k -> ConcurrentHashMap.newKeySet()); }
private long key(Location l) { return key((long) Math.floor(l.x() / cellKm), (long) Math.floor(l.y() / cellKm)); }
private static long key(long cx, long cy) { return (cx << 32) ^ (cy & 0xffffffffL); }
}
class Trip {
private final String id, riderId, driverId;
private final Location pickup, drop;
private final VehicleType type;
private final int surgePercent; // locked in when the ride is booked
private TripStatus status = TripStatus.ASSIGNED; // guarded by this
private long farePaise; // guarded by this; set on completion
Trip(String id, String riderId, Driver driver, Location pickup, Location drop, int surgePercent) {
this.id = id; this.riderId = riderId; this.driverId = driver.id(); this.type = driver.type();
this.pickup = pickup; this.drop = drop; this.surgePercent = surgePercent;
}
synchronized void start(String byDriver) {
requireDriver(byDriver);
move(TripStatus.STARTED);
}
synchronized long complete(String byDriver, long meters, PricingStrategy pricing) {
requireDriver(byDriver);
move(TripStatus.COMPLETED);
farePaise = pricing.farePaise(type, meters, surgePercent);
return farePaise;
}
synchronized void cancel(String byRider) {
if (!riderId.equals(byRider)) throw new IllegalArgumentException(byRider + " did not book " + id);
move(TripStatus.CANCELLED);
}
private void move(TripStatus to) {
if (!status.canMoveTo(to)) throw new IllegalStateException(id + " cannot go from " + status + " to " + to);
status = to;
}
private void requireDriver(String who) {
if (!driverId.equals(who)) throw new IllegalArgumentException(who + " is not the driver of " + id);
}
String id() { return id; }
String riderId() { return riderId; }
String driverId() { return driverId; }
int surgePercent() { return surgePercent; }
synchronized TripStatus status() { return status; }
synchronized long farePaise() { return farePaise; }
}
Go deeperThe remaining files
import java.util.Set;
enum TripStatus {
ASSIGNED, STARTED, COMPLETED, CANCELLED;
boolean canMoveTo(TripStatus to) {
return switch (this) {
case ASSIGNED -> Set.of(STARTED, CANCELLED).contains(to);
case STARTED -> to == COMPLETED; // no cancelling a ride in progress
case COMPLETED, CANCELLED -> false;
};
}
}
enum DriverStatus { OFFLINE, AVAILABLE, ON_TRIP }
/** A point on a flat city map, in kilometres. Real systems use latitude and longitude (see the deep dives). */
record Location(double x, double y) {
double distanceTo(Location o) { return Math.hypot(x - o.x, y - o.y); }
}
enum VehicleType {
AUTO(3_000, 1_500), MINI(4_000, 1_100), SEDAN(6_000, 1_500);
final long basePaise, perKmPaise;
VehicleType(long basePaise, long perKmPaise) { this.basePaise = basePaise; this.perKmPaise = perKmPaise; }
}
import java.util.List;
interface MatchingStrategy {
/** Candidates in the order to try them; the service reserves the first one still free. */
List<Driver> rank(List<Driver> nearby, Location pickup, VehicleType type);
}
import java.util.Comparator;
import java.util.List;
class NearestDriver implements MatchingStrategy {
@Override
public List<Driver> rank(List<Driver> nearby, Location pickup, VehicleType type) {
return nearby.stream()
.filter(d -> d.type() == type && d.isAvailable())
.sorted(Comparator.comparingDouble((Driver d) -> d.location().distanceTo(pickup)).thenComparing(Driver::id))
.toList();
}
}
interface SurgePolicy {
/** Price multiplier in percent (100 = no surge) from free drivers of the type near the pickup. */
int multiplierPercent(int freeDriversNearby);
}
class SupplySurge implements SurgePolicy {
@Override
public int multiplierPercent(int free) {
if (free >= 3) return 100;
return free == 2 ? 125 : 150;
}
}
interface PricingStrategy {
long farePaise(VehicleType type, long meters, int surgePercent);
}
class StandardPricing implements PricingStrategy {
@Override
public long farePaise(VehicleType type, long meters, int surgePercent) {
long base = type.basePaise + type.perKmPaise * meters / 1_000;
return (base * surgePercent + 50) / 100; // rounded half up
}
}
class NoDriverException extends RuntimeException {
NoDriverException(String message) { super(message); }
}
RideService svc = new RideService(new NearestDriver(), new SupplySurge(), new StandardPricing(), 3.0);
Location home = new Location(0, 0), office = new Location(8, 0);
svc.addDriver(new Driver("D1", VehicleType.MINI, new Location(0.3, 0.4))); // 0.5 km away, stays offline
svc.addDriver(new Driver("D2", VehicleType.MINI, new Location(0.6, 0.8))); // 1.0 km
svc.addDriver(new Driver("D3", VehicleType.MINI, new Location(1.5, 2.0))); // 2.5 km
svc.addDriver(new Driver("D4", VehicleType.SEDAN, new Location(0.2, 0.0))); // 0.2 km
svc.addDriver(new Driver("D5", VehicleType.MINI, new Location(2.4, 1.0))); // 2.6 km
svc.addDriver(new Driver("D6", VehicleType.AUTO, new Location(9, 9))); // far away
for (String id : List.of("D2", "D3", "D4", "D5", "D6")) svc.driver(id).goOnline();
Trip a = svc.requestRide("asha", home, office, VehicleType.MINI);
say(a, "asha"); // D1 is closer but offline
Trip r = svc.requestRide("ravi", home, office, VehicleType.MINI);
say(r, "ravi");
svc.cancelTrip(r.id(), "ravi");
System.out.println(r.id() + " cancelled by ravi; D3 available again: " + svc.driver("D3").isAvailable());
attempt(() -> svc.startTrip(a.id(), "D3"));
svc.startTrip(a.id(), "D2");
long fare = svc.completeTrip(a.id(), "D2", 8_000);
svc.updateLocation("D2", office); // D2 is now 8 km away, in another cell
System.out.println(a.id() + " completed, 8 km, fare " + fare + " paise");
Trip r2 = svc.requestRide("ravi", home, office, VehicleType.MINI);
say(r2, "ravi");
attempt(() -> svc.requestRide("ravi", home, office, VehicleType.MINI));
Trip m = svc.requestRide("meera", home, office, VehicleType.SEDAN);
say(m, "meera");
svc.startTrip(m.id(), "D4");
attempt(() -> svc.cancelTrip(m.id(), "meera"));
attempt(() -> svc.requestRide("kiran", home, office, VehicleType.AUTO));
// 10 drivers, 30 riders, each rider sends the request twice at the same moment
RideService busy = new RideService(new NearestDriver(), new SupplySurge(), new StandardPricing(), 3.0);
Location stadium = new Location(50, 50);
for (int i = 0; i < 10; i++) {
Driver d = new Driver("S" + i, VehicleType.MINI, new Location(50 + i * 0.1, 50));
busy.addDriver(d);
d.goOnline();
}
ConcurrentLinkedQueue<Trip> booked = new ConcurrentLinkedQueue<>();
AtomicInteger noDriver = new AtomicInteger(), duplicate = new AtomicInteger();
CountDownLatch go = new CountDownLatch(1);
ExecutorService pool = Executors.newFixedThreadPool(16);
for (int i = 0; i < 60; i++) {
String rider = "fan" + (i % 30);
pool.submit(() -> {
go.await();
try { booked.add(busy.requestRide(rider, stadium, home, VehicleType.MINI)); }
catch (NoDriverException e) { noDriver.incrementAndGet(); }
catch (IllegalStateException e) { duplicate.incrementAndGet(); }
return null;
});
}
go.countDown();
pool.shutdown();
check(pool.awaitTermination(30, TimeUnit.SECONDS), "finished");
Code that ranDemo.java: the complete code behind the output below
public class Demo {
public static void main(String[] args) throws Exception {
RideService svc = new RideService(new NearestDriver(), new SupplySurge(), new StandardPricing(), 3.0);
Location home = new Location(0, 0), office = new Location(8, 0);
svc.addDriver(new Driver("D1", VehicleType.MINI, new Location(0.3, 0.4))); // 0.5 km away, stays offline
svc.addDriver(new Driver("D2", VehicleType.MINI, new Location(0.6, 0.8))); // 1.0 km
svc.addDriver(new Driver("D3", VehicleType.MINI, new Location(1.5, 2.0))); // 2.5 km
svc.addDriver(new Driver("D4", VehicleType.SEDAN, new Location(0.2, 0.0))); // 0.2 km
svc.addDriver(new Driver("D5", VehicleType.MINI, new Location(2.4, 1.0))); // 2.6 km
svc.addDriver(new Driver("D6", VehicleType.AUTO, new Location(9, 9))); // far away
for (String id : List.of("D2", "D3", "D4", "D5", "D6")) svc.driver(id).goOnline();
Trip a = svc.requestRide("asha", home, office, VehicleType.MINI);
say(a, "asha"); // D1 is closer but offline
Trip r = svc.requestRide("ravi", home, office, VehicleType.MINI);
say(r, "ravi");
svc.cancelTrip(r.id(), "ravi");
System.out.println(r.id() + " cancelled by ravi; D3 available again: " + svc.driver("D3").isAvailable());
attempt(() -> svc.startTrip(a.id(), "D3"));
svc.startTrip(a.id(), "D2");
long fare = svc.completeTrip(a.id(), "D2", 8_000);
svc.updateLocation("D2", office); // D2 is now 8 km away, in another cell
System.out.println(a.id() + " completed, 8 km, fare " + fare + " paise");
Trip r2 = svc.requestRide("ravi", home, office, VehicleType.MINI);
say(r2, "ravi");
attempt(() -> svc.requestRide("ravi", home, office, VehicleType.MINI));
Trip m = svc.requestRide("meera", home, office, VehicleType.SEDAN);
say(m, "meera");
svc.startTrip(m.id(), "D4");
attempt(() -> svc.cancelTrip(m.id(), "meera"));
attempt(() -> svc.requestRide("kiran", home, office, VehicleType.AUTO));
check(fare == 12_800, "fare");
check(r2.driverId().equals("D3") && r2.surgePercent() == 125, "rematch");
// 10 drivers, 30 riders, each rider sends the request twice at the same moment
RideService busy = new RideService(new NearestDriver(), new SupplySurge(), new StandardPricing(), 3.0);
Location stadium = new Location(50, 50);
for (int i = 0; i < 10; i++) {
Driver d = new Driver("S" + i, VehicleType.MINI, new Location(50 + i * 0.1, 50));
busy.addDriver(d);
d.goOnline();
}
ConcurrentLinkedQueue<Trip> booked = new ConcurrentLinkedQueue<>();
AtomicInteger noDriver = new AtomicInteger(), duplicate = new AtomicInteger();
CountDownLatch go = new CountDownLatch(1);
ExecutorService pool = Executors.newFixedThreadPool(16);
for (int i = 0; i < 60; i++) {
String rider = "fan" + (i % 30);
pool.submit(() -> {
go.await();
try { booked.add(busy.requestRide(rider, stadium, home, VehicleType.MINI)); }
catch (NoDriverException e) { noDriver.incrementAndGet(); }
catch (IllegalStateException e) { duplicate.incrementAndGet(); }
return null;
});
}
go.countDown();
pool.shutdown();
check(pool.awaitTermination(30, TimeUnit.SECONDS), "finished");
Set<String> driversUsed = new HashSet<>(), riders = new HashSet<>();
for (Trip t : booked) { driversUsed.add(t.driverId()); riders.add(t.riderId()); }
check(booked.size() == 10, "every driver booked once");
check(driversUsed.size() == 10, "no driver twice");
check(riders.size() == 10, "no rider twice");
check(noDriver.get() + duplicate.get() == 50, "the rest refused");
System.out.println("30 riders x 2 requests at once for 10 drivers: 10 trips, 10 different drivers, no rider with two rides");
}
static void say(Trip t, String rider) {
System.out.println(t.id() + ": " + rider + " -> " + t.driverId() + ", surge " + t.surgePercent() + "%");
}
static void attempt(Runnable r) {
try { r.run(); } catch (RuntimeException e) { System.out.println("rejected: " + e.getMessage()); }
}
static void check(boolean ok, String what) { if (!ok) throw new AssertionError(what); }
}
T1: asha -> D2, surge 100%
T2: ravi -> D3, surge 125%
T2 cancelled by ravi; D3 available again: true
rejected: D3 is not the driver of T1
T1 completed, 8 km, fare 12800 paise
T3: ravi -> D3, surge 125%
rejected: ravi already has an active ride
T5: meera -> D4, surge 150%
rejected: T5 cannot go from STARTED to CANCELLED
rejected: no AUTO within 3.0 km of kiran
30 riders x 2 requests at once for 10 drivers: 10 trips, 10 different drivers, no rider with two rides
P17.6Concurrency
| Race | Defense |
|---|---|
| Two requests pick the same driver | tryReserve is a compare-and-set from AVAILABLE to ON_TRIP; the loser tries its next candidate |
| A rider double-taps "Book" | activeTripOfRider.putIfAbsent; the second request fails at once; a failed request removes only its own entry |
| A driver goes offline while being matched | goOffline is a compare-and-set from AVAILABLE, so it fails once the driver is reserved, and a reserve fails once the driver is offline |
| Rider cancels as the driver starts the trip | Both go through Trip's synchronized moves; one wins, the other gets an illegal-move error |
| A driver's location changes during a search | Pings for one driver are serialized by the driver's lock; a search may see a slightly old position, which is fine for "near" |
The pattern is the same as parking spots in P1 and partners in P15: search without locks, then claim with one atomic step, and treat a failed claim as "try the next one". Locking the whole service, or even the whole cell, while searching would make every request in a busy area wait in line.
Do not compute the fare from the surge at completion time. Surge can rise during a 40-minute ride, and the rider agreed to the multiplier shown when booking. Store it on the trip, the same reason P15 stores prices on the order.
Real systems index locations with hexagonal cells (Uber's H3) or geohashes, keep driver locations in an in-memory store sharded by city, and match in short batches (every couple of seconds, solve an assignment problem over all waiting riders and free drivers) instead of greedily one request at a time, which gives lower total pickup time. The driver must also accept the offer, so assignment is an offer with a timeout, as in the P15 deep dive.
P17.7Deep dives
Guided designExtensions interviewers ask forAnswer each before revealing
- 1Latitude and longitude instead of a flat map
Cells of 1 km and distance with
hypotonly work on a plane.Write your answer, then reveal
Use the haversine formula for distance, and cells that are a fixed angle (a geohash prefix, or an H3 cell at a chosen resolution). A cell is about 1 km tall everywhere but narrower in longitude away from the equator, so scan neighbors by cell adjacency rather than a fixed count, and always filter by real distance. The design does not change: the index is behind
near(point, radius). - 2The driver can decline
The driver has 15 seconds to accept the ride.
Write your answer, then reveal
Add OFFERED between reserving and assigning. Reserve the driver (so nobody else offers them the same ride), send the offer, start a timer (P12). Accept: create the trip as ASSIGNED. Decline or timeout: release the driver, remember the decline, and offer the next candidate. The rider sees "finding your driver" for the whole sequence.
- 3Better surge
"Fewer free drivers" ignores demand.
Write your answer, then reveal
Compute supply and demand per cell over a sliding window: requests in the last 5 minutes against free drivers now, smoothed and capped (say 1x to 3x). Recompute every 30 seconds per cell rather than on each request. It stays a
SurgePolicy; the trip still stores the multiplier it was booked with. - 4Pool rides
Two riders share a car going the same way.
Write your answer, then reveal
The driver gains a seat capacity instead of a boolean: an atomic counter claimed with compare-and-set up to the limit, as in batched deliveries in P15. Matching for the second rider prefers drivers already on a pool trip whose route detour stays under a limit. Each rider has their own trip and fare; the driver is released when the last one ends.
P17.8Machine coding plan
| Minutes | Do |
|---|---|
| 0-8 | Clarify matching radius, cancellation window, fare formula, surge; write the invariants |
| 8-25 | Location, VehicleType, Driver with the atomic status; TripStatus |
| 25-45 | RideService.requestRide with a plain list scan first; NearestDriver; NoDriverException |
| 45-60 | Trip moves; start, complete with StandardPricing, cancel; release on both end states |
| 60-75 | Replace the scan with GridIndex; location updates |
| 75-90 | Concurrent booking test; walk through offers, geohash, and batch matching |
Starting with a plain list scan and swapping in the grid later is deliberate: the grid sits behind one method, so the swap touches nothing else, and you never run out of time with a half-built index and no working flow.
P17.9What a strong answer shows
- SDE-2 bar: atomic driver claim with fallback to the next candidate; one active ride per rider; trip state machine with actor checks; pricing and matching as strategies; driver released on every end state.
- Above the bar: a spatial index with a clear cost argument; surge locked at booking; driver offers with timeouts; batch matching and H3 or geohash in production.
- Why an
AtomicReferencefor the driver's status instead of a synchronized method?Reveal
Every transition is "if status is X, set it to Y", which is exactly one compare-and-set. It never blocks, and matching threads that lose simply try the next driver.
- Why does the grid scan cells and then filter by distance?
Reveal
Cells are squares and the search area is a circle. The cells give a cheap superset; the distance check removes drivers in the corners of the scanned square.
- What happens if the rider cancels and the driver presses Start at the same moment?
Reveal
Both go through the trip's synchronized moves. Cancel first: the trip is CANCELLED, the driver is released, and Start fails. Start first: the trip is STARTED and Cancel fails because a started ride cannot be cancelled. Never both.
- Why release the driver in the service rather than inside
Trip?Reveal
The trip records what happened to it; freeing the driver is a step in the booking flow, done once after a successful end move. Keeping it in one
finishmethod makes "released exactly once" easy to check.
Cheat sheets
One page to skim before a round: the 14 patterns by the pressure they relieve, SOLID in one line each, UML relationships as Java, the collections and concurrency tools that solve LLD problems, tables and APIs on one screen, the key idea of every worked problem, and the Java you type in every machine coding round.
Patterns by pressure
| Pressure you feel | Pattern | Shape in one line | Where you have used it | Lesson |
|---|---|---|---|---|
Callers pick a concrete class with if/switch on a type |
Factory | One method returns the right implementation of an interface | List.of, Spring BeanFactory |
M4 |
| A constructor with many optional parameters | Builder | Fluent setters on a helper, build() validates and creates |
HttpRequest.newBuilder(), Lombok @Builder |
M4 |
| Exactly one instance must exist | Singleton | Private constructor, one static instance; prefer one DI bean | Runtime.getRuntime(), Spring singleton beans |
M4 |
| A class has the wrong interface for your code | Adapter | Wrap it and translate calls | InputStreamReader, Arrays.asList |
M5 |
| Add behavior around calls, in combinations | Decorator | Same interface, wraps another instance, adds before or after | BufferedInputStream, Collections.unmodifiableList |
M5 |
| Control access: lazy, remote, cached, secured | Proxy | Same interface, decides whether and how to call the real one | Spring @Transactional proxies |
M5 |
| A subsystem is too complicated for callers | Facade | One simple class in front of many | JdbcTemplate, SLF4J |
M5 |
| Treat a tree of parts like a single part | Composite | Leaf and container share one interface | File systems, UI containers | M5 |
| One step's algorithm varies | Strategy | Interface for the step, injected implementation | Comparator, pricing rules |
M6 |
| Same steps, some differ by subtype | Template method | Base class fixes the order, subclasses fill the steps | HttpServlet.service calling doGet |
M6 |
| A request passes through a list of checks | Chain of responsibility | Each handler handles or passes on | Servlet filters, Spring Security | M6 |
| Others must react to a change without coupling | Observer | Subject keeps listeners and notifies them | Spring @EventListener |
M7 |
| Actions must be queued, logged, or undone | Command | An action as an object with execute (and undo) |
Runnable tasks, editor undo |
M7 |
| Behavior depends on a mode with rules for moving between modes | State | Enum transitions or one class per state | Order lifecycles, vending machines | M7 |
Name the pressure before the pattern: "pricing rules will change per city, so pricing is a strategy." If you cannot name a pressure, you do not need the pattern.
SOLID in one line each
| Principle | One line | The smell when it is broken |
|---|---|---|
| Single responsibility | A class has one reason to change | A class you edit for unrelated feature requests |
| Open/closed | Add behavior by adding code, not editing working code | A switch on type that grows with every feature |
| Liskov substitution | A subtype works everywhere its parent does | Overrides that throw UnsupportedOperationException or skip rules |
| Interface segregation | Clients depend only on methods they use | Implementations full of empty methods |
| Dependency inversion | Depend on interfaces; inject the implementation | new of infrastructure classes inside business logic |
UML relationships as Java
| Relationship | Arrow | In Java | Example |
|---|---|---|---|
| Inheritance | Solid line, hollow triangle at the parent | extends |
Car extends Vehicle |
| Realization | Dashed line, hollow triangle at the interface | implements |
TokenBucket implements Limiter |
| Composition | Filled diamond at the whole | Field created and owned by the whole; dies with it | Order owns its OrderLines |
| Aggregation | Hollow diamond at the whole | Field passed in; the part outlives the whole | Team has Players |
| Association | Solid line, open arrow | A field that refers to another object | Booking refers to a Show |
| Dependency | Dashed line, open arrow | Parameter, local variable, or return type only | Service uses a Request |
Full lesson: M2 The six relationships.
Collections that solve LLD problems
| Need | Use | Cost |
|---|---|---|
| Lookup by key | HashMap |
O(1) average |
| Least recently used eviction | LinkedHashMap(cap, 0.75f, true) with removeEldestEntry, or map plus doubly linked list |
O(1) |
| Sorted keys; nearest key below or above (time slots, intervals) | TreeMap with floorEntry, ceilingEntry |
O(log n) |
| Next item by priority or time | PriorityQueue with a Comparator |
O(log n) add and poll |
| Queue, stack, undo and redo | ArrayDeque (not Stack, not LinkedList) |
O(1) at both ends |
| Keys that are an enum | EnumMap, EnumSet |
Array-backed, ordered |
| Counts or totals shared by threads | ConcurrentHashMap.merge, LongAdder |
Atomic per key |
| Producer-consumer handoff | ArrayBlockingQueue (bounded) |
Blocks when full or empty |
| Run something later or repeatedly | ScheduledExecutorService, DelayQueue |
See P12 |
| A read-only copy to return | List.copyOf, Map.copyOf |
O(n), immutable |
Concurrency tools
| Problem | Tool | Remember |
|---|---|---|
| Check-then-act on a map | computeIfAbsent, putIfAbsent, merge |
Atomic per key; keep the function short and side-effect free |
| A shared counter | AtomicLong, LongAdder |
LongAdder for hot counters read rarely |
| An invariant across several fields | synchronized or ReentrantLock |
Guard every read and write of those fields with the same lock |
| Many readers, few writers | ReentrantReadWriteLock |
Worth it only when reads are long or frequent |
| Two locks in one operation | Lock in a global order (by ID) | Prevents deadlock (P10) |
| Wait until something is available | BlockingQueue, or Condition with await in a while loop |
Never if around await |
| A stop flag read by another thread | volatile boolean |
Visibility only, not atomicity |
| Run tasks on threads | ExecutorService; Future.get() surfaces exceptions |
Always shutdown() |
| A request that must take effect once | putIfAbsent(key, CompletableFuture) |
Duplicates wait for the first result (P10) |
| Lost updates in a database | Version column, conditional update | Optimistic: retry on conflict (M9) |
Tables and APIs on one screen
| Need | Answer | More |
|---|---|---|
| One per something | UNIQUE or a primary key; catch the violation and answer 409 |
M11 |
| One live row per something | Partial unique index ... WHERE status IN (...) |
M11 |
| A contended state change | UPDATE ... WHERE status = 'AVAILABLE', then check rows changed = 1 |
M11 |
| Several rows all or nothing | One transaction; rows in ID order; any 0-row update rolls back | M11 |
| Hot queries | Index equality columns first, then the range or sort column | M11 |
| A POST that is retried | Idempotency-Key header, unique per user in the table |
M11 |
| A list that changes while paging | Cursor of the last (created_at, id) |
M11 |
| Status codes | 201 created, 400 malformed, 401 who are you, 403 not allowed, 404 missing, 409 state clash, 422 breaks a rule, 429 slow down | M11 |
Every worked problem in one line
| Problem | The idea to remember |
|---|---|
| P1 Parking lot | Spot allocation and pricing as strategies; claim a spot atomically, try the next on failure |
| P2 Vending machine | States with illegal actions rejected; money in escrow until a sale commits stock, cash, and change together |
| P3 LRU cache | Hash map plus doubly linked list, O(1); or LinkedHashMap in access order |
| P4 Snake and ladder | Board as data, a game loop that owns turns, dice and rules pluggable |
| P5 Logging framework | Levels and appenders; one immutable settings snapshot; async with a bounded queue |
| P6 Rate limiter | Token bucket or sliding window per key; atomic updates per key |
| P7 Splitwise | Split strategies, amounts in paise with leftovers assigned, net balances; simplify by matching the largest debtor and creditor |
| P8 Elevator | LOOK scheduling per car, a dispatch strategy, one controller thread |
| P9 Movie booking | ShowSeat per seat per show; holds with expiry; all seats or none |
| P10 Digital wallet | Ordered locking, both ledger rows, idempotency keys |
| P11 Pub-sub queue | Partitions, consumer groups, offsets committed after processing |
| P12 Task scheduler | A priority queue by time, a worker pool, cancellation and recurrence |
| P13 Tic-tac-toe | O(1) win check with row, column, and diagonal counters; undo through the move history |
| P14 Meeting rooms | Half-open intervals; TreeMap floor and ceiling; a lock per room |
| P15 Food delivery | Transitions that name their actor; partner claimed atomically, released if the order changed |
| P16 Key-value store | Write layers per transaction, tombstones, versioned commit with retry |
| P17 Ride matching | Grid index, compare-and-set on the driver, surge locked at booking |
Java you type in every round
/** Value object: immutable, equality by value, validation in the compact constructor. */
record Money(long paise) {
Money {
if (paise < 0) throw new IllegalArgumentException("negative amount");
}
Money plus(Money o) { return new Money(Math.addExact(paise, o.paise)); }
}
/** Enum with data and behavior: no switch needed at the call site. */
enum VehicleType {
BIKE(1), CAR(2), TRUCK(4);
final int slots;
VehicleType(int slots) { this.slots = slots; }
}
/** Domain exception: unchecked, named for the rule that was broken. */
static class SlotUnavailable extends RuntimeException {
SlotUnavailable(String id) { super("slot " + id + " is taken"); }
}
/** Switch expression over an enum: the compiler checks every case is handled. */
static String size(VehicleType t) {
return switch (t) {
case BIKE -> "small";
case CAR -> "medium";
case TRUCK -> "large";
};
}
Map<String, List<String>> byCity = new HashMap<>();
byCity.computeIfAbsent("Pune", k -> new ArrayList<>()).add("asha"); // group without null checks
Map<String, Integer> count = new HashMap<>();
for (String w : List.of("a", "b", "a")) count.merge(w, 1, Integer::sum); // counting
int missing = count.getOrDefault("z", 0);
EnumMap<VehicleType, Integer> free = new EnumMap<>(VehicleType.class); // compact, ordered by enum
free.put(VehicleType.CAR, 10);
List<Order> orders = List.of(new Order("ravi", "Pune", 300), new Order("asha", "Delhi", 500), new Order("meera", "Pune", 500));
List<Order> sorted = orders.stream()
.sorted(Comparator.comparingLong(Order::amount).reversed().thenComparing(Order::user))
.toList();
Map<String, Long> totalByCity = orders.stream()
.collect(Collectors.groupingBy(Order::city, TreeMap::new, Collectors.summingLong(Order::amount)));
TreeMap<Integer, String> bookings = new TreeMap<>(Map.of(9, "standup", 14, "review")); // start hour -> meeting
Map.Entry<Integer, String> before11 = bookings.floorEntry(11); // latest start at or before 11
Map.Entry<Integer, String> after11 = bookings.ceilingEntry(11); // earliest start at or after 11
PriorityQueue<Order> biggestFirst = new PriorityQueue<>(Comparator.comparingLong(Order::amount).reversed());
biggestFirst.addAll(orders);
Deque<String> undo = new ArrayDeque<>(); // use ArrayDeque, not Stack
undo.push("type a");
undo.push("type b");
Map<Integer, String> lru = new LinkedHashMap<>(16, 0.75f, true) { // true = access order
@Override
protected boolean removeEldestEntry(Map.Entry<Integer, String> eldest) { return size() > 2; }
};
lru.put(1, "a");
lru.put(2, "b");
lru.get(1); // 1 is now most recent
lru.put(3, "c"); // evicts 2
ConcurrentHashMap<String, Long> hits = new ConcurrentHashMap<>();
ExecutorService pool = Executors.newFixedThreadPool(4);
List<Future<?>> futures = new ArrayList<>();
for (int i = 0; i < 1_000; i++)
futures.add(pool.submit(() -> hits.merge("home", 1L, Long::sum))); // atomic read-modify-write
for (Future<?> f : futures) f.get(); // wait, and surface exceptions
pool.shutdown();
Code that ranDemo.java: the rest of the file, including the main method behind the output below
public class Demo {
// ... (code shown above) ...
record Order(String user, String city, long amount) {}
public static void main(String[] args) throws Exception {
// ... (code shown above) ...
System.out.println("group " + byCity + ", counts " + count + ", missing " + missing + ", free " + free);
// ... (code shown above) ...
System.out.println("sorted " + sorted.stream().map(Order::user).toList() + ", by city " + totalByCity);
System.out.println("floor(11) " + before11.getValue() + ", ceiling(11) " + after11.getValue()
+ ", heap top " + biggestFirst.peek().amount() + ", undo pops " + undo.pop());
// ... (code shown above) ...
System.out.println("lru keys " + lru.keySet());
// ... (code shown above) ...
System.out.println("hits " + hits.get("home") + "; money " + new Money(150).plus(new Money(50)) + "; " + size(VehicleType.TRUCK));
try {
throw new SlotUnavailable("A1");
} catch (SlotUnavailable e) {
System.out.println("caught: " + e.getMessage());
}
}
}
group {Pune=[asha]}, counts {a=2, b=1}, missing 0, free {CAR=10}
sorted [asha, meera, ravi], by city {Delhi=500, Pune=800}
floor(11) standup, ceiling(11) review, heap top 500, undo pops type b
lru keys [1, 3]
hits 1000; money Money[paise=200]; large
caught: slot A1 is taken
Edge cases to say out loud
| Area | Ask or test |
|---|---|
| Input | Null, empty, zero, negative, duplicate, unknown ID |
| Capacity | Full, exactly at the limit, one over |
| Time | Exactly at expiry; clock injected for tests; time zones for schedules |
| Money | Integer paise, never double; rounding remainder assigned on purpose |
| State | Every invalid transition rejected with a clear error |
| Concurrency | Two users on the same resource; the same request twice |
| Extension | The next likely feature lands as a new class, not an edit everywhere |
Interview playbook
How to run both LLD rounds minute by minute: the LLD discussion (diagrams and key code in 45 to 60 minutes) and the machine coding round (runnable code in 90 to 120 minutes). With a clarifying checklist, phrases that show seniority, recovery moves, a round timer, and a rubric to score your mocks.
Two rounds, two different jobs
| LLD discussion | Machine coding | |
|---|---|---|
| Format | Whiteboard, doc, or drawing tool; 45 to 60 minutes | Your IDE; 90 to 120 minutes; sometimes a 2 to 3 hour take-home |
| What you produce | Requirements, class diagram, key flows, the core code | Code that compiles and runs a demo of the main flows |
| What is scored | Modeling, extensibility, reasoning about trade-offs and concurrency, communication | Working code first, then structure, readability, edge cases, extensibility |
| Typical twist | "Now add X. What changes?" | "You have 15 minutes: add X" or a reviewer reads your code afterwards |
| Biggest risk | Drawing for 40 minutes and never showing code | Nothing runs at the end |
The clarifying checklist
Ask these in the first 5 to 10 minutes and write the answers down as a numbered requirements list. The interviewer is scoring whether you find the hard parts yourself.
- Actors and core use cases. Who uses it, and which 3 to 5 flows must work? "Which of these should the demo show?"
- Rules. Limits, pricing, validation, what happens when something fails or runs out.
- Lifecycles. Which entities move through states (order, booking, payment)? Draw them later.
- Concurrency. Can two users act on the same thing at once? Which operation must never happen twice?
- Scale inside one process. Roughly how many entities? In memory is fine? (It almost always is.)
- Interface. Method calls, a command-line driver, or REST? Is there a given input format?
- Out of scope. Persistence, authentication, UI, real payments. Say what you will skip.
- What will change. "Are new vehicle types or pricing rules likely?" The answers are your variation points.
Finish with the invariants, the rules that must always hold ("a seat is never sold twice", "a balance is never negative"). They decide where the locks and the validations go.
Running the LLD discussion round
| Phase (45 min plan) | Produce | Say |
|---|---|---|
| Clarify (5) | Numbered requirements, scope, invariants | "Let me confirm scope before designing." |
| Entities (5) | Nouns that own data and rules; value objects; enums | "Seat is the physical seat; ShowSeat is its state for one show." |
| Class diagram (10) | Classes with key fields and methods; relationships with multiplicity; interfaces at variation points | "Pricing varies by city, so it sits behind an interface." |
| Flows (10) | One sequence diagram for the main use case; a state diagram if there is a lifecycle | "Here is what happens when two users pick the same seat." |
| Code the core (10) | The class where the hard rule lives, with real method bodies | "This method is the invariant; everything else is plumbing." |
| Extend (5) | Concurrency answer, one new requirement placed in the design, trade-offs | "Adding EV charging is a new spot type and a new pricing strategy; nothing else changes." |
Use Module 8's seven-step method for the middle phases. In a 60-minute round, spend the extra time on code: interviewers remember the candidate who wrote the tricky method correctly.
Running the machine coding round
| Phase (90 min plan) | Do | Watch out for |
|---|---|---|
| Clarify (8) | The checklist above; agree on the demo scenarios | Starting to code before agreeing on what "done" means |
| Model (10) | Class list in comments or on paper; interfaces at variation points | Designing for features nobody asked for |
| Skeleton (12) | Packages, records, enums, exceptions, repository interfaces, an empty Main that compiles |
Spending 20 minutes on a framework setup |
| Core flow (35) | Services and strategies; one happy path end to end first; extend the demo as each feature lands | Perfecting one class while others do not exist |
| Edges (13) | Validation, error cases, concurrency if asked; the edge-case list from the cheat sheet | New features at this point |
| Demo (7) | Main runs every agreed scenario with readable output; tests if allowed |
Output that needs explaining |
| Walkthrough (5) | Explain structure, patterns with their reasons, and where the next feature plugs in | Apologizing for the code instead of explaining it |
Rules that save rounds:
- Compile by minute 30, happy path by minute 65. If you are behind, cut scope out loud: "I will skip the refund flow and come back if time allows."
- Type the starter skeleton while you think about the model; it compiles in ten minutes and gives you the driver, exceptions, and repositories.
- Write the driver early and grow it. A demo that ran at minute 50 can only get better.
- In-memory repositories behind interfaces, IDs from an
AtomicLongor a prefix plus counter. Never a database unless asked. - One exception family for domain errors, with messages a user could read.
- No pattern without a pressure. A strategy for pricing that varies is good; a factory for one class is noise.
- Stop adding features 10 minutes before the end. Rename, delete dead code, and write three lines of assumptions at the top of
Main.
Module 10 covers layout, IDs, tests, and the driver in detail.
Phrases that show seniority
| Moment | Say |
|---|---|
| Scoping | "I will design for these four use cases and leave refunds as an extension; tell me if refunds matter." |
| Choosing a pattern | "Pricing rules differ by city and will grow, so pricing is a strategy. Spot selection is fixed, so it stays a method." |
| Rejecting a pattern | "I could add a factory here, but there is one implementation and no sign of more. I will extract it when a second one appears." |
| Concurrency | "The race is two bookings for the last seat. I lock per seat in sorted order, so different seats never wait on each other and overlapping requests cannot deadlock." |
| Trade-off | "One lock per show is simpler and correct; per-seat locks scale better. At our load, I would start with per show." |
| Extension | "A new vehicle type is a new enum value and a new pricing rule; no existing class changes." |
| Testing | "I injected the clock so expiry is testable without sleeping." |
When things go wrong
| Situation | Move |
|---|---|
| A new requirement mid-way | Restate it, say which classes change and which do not, then change the fewest. This is the extensibility test, not an interruption. |
| You are stuck on an algorithm | Say the simple correct version first ("a linear scan works; we can index later"), make it work, then improve. |
| You find a bug in your design | Say so and fix it: "this allows a double booking; the check has to move inside the lock." Owning it scores better than hiding it. |
| The interviewer pushes back | Ask what they are worried about, then either defend with the trade-off or adopt the better idea. Both are fine; silence is not. |
| A pattern name you do not know | Ask what problem it solves in this context; often you already know the idea under another name. |
| Running out of time | Stop and summarize: what works, what is missing, how you would finish it. A clear plan for the rest earns partial credit. |
Red flags
| Red flag | Why it hurts | Instead |
|---|---|---|
A Manager or System class that does everything |
Shows no modeling; nothing can change without editing it | Give each rule to the class that owns its data |
| Getters and setters on every field | Anyone can break invariants | Methods named after behavior: book, release, debit |
instanceof and switch on types everywhere |
Every new type edits old code | Polymorphism or a strategy |
double for money |
Rounding errors in a payment answer | Integer paise or BigDecimal |
| Five patterns, no running code | Looks like memorization | The minimum design that runs, then extensions |
synchronized on every method, or none anywhere |
Either no concurrency thinking or no understanding of it | Name the shared state, then protect only that |
| Silent typing for ten minutes | The interviewer cannot score what they cannot hear | Narrate decisions, not keystrokes |
Other practical rounds
Some companies add rounds that test the same skills on code you did not write, or grow one problem in stages. Formats change, so ask your recruiter which rounds you will get. Stripe is known for debugging (bug squash) and integration rounds; Uber and many Indian product companies run classic machine coding; API and table design often appears inside design rounds.
| Round | What happens | How to prepare |
|---|---|---|
| Multi-part coding | One problem in three or four parts; each part adds a requirement to your own code | Solve handbook problems, then do their deep dives as the next parts against your own code; keep rules in small functions or strategies so part 3 does not rewrite part 1 |
| Bug squash | An unfamiliar repository with a failing test or a bug report; find and fix the cause | Reproduce first; read the code path; state a hypothesis out loud and confirm it with one print or breakpoint; fix the cause, add a test, run the whole suite |
| Integration | Add a feature to an unfamiliar codebase using a provided API or SDK and its docs | Read the docs for auth, errors, and pagination before coding; make one call work end to end and print the response; wrap the API in one client class (adapter) |
| Refactoring or code review | Improve working but messy code without changing behavior, or review a pull request | Smell drills; add a test that pins current behavior before changing anything; small steps that each compile |
| API and table design | Design endpoints and tables for a feature | Module 11 |
The bugs planted in debugging rounds are usually ordinary: off by one, null or empty input, == on strings, a shared mutable default, integer overflow, time zones, floating-point money, an unhandled error or retry path. Scan for them while you read, and narrate what you rule out.
Practice with the timer and the rubric
A weekly routine that works:
- One timed problem from the handbook with the timer running, then compare section by section and set the status.
- One practice prompt from the tracker that you have not seen, in the other round format.
- Score both on the rubric, and practice the weakest row next week.
- Every other week, a mock with a friend or a recorded solo session: watching yourself think out loud is uncomfortable and very effective.
What separates SDE-2 from SDE-1 answers: scoping the problem without being led; naming the invariants and protecting them under concurrency; placing variation behind interfaces for reasons you can state, and refusing patterns you cannot justify; managing the clock; and handling a new requirement by changing the fewest classes.
Glossary
Every term used in the handbook in one line each, with a link to where it is explained. Search finds these too.
- Abstraction
- Exposing what an object does through an interface while hiding how it does it. Read more
- Adapter
- A wrapper that converts one interface into the one your code expects. Read more
- Aggregation
- A has-a relationship where the part is passed in and can outlive the whole (hollow diamond). Read more
- Association
- One class holds a reference to another as a field (solid line). Read more
- At-least-once delivery
- Messages are never lost but may repeat, because the offset is committed after processing; handlers must be idempotent. Read more
- Atomic operation
- A read-modify-write that no other thread can interleave with, such as incrementAndGet or merge. Read more
- BlockingQueue
- A thread-safe queue whose put waits when full and take waits when empty; the producer-consumer building block. Read more
- Builder
- A helper with fluent setters and a validating build() for objects with many optional parts. Read more
- Chain of responsibility
- A list of handlers where each one handles the request or passes it to the next. Read more
- Check-then-act
- A race where a thread checks a condition and acts on it, but another thread changes it in between. Read more
- Class diagram
- UML boxes for classes with fields and methods, joined by relationship lines with multiplicities. Read more
- Cohesion
- How closely the parts of a class belong together; high cohesion means one clear purpose. Read more
- Command
- An action packaged as an object with execute (and undo), so it can be queued, logged, or reversed. Read more
- Compare-and-set (CAS)
- An atomic update that succeeds only if the value is still what you read; the basis of lock-free atomics. Read more
- Composite
- Leaves and containers share one interface, so a tree is used like a single object. Read more
- Composition
- Building behavior by holding other objects rather than inheriting; in UML, a part owned by and dying with its whole (filled diamond). Read more
- ConcurrentHashMap
- A thread-safe map with atomic per-key operations: computeIfAbsent, merge, putIfAbsent. Read more
- Conditional update
- An UPDATE whose WHERE clause includes the expected state, so the check and the write are one statement; zero rows changed means someone else won. Read more
- Consumer group
- Consumers that share a topic's partitions, so each message is processed once per group. Read more
- Coupling
- How much one class depends on another's details; low coupling lets classes change independently. Read more
- Cursor pagination
- Paging by "rows after this one" using the last row's sort key, instead of an offset; stable while rows are added. Read more
- Deadlock
- Threads each hold a lock the other needs, so none can continue; prevented by a global lock order. Read more
- Decorator
- A wrapper with the same interface that adds behavior before or after delegating; stackable. Read more
- Dependency (UML)
- A class uses another only as a parameter, local variable, or return type (dashed arrow). Read more
- Dependency injection
- Passing an object its collaborators (usually through the constructor) instead of letting it create them. Read more
- Dependency inversion
- High-level policy depends on interfaces, and low-level details implement them. Read more
- Double-entry ledger
- Every money movement writes a debit and an equal credit, so balances can be rebuilt and checked. Read more
- DRY
- Do not repeat yourself: one authoritative place for each piece of knowledge. Read more
- Encapsulation
- Keeping state private and changing it only through methods that protect its rules. Read more
- Entity
- An object with an identity that persists while its state changes, such as an order or a seat. Read more
- Facade
- One simple class in front of a complicated subsystem. Read more
- Factory
- A method or class that decides which implementation to create, so callers depend only on the interface. Read more
- Fixed rate and fixed delay
- Fixed rate starts runs on a clock rhythm; fixed delay waits a set time after each run ends. Read more
- God class
- One class that knows and does everything; a sign that responsibilities were never assigned. Read more
- Half-open interval
- A range [start, end) that includes its start and excludes its end, so back-to-back ranges do not overlap. Read more
- Idempotency key
- A client-chosen ID that makes a retried request return the first result instead of acting twice. Read more
- Immutability
- An object whose state cannot change after construction; safe to share and to use as a map key. Read more
- Inheritance
- A subclass reuses and specializes a parent class (extends); strong coupling, so use it for true is-a cases. Read more
- Interface segregation
- Clients should depend only on the methods they use; prefer small, focused interfaces. Read more
- Invariant
- A rule that must always hold, such as a balance never going negative; encapsulation exists to protect invariants. Read more
- Law of Demeter
- Talk to your direct collaborators, not to their collaborators (avoid a.getB().getC().do()). Read more
- Lazy expiry
- Treating an expired hold or entry as free when it is next read, instead of relying on a timer to clear it. Read more
- Liskov substitution
- A subtype must work anywhere its parent is expected, keeping the parent's promises. Read more
- Lock granularity
- How much state one lock guards: one global lock is simple, per-object locks allow more parallelism. Read more
- Lock ordering
- Acquiring several locks in one global order (for example by ID) so no deadlock cycle can form. Read more
- LOOK scheduling
- An elevator serves stops in its current direction and reverses at the last requested floor. Read more
- LRU cache
- A fixed-size cache that evicts the least recently used entry; a hash map plus a doubly linked list. Read more
- Machine coding round
- An interview where you write runnable, well-structured code for a design problem in 90 to 120 minutes. Read more
- Multiplicity
- How many objects take part at each end of a relationship: 1, 0..1, *, 1..*. Read more
- Observer
- A subject keeps a list of listeners and notifies them of changes, without knowing what they do. Read more
- Offset
- A message's position in a partition; a consumer group's committed offset says where it resumes. Read more
- Open/closed principle
- Extend behavior by adding new code rather than editing working code. Read more
- Optimistic locking
- Read a version, write only if the version is unchanged, retry on conflict. Read more
- Partial unique index
- A unique index over only the rows that match a condition, such as one live trip per driver. Read more
- Partition
- An ordered, append-only slice of a topic; order is guaranteed only within one partition. Read more
- Polymorphism
- One call, many behaviors: the object's type decides which method runs, replacing type switches. Read more
- Producer-consumer
- Producers put work on a queue and consumer threads take it, decoupling their speeds. Read more
- Proxy
- A stand-in with the same interface that controls access to the real object (lazy, cached, secured, remote). Read more
- Race condition
- A result that depends on how threads interleave; usually a lost update or check-then-act. Read more
- Read-write lock
- Many readers at once or one writer alone; useful when reads dominate. Read more
- Rebalance
- Reassigning a consumer group's partitions when a member joins or leaves. Read more
- Record
- A Java class for immutable data: fields, constructor, equals, hashCode, and toString generated. Read more
- Repository
- An interface for storing and finding entities, so services do not depend on the storage. Read more
- Scoping
- Turning a one-line prompt into a buildable design: the core loop, the contended resource, three use cases to build and three to skip. Read more
- Sequence diagram
- UML lifelines and arrows showing which object calls which, in time order, for one use case. Read more
- Service
- A class that coordinates entities to carry out a use case; holds little state of its own. Read more
- Single responsibility
- A class should have one reason to change. Read more
- Single-writer principle
- Only one thread changes a piece of state; others send it commands through a queue. Read more
- Singleton
- A class with exactly one instance; usually better as one bean created by dependency injection. Read more
- Smell
- A surface sign in code that a design pressure is building up, such as a growing switch on type. Read more
- Spatial index
- A structure that finds nearby points without scanning all of them, such as a grid of cells, a geohash, or H3. Read more
- State diagram
- UML states and labeled transitions (event, guard, action) for one object's lifecycle. Read more
- State pattern
- One class per state, each handling events its own way and choosing the next state. Read more
- Strategy
- An interface for one varying step, with implementations chosen and injected at runtime. Read more
- Template method
- A base class fixes the order of steps and lets subclasses fill in some of them. Read more
- Thread pool
- A fixed set of worker threads that run submitted tasks from a queue (ExecutorService). Read more
- Token bucket
- A rate limiter that refills tokens at a steady rate up to a capacity; each request spends one. Read more
- Tombstone
- A marker that records a delete, so a lookup stops there instead of falling through to older data. Read more
- Type vs instance
- Separating what something is (a seat) from its state in one context (a seat for one show). Read more
- Value object
- An immutable object compared by value, such as Money or a date range; validates itself. Read more
- Variation point
- The place in a design where change is expected, hidden behind an interface. Read more
- Volatile
- A field modifier that makes writes visible to other threads; it does not make compound updates atomic. Read more
- Write-ahead log
- An append-only log of changes, flushed before a change is acknowledged, so a restart can replay it. Read more
- YAGNI
- You are not going to need it: do not build for features nobody has asked for. Read more
Roadmap and revision
A 12-week plan at two LLD sessions a week (about 5 hours), with problems from week 4 and mocks from week 7. If you run the HLD handbook at the same time, interleave as shown below and expect about 18 weeks for both.
Weekly plan
Each session is about 2 to 2.5 hours. Start every session by clearing the revision queue on the home page (15 to 20 minutes).
After week 12: one practice prompt a week, alternating the LLD and machine coding formats, plus the revision queue, until your interviews.
Running it next to the HLD handbook
With three design sessions a week, do two HLD sessions and one LLD session in weeks 1 to 6 (the LLD foundations are shorter), then switch to one HLD and two LLD sessions while the LLD problems run. Module 11 sits well next to the HLD data modeling material. Some problems pair naturally across the two handbooks, so schedule them in the same week: the rate limiter (classes here, Redis and distribution there), the pub-sub queue with the HLD queues module, the task scheduler with the HLD scheduler case, and movie booking with the HLD ticketing case.
Revision rules
- Spaced review: set a status after every problem and practice prompt. The queue brings them back after 1, 2, 4, 10, or 30 days.
- Recode, do not reread: on review, close the page, open an empty project, and rebuild the core with a timer (30 minutes for the class diagram and the core class, 60 for a full run). Then compare.
- One-line misses: keep a running list of what you forgot ("no lock ordering", "money as double", "forgot the state diagram"). Read it before every mock.
- Mastery checklists: revisit them at the end of each phase; untick anything that is no longer true.
Mock interviews
| When | Format | Focus |
|---|---|---|
| Week 7 | 45-minute LLD discussion with a peer, a Starter prompt | Clarifying, a clean class diagram, the core method in code |
| Week 10 | 90-minute machine coding, a Core prompt you have not seen | Compile by 30, happy path by 65, a demo that runs |
| Week 12 | 120-minute machine coding, a Stretch prompt, starting from a one-line prompt you scope yourself | Scoping, concurrency, extension in the last 20 minutes |
| Then | Every 1 to 2 weeks, alternating formats | Rotate prompts from the tracker |
Score each mock with the rubric on the interview playbook and note the weakest row; that decides the next week's revision.
Using the handbook at work
- Code review: each module's "Common mistakes" box is a review checklist. The most useful at work: M3 for responsibilities and dependencies, M9's thread-safety checklist for anything shared between threads.
- Before adding a feature: find the variation point first (M8). If the feature edits a growing
switch, it is time for a strategy or polymorphism. - Lifecycles: orders, payments, cards, and loans are state machines. Write the transition table before the code (M7); most status bugs are missing guards.
- Money paths: idempotency keys, ledgers, and ordered locking from P10 apply directly to payment and repayment services.
- Schema and API reviews: Module 11's checks (constraints for invariants, indexes from queries, conditional updates, idempotency keys, cursor pagination) work as a review checklist for any new endpoint.
- Tests: inject time and randomness (M10) so behavior that depends on them can be tested without sleeping.
Practice tracker
All 17 worked problems and 20 more practice prompts in one list. Set a status after each attempt; due problems come back in the revision queue. Practice prompts reuse the problems and modules listed under each.
Your problems
How to use practice prompts
Practice prompts have no write-up of their own. Each one combines ideas from the problems and modules listed under it. Attempt it in one of the two formats (a 45-minute LLD discussion or a 90-minute machine coding round), then reread the linked material to check your design, and set your status honestly.
| Status | When to choose it |
|---|---|
| Could not solve it | You could not get a coherent design or running core without help |
| Solved with hints | You needed to look something up for the core design or code |
| Solved independently | Design and working core inside the time box, trade-offs explained |
| Needs revision | You did it before, but a redo went badly |
| Can teach it | Done twice, weeks apart, and you can answer every follow-up |
Backup
Back up or move your progress
Progress saves to your account automatically. Use this only to move progress by hand, for example from a private window.
SDE-2 readiness check
Tick an item only when you can do it with notes closed. When every box on this page is ticked and your recent mocks score "solid" or better on every rubric row, you are ready for SDE-2 LLD and machine coding rounds.