How to Design at LLD: Blank Whiteboard to Class Diagram
The mechanical playbook for low level design interview execution. Covers entity extraction, SOLID principles, picking the right design patterns (Strategy, State, Observer, Builder), concurrency primitives, dependency injection, and canonical lld problems like parking lot and library as object-oriented case studies.
Design Is a Mechanical Skill — Treat It That Way
Strong LLD candidates do not invent class hierarchies from scratch in 45 minutes. They run a playbook: a sequence of patterns and templates that produce defensible designs reliably. The skill is not raw creativity — it is knowing which pattern fits which requirement signal and assembling the playbook under time pressure.
This page is the mechanical playbook. It assumes you already know how to approach the interview (covered in How to Approach a Low Level Design Interview) and focuses on what to draw and why.
The rule that organizes everything: requirements drive class structure, not the other way around. A noun in the requirements becomes a candidate class, but only if it has identity and behavior. A verb becomes a candidate method, but only if it transitions state or enforces an invariant. Variation along multiple axes drives composition; single-axis specialization drives inheritance. If you cannot tie a class to a noun and a method to a verb, that class or method should not be in your design.
The second rule: interfaces at extension points, concrete classes everywhere else. Most candidates draw concrete classes everywhere and add interfaces only when the interviewer asks "how would you swap this out?" The reverse is correct — every place where the requirements name a variation (multiple pricing algorithms, multiple notification channels, multiple payment methods) gets an interface. This eliminates 70% of the OCP violations that show up in sloppy designs.
The 6-Phase LLD Design Playbook
Phase 1 — Extract entities and behaviors (5 min)
Noun scan → candidate classes. Verb scan → candidate methods. Apply the survive/collapse test: identity + behavior → class; pure data → attribute. For Parking Lot: ParkingLot, Floor, ParkingSpot, Vehicle, Ticket survive; license_plate becomes a Vehicle attribute.
Phase 2 — Identify variation axes (3 min)
For each candidate class, count variation axes. Single axis (Vehicle varies by type alone) → inheritance is fine. Multiple axes (Vehicle varies by form factor AND fuel type AND size) → composition with strategy fields. This decision before drawing prevents hierarchy lock-in.
Phase 3 — Draw the class diagram (10 min)
Define classes with attributes and methods. Use inheritance for is-a; composition for has-a. Add interfaces at every extension point named in the requirements. Mark relationships with cardinalities (1:1, 1:N, N:M) and ownership (composition vs aggregation).
Phase 4 — Apply SOLID self-check (2 min)
Run through SRP, OCP, LSP, ISP, DIP audibly. For each class: 'one reason to change?' For each interface: 'closed for modification, open for extension?' For each subclass: 'substitutable for the base?' For each interface: 'no fat interfaces forcing unused methods?' For each dependency: 'depending on abstraction, not concrete?'
Phase 5 — Implement the 2-3 signal-rich methods (15 min)
Pick methods that demonstrate concurrency (locks), strategy selection (interface dispatch), or state transitions (invariant enforcement). Show typed exceptions, dependency injection in constructors, and at least one threading primitive. Skip getters, setters, and toString().
Phase 6 — Extensions, testability, production gaps (5 min)
Walk through one new requirement to demonstrate OCP. Show how a mock implementation enables unit testing (DI payoff). Name at least one production concern the design doesn't handle (persistence, distributed state, audit log) — this signals self-awareness.
The Entity Extraction Pipeline
The SOLID Cheatsheet — Tie Every Decision to a Principle
Memorize the 5-second test for each principle. In an interview, you should be able to apply this list to any design choice in 30 seconds.
- SRP (Single Responsibility) — "What's the one reason this class would change?" If you can name two unrelated reasons (Ticket changes when fields are added; Ticket changes when checkout rules change), split the class.
- OCP (Open/Closed) — "Can I add a new variant without modifying existing code?" If adding a new pricing algorithm requires editing
ParkingLot.calculateFee(), the design fails OCP. Fix: extract aPricingStrategyinterface. - LSP (Liskov Substitution) — "Can any subclass replace the base without breaking caller assumptions?" If
MotorcycleSpot.canFit(truck)throws whileParkingSpot.canFit(truck)returns false, LSP is violated. Subclasses must honor the base contract. - ISP (Interface Segregation) — "Are clients forced to depend on methods they don't use?" If
Vehicleinterface hasgetCargoCapacity()andMotorcyclereturns null, split intoVehicleandCargoVehicle. - DIP (Dependency Inversion) — "Does the high-level module depend on an abstraction or a concrete class?"
ParkingLotshould depend onPricingStrategyinterface, not onHourlyPricingdirectly. This enables testing (mock injection) and OCP (swap implementations).
The senior move: every named pattern in your design serves at least one principle. Strategy → OCP + DIP. Factory → DIP + SRP. Observer → OCP. Decorator → OCP. State → SRP + OCP. If you can't name the principle, the pattern is decoration.
Pattern Selection — Match the Requirement Signal to the Pattern
Strategy — multiple algorithms swap at runtime
Signal: requirements name N variants of the same operation (3 pricing modes, 4 sort orders, 2 ranking algorithms). Implementation: interface with one method; concrete classes per algorithm; client composes the strategy. Used at: Stripe (PaymentMethod), Java's Comparator, Netflix's RankingStrategy.
Observer — multiple listeners react to events
Signal: 'when X happens, notify Y, Z, and W' or 'multiple subscribers care about state changes.' Implementation: Subject maintains a list of Observers; on event, calls notify() on each. Used at: Java's PropertyChangeListener, React's useState subscribers, Kafka consumer groups (logical pattern).
Factory — creation logic varies by input
Signal: 'depending on the request, instantiate a different concrete class.' Implementation: a factory class or method that returns the base type but constructs the right subclass. Used at: Spring's BeanFactory, Java's Calendar.getInstance(), parser libraries that pick lexer based on file extension.
Builder — object has many optional or interdependent fields
Signal: 'a constructor with 8+ parameters, many optional, some dependent.' Implementation: separate Builder class with fluent withX().withY().build() API. Used at: Java's StringBuilder, Lombok's @Builder, Protobuf message builders, OkHttp's Request.Builder.
Singleton — exactly one instance, globally accessible
Signal: 'there is one ParkingLot, one ConfigManager, one Logger.' Implementation: private constructor + static getInstance(). Use sparingly — Singletons hurt testability. Java enum-based Singleton is the only thread-safe form without explicit locking. Most modern codebases prefer DI containers (Spring, Guice) over manual Singletons.
State — object behavior changes based on its current state
Signal: 'when in state A, X is allowed; in state B, X throws.' Examples: Order (PLACED allows cancel; SHIPPED does not), Elevator (MOVING allows emergency stop; DOORS_OPEN allows boarding), ATM (PIN_ENTRY accepts digits; DISPENSING does not). Implementation: state interface; concrete state classes; context delegates.
Decorator — add behavior dynamically without subclassing
Signal: 'wrap an existing object to add a feature without changing it.' Implementation: decorator implements the same interface, holds a reference to the wrapped object, delegates and adds. Used at: Java's BufferedReader wrapping FileReader, Python's @decorator syntax, middleware chains in Express/Django.
Template Method — fixed algorithm, variable steps
Signal: 'the workflow is fixed (validate → process → notify) but each step varies per use case.' Implementation: abstract base class defines the algorithm; subclasses override individual steps. Used at: Java's Servlet (doGet/doPost as overridable steps), Spring's JdbcTemplate.
Pattern Decision Matrix — Signal to Pattern
| Requirement signal | Pattern | Why this and not that | When NOT to use |
|---|---|---|---|
| N interchangeable algorithms for the same operation | Strategy | OCP: add a new algorithm without modifying client; DIP: client depends on interface | Algorithm is stable and there's only one implementation — inline as a method |
| Notify multiple listeners when state changes | Observer | OCP: add new listeners without modifying subject; loose coupling between subject and listeners | Single observer with synchronous coupling — use a direct method call |
| Construction logic varies based on input data | Factory (Method or Abstract) | DIP: caller depends on interface; SRP: extracts construction logic from business logic | Single concrete type — direct instantiation is simpler |
| Object has 8+ constructor params, many optional | Builder | Readability + immutability + step-by-step validation; avoids telescoping constructors | 3-4 params or all required — constructor is fine; Kotlin/Scala have named args + defaults |
| Exactly one shared instance globally | Singleton (or DI scope) | Centralized state with global access; resource pooling (DB connection) | Multiple instances are fine, or testing requires multiple isolated copies — use DI scope instead |
| Behavior depends on current internal state | State | Replaces a giant switch on state with polymorphism; SRP: each state class has one responsibility | States are stable and few (2-3) — switch statement is simpler and more readable |
| Add features to an object without modifying it | Decorator | OCP: stack features dynamically (compress + encrypt + log) without combinatorial subclasses | Fixed feature set known at compile time — direct subclassing or composition is simpler |
| Workflow is fixed, individual steps vary | Template Method | Reuses workflow code; subclasses customize only what differs | Workflow itself varies — use Strategy or composition of separate operations |
| Tree-like structure with operations on each node | Visitor | Adds operations to a class hierarchy without modifying classes (double dispatch) | Hierarchy changes more often than operations — Visitor amplifies change cost |
| Pass requests through a series of handlers | Chain of Responsibility | Decouples sender from receiver; each handler decides to handle or pass | Single handler — direct call is fine; ordered processing where every handler runs is a Pipeline, not Chain |
Inheritance vs Composition — The Decision That Defines the Design
Every LLD design has a moment where you choose between inheritance (Car extends Vehicle) and composition (Vehicle has FormFactor + FuelType). Getting this right determines whether the design accommodates new requirements gracefully or collapses into a tangle of conditionals.
The classic test: is-a versus has-a. CompactSpot is a ParkingSpot (specialization with a constraint) — inheritance fits. ParkingLot has Floors, has a PricingStrategy, has a NotificationService — composition fits. Misapplying this — ParkingLot extends Vehicle — produces semantic nonsense and immediate LSP violations.
The deeper test, and the one most interviews actually grade: count the variation axes. A class that varies along one axis (Vehicle by type) maps cleanly to inheritance — three subclasses, done. A class that varies along multiple independent axes (Vehicle by form factor AND fuel type) breaks inheritance: encoding both axes produces 2^N leaf classes (ElectricCar, GasCar, ElectricTruck, GasTruck, ElectricMotorcycle, GasMotorcycle — and that's only 2 axes). Composition encodes each axis as a field; adding a third axis (size class) adds one field, not 8 new classes.
The Java standard library demonstrates this distinction. BufferedReader extends Reader — single axis (a Reader with buffering), inheritance correct. HashMap does not extend ArrayList — the variation axes of collections are key-value vs sequential, ordered vs unordered, sync vs async; encoded as separate interfaces (Map, List, Queue) with concrete implementations composing where needed. The Spring Framework uses composition almost exclusively — BeanDefinition has properties, has scopes, has lifecycle callbacks; it does not extend ScopedBeanDefinition extends LifecycleAwareBeanDefinition extends BaseDefinition. The pattern: inheritance for single-axis specialization; composition for everything else, especially anything with two or more variation dimensions.
The senior signal in interview is to count axes audibly: "Vehicle varies by form factor — that's one axis. Did the requirements mention fuel type? If yes, that's a second axis and I'll use composition; if no, inheritance is fine." This converts an intuitive choice into a defensible one.
Concurrency Primitives — When to Use Each
Synchronized / Lock — exclusive access to mutable state
Use when: a critical section must be entered by one thread at a time. Java's synchronized blocks or ReentrantLock; Python's threading.Lock. Keep critical sections short (lock granularity matters: lock the spot, not the lot). For LLD: ParkingSpot.park() takes a lock per-spot, so 100 threads parking in 100 different spots run in parallel.
ReadWriteLock — many readers, occasional writer
Use when: read frequency >> write frequency and reads don't need to block each other. Java's ReentrantReadWriteLock. Multiple readers can hold the read lock simultaneously; writers get exclusive access. For LLD: a Library catalog read 1000x more than written → ReadWriteLock on the catalog.
ConcurrentHashMap / atomic collections — lock-free shared maps
Use when: you need a shared map with concurrent access. Java's ConcurrentHashMap (lock striping internally), Python's dict is NOT thread-safe (the GIL only protects single bytecodes). For LLD: active_tickets in ParkingLot — 10K concurrent reads + a few hundred writes/sec → ConcurrentHashMap, no manual locking.
AtomicInteger / AtomicReference / CAS — single-variable atomicity
Use when: you need atomic increment, compare-and-swap, or single-reference updates without a full lock. Java's AtomicInteger.incrementAndGet() or AtomicReference.compareAndSet(). For LLD: a shared counter (parking spot count, transaction id generator) — atomic ops are 10-100x faster than synchronized.
Immutability — eliminate concurrency by design
Use when: an object's state never changes after construction. Java's record, Kotlin's val, Python's @dataclass(frozen=True). For LLD: Ticket can be immutable (entry_time and vehicle never change after creation; exit_time is captured at checkout into a NEW Ticket). Immutable objects are thread-safe by definition; this is the simplest and most defensible concurrency story.
Optimistic locking with version field — high-throughput state transitions
Use when: contention is low and you want lock-free reads. Add a version field; on update, WHERE version = expected_version. If 0 rows updated, retry. For LLD: ParkingSpot.assign(vehicle, version) — no thread blocks; conflicts surface as retries. This is what databases use under the hood (Postgres MVCC).
Reference Class Diagram — Parking Lot with Patterns Applied
Dependency Injection — Make Your Design Testable
Inject collaborators in the constructor, not in methods
ParkingLot constructor takes PricingStrategy, NotificationService, ClockProvider — every collaborator that varies. The class becomes a pure composition of injected dependencies. In Java/Kotlin, use Spring's @Autowired or Guice; in Python, pass them explicitly or use dependency_injector.
Depend on interfaces, not concrete classes
ParkingLot's constructor signature: ParkingLot(spots, PricingStrategy pricing, NotificationService notifier). Not HourlyPricing pricing or EmailNotificationService notifier. The interface dependency lets you swap implementations for testing or for new requirements.
Inject a Clock for time-dependent logic
Don't call Instant.now() directly inside calculateFee() — that makes the method untestable for time-based scenarios. Instead, inject a ClockProvider (Java's java.time.Clock, Python's time.time wrapper). Tests can pass a FixedClock(2024-01-15T10:00) to verify peak-hour pricing.
Use a DI container in real code; show explicit wiring in interview code
Production: Spring (Java), Guice (Java), Dagger (Android), Wire (Go), dependency_injector (Python). Interview: write the wiring explicitly in main() to keep the design self-contained. Don't import a container — show the wiring is intentional.
Show one mock-based test to demonstrate the payoff
After the design, show: var lot = new ParkingLot(spots, mockPricing, mockNotifier); when(mockPricing.calculateFee(any())).thenReturn(Money.of(10)); var fee = lot.leave(ticketId); assertEquals(Money.of(10), fee); — this proves the DI design enables unit testing without instantiating real pricing or notification services.
Worked Example: Designing Splitwise from Scratch
Requirements: groups of users; each user can owe / be owed by other users; expenses can be split equally, by percentage, or by exact amount; system tracks balances; supports settling up; should handle concurrent expense additions.
Phase 1 — Entities: noun scan: User, Group, Expense, Split, Balance, Settlement. Verb scan: addExpense, splitEqually, settle, getBalance, calculateOwed.
Survive/collapse: User (identity + balance) → class. Group (identity + members + expenses) → class. Expense (identity + amount + payer + splits) → class. Split (per-user portion of an expense) → class (has identity within an Expense). Balance (User-to-User owed amount) → class OR derivable from expenses (decision: store for query speed; rebuild from expenses for audit). Settlement (a payment that reduces balance) → class.
Phase 2 — Variation axes: Split has 3 algorithms (equal, percentage, exact). That's a variation axis → SplitStrategy interface with implementations EqualSplit, PercentageSplit, ExactSplit. User has no variation axis → concrete class.
Phase 3 — Class diagram (key elements):
User(userId, name, email). Group(groupId, name, members: List<User>, expenses: List<Expense>). Expense(expenseId, payer: User, amount: Money, splits: List<Split>, createdAt: Instant). Split(user: User, amount: Money). SplitStrategy interface with calculateSplits(amount: Money, participants: List<User>, params): List<Split>. BalanceSheet(balances: ConcurrentHashMap<UserPair, Money>) — where UserPair is a directed key (from, to). Settlement(from: User, to: User, amount: Money, settledAt: Instant).
Phase 4 — SOLID self-check: SRP — Expense holds the data; SplitStrategy computes the splits; BalanceSheet aggregates. OCP — adding a new split mode (e.g., share-based) extends SplitStrategy. LSP — every SplitStrategy returns splits that sum to the total. ISP — SplitStrategy has one method. DIP — Group depends on SplitStrategy interface, not concrete implementations.
Phase 5 — Key methods:
Group.addExpense(payer, amount, participants, strategy): validates payer is in group; calls strategy.calculateSplits(amount, participants, params); constructs Expense; appends to expenses; updates balanceSheet atomically (per-pair compute() on ConcurrentHashMap to avoid lost updates from concurrent additions).
BalanceSheet.simplify(): minimizes the number of transactions to settle all balances. Algorithm: build a directed graph of debts; for each cycle, cancel out; remaining edges form a sparse settlement plan. This is the classic "minimum cash flow" problem — interesting algorithmic content, signal-rich.
BalanceSheet.update(from, to, amount): uses ConcurrentHashMap.compute() with atomic merge — bsh.compute(pair, (k, v) -> v == null ? amount : v.add(amount)). No manual locking; thread-safe by data structure choice.
Phase 6 — Extensions and gaps: Adding a new split mode → new SplitStrategy implementation, no changes to Group or Expense. Persistence: BalanceSheet rebuilt from Expense log on startup (Event Sourcing). Distributed scenarios: not covered — would require per-group sharding or a distributed lock for cross-group settlements.
The whole design takes 35-40 minutes if you run the playbook. Without it, candidates drift on which entity owns the balance and burn 10 minutes deciding whether Balance is a class or a query.
Canonical LLD Problems — Pattern Cheatsheet
| Problem | Core entities | Key patterns | Concurrency concern | Signature trap |
|---|---|---|---|---|
| Parking Lot | ParkingLot, Floor, ParkingSpot, Vehicle, Ticket, PricingStrategy | Strategy (pricing), Decorator (peak-hour), Singleton (lot), inheritance for spot types | Concurrent park() on the same spot — atomic CAS or per-spot lock | Putting pricing logic inside ParkingSpot (SRP violation) or single-axis Vehicle hierarchy |
| Elevator | Elevator, Floor, Request, ElevatorSystem, SchedulingStrategy | State (MOVING/IDLE/DOORS_OPEN), Strategy (scheduling: SCAN, FCFS, LOOK), Observer (floor sensors) | Concurrent button presses from multiple floors — request queue with priority | Modeling Direction as a class instead of an enum on the Elevator's State |
| Vending Machine | VendingMachine, Inventory, Product, Coin, State (Idle, HasMoney, Dispensing) | State (each transition allowed only in certain states), Strategy (payment: cash, card, mobile) | Single-user typically; if shared, lock the inventory transition | Implementing as a giant switch on state instead of State pattern; conflating Inventory with Product |
| Library Management | Book, BookItem, Member, Lending, Reservation, Fine | Strategy (recommendation), Observer (return → notify reservations), State (BookItem: AVAILABLE/CHECKED_OUT/RESERVED/LOST) | Concurrent checkout of last copy — optimistic locking with version | Conflating Book (catalog) with BookItem (physical copy) — schema cannot model multiple copies |
| Splitwise | User, Group, Expense, Split, BalanceSheet, Settlement | Strategy (split: equal/percent/exact), Builder (Expense with optional metadata) | Concurrent expense additions — ConcurrentHashMap.compute() for atomic merge | Storing balance in User instead of as a User-pair edge; recomputing balance from full log every read |
| ATM | ATM, Card, Account, Transaction, State (PIN_ENTRY, OPTIONS, DISPENSING, EJECTING) | State (transitions per state), Chain of Responsibility (validation: card → PIN → balance → daily limit) | Single user per session; concurrency on shared Account across sessions — pessimistic lock or transactional update | Modeling cash dispensing without a State machine — produces tangled if/else |
Don't Skip the Testability Story
Testability is the second-strongest L5+ signal after extensibility, and most candidates skip it. The reason is that they design with concrete dependencies — ParkingLot constructs its own HourlyPricing internally, calls Instant.now() directly, sends real notifications. Testing this requires firing up the entire system.
The fix is straightforward and high-signal: constructor-injection of every collaborator that has variation or external effect. PricingStrategy is injected (different algorithms in tests). NotificationService is injected (mock to verify calls). Clock is injected (advance time without sleeping). Persistence is injected behind a Repository interface (in-memory Map for tests, database in production).
Show this in the interview. After the class diagram, say: "All collaborators are injected via the constructor — PricingStrategy, NotificationService, Clock. In a unit test for ParkingLot.leave(), I'd construct new ParkingLot(spots, mockPricing, mockNotifier, fixedClock), configure mockPricing to return $10, advance the clock past entry by 2 hours, call leave(), and assert the returned fee is $20. No real pricing logic, no real time, no real notifications — pure unit test in milliseconds."
What this signals: production-mindset design (tests run in CI in seconds, not minutes); SOLID applied (DIP gives testability for free); awareness that the design must be operable by other engineers, not just functional. Skipping the testability story leaves these signals on the table.
Interview Questions
Click to reveal answersSign in to take the Quiz
This topic has 15 quiz questions with instant feedback and detailed explanations. Sign in to unlock quizzes.
Sign in to take quiz →