Low-Level Design Interview in Java: Parking Lot, LRU Cache and Splitwise
A 6-step approach to LLD and machine coding rounds, SOLID and the six patterns that matter, plus a parking lot, LRU cache and Splitwise worked out in runnable Java 25.
New29 min read

Low level design interview questions in Java ask you to turn a short, vague brief ("design a parking lot") into classes, interfaces and working code within a single interview, usually 45 minutes to two hours. You are judged less on the "right" answer and more on how you clarify scope, model the domain, keep the design open for change, and write code that actually runs.
This guide is for Java developers with roughly 1 to 8 years of experience who are facing an LLD round or a machine coding round, usually at product companies. You get a repeatable 6-step approach, the SOLID principles and six design patterns seen through an interview lens, and three problems solved end to end: a parking lot, an LRU cache and Splitwise. Every program below was compiled and run on JDK 25, and the output shown is the real output.
At the end there are 15 more LLD problems to practice, each with the hint that unlocks it.
In this guide:
- What a low-level design interview and a machine coding round test
- A 6-step approach that works for any LLD problem
- SOLID principles interview questions, applied to real designs
- The 6 design patterns that come up most
- Problem 1: parking lot design in Java (with concurrency)
- Problem 2: LRU cache in Java (two implementations)
- Problem 3: Splitwise low level design (split strategies and debt simplification)
- How to write code in a machine coding round
- 15 more LLD interview questions to practice
- FAQ
What does a low-level design interview test?
What is the difference between low-level design and high-level design?
High-level design (system design) decides the boxes of a distributed system: services, databases, caches, queues and how they scale. Low-level design zooms into one of those boxes and decides its classes, interfaces, method signatures, data structures and object relationships. HLD answers "how does this survive a million users?"; LLD answers "how is this code organized so the next feature is cheap to add?"
In practice the two overlap. A good LLD answer still mentions where state lives and what happens with two concurrent requests, and a good HLD answer often drills into one component's data model. If you are preparing for both, read our system design interview framework alongside this page.
What happens in a machine coding round?
A machine coding round is an LLD interview where you write a working, runnable program rather than a diagram. You usually get a problem statement with a list of features, a fixed time box (often 60 to 120 minutes), and freedom to use your own IDE. Afterwards an interviewer reviews the code with you and asks you to extend it live.
Many Indian product companies and startups use some version of this format, and global companies use similar "object-oriented design" rounds. The exact format differs between companies and even between teams, so ask your recruiter: is it a whiteboard discussion or real code, can you use the internet, is a database expected, are tests expected? The answer changes how you spend the time.
What the evaluator usually looks for:
- It runs. A demo
mainor a few tests that exercise the core flow. Code that does not compile or run is one of the quickest ways to fail the round. - Separation of concerns. Models, services and strategies in their own types, not one 400-line class.
- Extensibility. A new vehicle type, pricing rule or split type should be a new class, not edits to five switch statements.
- Correctness at the edges. Invalid input, a full lot, a reused ticket, rounding money.
- Readable code. Good names, small methods, enums instead of magic strings, immutable value objects.
What the interviewer is checking: whether they would be happy to review your pull requests every day. Clean, boring, working code beats a clever design that does not compile.
A 6-step approach to any LLD problem
Most candidates fail LLD rounds by jumping straight to classes. The approach below keeps you structured and makes your thinking visible, which is a big part of the score. The minutes in the diagram are a suggested budget for a 90-minute machine coding round; scale them to your own time box.
- Clarify requirements (about 5 minutes). Write down 4 to 6 functional requirements and say explicitly what is out of scope. "Multiple floors, three vehicle sizes, hourly pricing; no online booking and no payment gateway for now." Ask about scale and concurrency once.
- Find the entities. Underline the nouns in the requirements (vehicle, spot, floor, ticket) and the verbs (park, unpark, pay). Nouns become classes or records; verbs become methods on the class that owns the data.
- Define relationships and interfaces. Sketch a class diagram: who owns whom (a lot has floors, a floor has spots), who only refers to whom (a ticket refers to a spot). Decide which types are immutable values (records) and which hold state.
- Pick patterns where change is likely. Ask "what will the product manager change next month?" Pricing rules, allocation rules and split types change; wrap each in an interface. Do not add patterns where nothing varies.
- Code the core flow first. In-memory storage behind small classes, one happy-path demo, then the second and third features. Keep it compiling all the time.
- Edge cases, concurrency and extensions. Walk through failure cases, say how you would make the core operation thread-safe, and show how one new requirement plugs in.
SOLID principles interview questions (with an LLD lens)
What are the SOLID principles in Java?
SOLID is five object-oriented design principles: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation and Dependency Inversion. Together they push you toward small classes with one reason to change, behavior added through new types instead of edits, and code that depends on interfaces rather than concrete classes.
- Single Responsibility:
ParkingLotcoordinates parking; it does not calculate fees. Fee calculation lives in aPricingStrategy, so a pricing change never touches allocation code. - Open/Closed: adding weekend pricing means writing
WeekendPricing implements PricingStrategy, not editing anif (isWeekend)insideHourlyPricing. - Liskov Substitution: every
SplitStrategymust return shares that add up to the expense amount. A subclass that silently returns less breaks every caller, even though it compiles. - Interface Segregation: a display board only needs "free spots per size", so give it a small read-only interface instead of the whole
ParkingLotAPI. - Dependency Inversion:
ParkingLotreceives its strategies through the constructor. In tests you pass a fake; in Spring the container injects the real ones.
Which SOLID principle does a big switch on vehicle type violate?
Mainly Open/Closed. A switch (vehicleType) repeated in allocation, pricing and display code means every new vehicle type edits several classes. Move the varying behavior into the enum itself (as VehicleType.fits(size) does below) or into a strategy per type, so a new type is one change in one place.
That said, a single switch expression over an enum or (since Java 21) a sealed interface is fine when it has no default branch: the compiler rejects it as soon as a new case is added, which is exactly the safety net you want. The problem is the same switch copied across the codebase.
The 6 design patterns that come up most in LLD rounds
You do not need all 23 Gang of Four patterns. These six cover the large majority of LLD interview questions:
- Strategy: swappable algorithms behind one interface. Pricing, spot allocation, split types, cab-matching, payment methods. If you use one pattern in an LLD round, it is this one.
- Factory: one place that creates the right subtype from input, such as a notification sender from a channel name or a vehicle from a type code. Keeps
newcalls and their switch out of business code. - Observer: notify interested parties when state changes. Display boards when a spot frees up, an activity feed when an expense is added, subscribers in a pub-sub system.
- State: behavior that depends on the current state, with explicit transitions. Vending machines, elevators, order lifecycles (CREATED, PAID, SHIPPED, DELIVERED). Each state as a class, or a sealed interface with records, prevents illegal transitions.
- Builder: objects with many optional fields (an order, an HTTP request, a search query). In modern Java, records cover most small value objects; builders remain useful for large ones.
- Singleton: one instance of something, such as "the parking lot". Interviewers often ask about it to see whether you know the pitfalls: prefer an
enumsingleton or, better, let a DI container manage a single instance so the class stays testable.
Decorator (add-ons such as coupons or toppings) and Chain of Responsibility (logging levels, approval workflows, ATM note dispensing) are the next two to learn. Our design patterns in Java question bank covers each with the follow-up questions interviewers ask.
Problem 1: Parking lot design in Java
Requirements we agree on
- The lot has several floors; each floor has small, medium and large spots.
- Bikes fit any spot, cars need medium or large, trucks need large.
- On entry the system assigns a spot and issues a ticket; on exit it frees the spot and returns the fee.
- Fee is charged per started hour, at least one hour, with a rate per vehicle type.
- Allocation and pricing rules must be easy to change.
- Several entry gates work at the same time, so two cars must never get the same spot.
- Out of scope: online reservations, payment gateway, number-plate cameras.
Classes and relationships
The nouns give us ParkingLot, Floor, ParkingSpot, Vehicle and Ticket. The two rules that will change become interfaces: SpotAllocationStrategy and PricingStrategy. Vehicle, Floor and Ticket never change after creation, so they are records (Floor copies its spot list with List.copyOf); ParkingSpot holds mutable occupancy state, so it is a class.
Notice what the diagram does not have: no Car, Bike and Truck subclasses. They would differ only in which spot sizes they accept, which is data, not behavior. An enum with a minSize field is simpler and still open for a new type. If the interviewer later adds behavior per type (EVs need a charger), that is the moment to introduce a sealed interface.
The parking lot code
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
public class ParkingLotDemo {
enum SpotSize { SMALL, MEDIUM, LARGE }
enum VehicleType {
BIKE(SpotSize.SMALL), CAR(SpotSize.MEDIUM), TRUCK(SpotSize.LARGE);
final SpotSize minSize;
VehicleType(SpotSize minSize) { this.minSize = minSize; }
boolean fits(SpotSize size) { return size.ordinal() >= minSize.ordinal(); }
}
record Vehicle(String plate, VehicleType type) {}
static final class ParkingSpot {
final String id;
final SpotSize size;
private final AtomicReference<Vehicle> occupant = new AtomicReference<>();
ParkingSpot(String id, SpotSize size) { this.id = id; this.size = size; }
boolean isFree() { return occupant.get() == null; }
/** Atomic: two threads can never both win the same spot. */
boolean tryOccupy(Vehicle v) { return occupant.compareAndSet(null, v); }
void release() { occupant.set(null); }
}
record Floor(int number, List<ParkingSpot> spots) {
Floor { spots = List.copyOf(spots); } // the list of spots never changes after creation
}
record Ticket(String id, Vehicle vehicle, ParkingSpot spot, Instant entryTime) {}
// ---- strategies: the two things interviewers ask you to change later ----
interface SpotAllocationStrategy {
Optional<ParkingSpot> allocate(List<Floor> floors, Vehicle vehicle);
}
/** Lowest floor first; on each floor the smallest spot that fits (keeps big spots for big vehicles). */
static final class LowestFloorBestFit implements SpotAllocationStrategy {
public Optional<ParkingSpot> allocate(List<Floor> floors, Vehicle vehicle) {
for (Floor floor : floors) {
for (SpotSize size : SpotSize.values()) {
if (!vehicle.type().fits(size)) continue;
for (ParkingSpot spot : floor.spots()) {
if (spot.size == size && spot.isFree() && spot.tryOccupy(vehicle)) return Optional.of(spot);
}
}
}
return Optional.empty();
}
}
interface PricingStrategy {
long fee(Ticket ticket, Instant exitTime);
}
/** Per started hour, minimum one hour, rate by vehicle type (amounts in rupees). */
static final class HourlyPricing implements PricingStrategy {
private final Map<VehicleType, Long> ratePerHour;
HourlyPricing(Map<VehicleType, Long> ratePerHour) { this.ratePerHour = ratePerHour; }
public long fee(Ticket t, Instant exit) {
long minutes = Duration.between(t.entryTime(), exit).toMinutes();
long hours = Math.max(1, (minutes + 59) / 60);
return hours * ratePerHour.get(t.vehicle().type());
}
}
static final class ParkingLot {
private final List<Floor> floors;
private final SpotAllocationStrategy allocation;
private final PricingStrategy pricing;
private final Map<String, Ticket> activeTickets = new ConcurrentHashMap<>();
private final AtomicLong ticketSeq = new AtomicLong();
ParkingLot(List<Floor> floors, SpotAllocationStrategy allocation, PricingStrategy pricing) {
this.floors = floors;
this.allocation = allocation;
this.pricing = pricing;
}
Optional<Ticket> park(Vehicle vehicle, Instant now) {
return allocation.allocate(floors, vehicle).map(spot -> {
Ticket t = new Ticket("T" + ticketSeq.incrementAndGet(), vehicle, spot, now);
activeTickets.put(t.id(), t);
return t;
});
}
long unpark(String ticketId, Instant now) {
Ticket t = activeTickets.remove(ticketId);
if (t == null) throw new IllegalArgumentException("unknown or already used ticket " + ticketId);
t.spot().release();
return pricing.fee(t, now);
}
}
static List<Floor> buildFloors(int floorCount, int small, int medium, int large) {
List<Floor> floors = new ArrayList<>();
for (int f = 0; f < floorCount; f++) {
List<ParkingSpot> spots = new ArrayList<>();
for (int i = 0; i < small; i++) spots.add(new ParkingSpot(f + "-S" + i, SpotSize.SMALL));
for (int i = 0; i < medium; i++) spots.add(new ParkingSpot(f + "-M" + i, SpotSize.MEDIUM));
for (int i = 0; i < large; i++) spots.add(new ParkingSpot(f + "-L" + i, SpotSize.LARGE));
floors.add(new Floor(f, spots));
}
return floors;
}
public static void main(String[] args) throws Exception {
var pricing = new HourlyPricing(Map.of(VehicleType.BIKE, 20L, VehicleType.CAR, 50L, VehicleType.TRUCK, 100L));
var lot = new ParkingLot(buildFloors(2, 1, 2, 1), new LowestFloorBestFit(), pricing);
Instant nine = Instant.parse("2026-10-30T09:00:00Z");
Ticket bike = lot.park(new Vehicle("KA01-B-1", VehicleType.BIKE), nine).orElseThrow();
Ticket car = lot.park(new Vehicle("KA01-C-7", VehicleType.CAR), nine).orElseThrow();
Ticket truck = lot.park(new Vehicle("KA01-T-3", VehicleType.TRUCK), nine).orElseThrow();
System.out.println(bike.vehicle().plate() + " -> spot " + bike.spot().id);
System.out.println(car.vehicle().plate() + " -> spot " + car.spot().id);
System.out.println(truck.vehicle().plate() + " -> spot " + truck.spot().id);
// bike #2: the only small spot on floor 0 is taken, so best fit uses a medium one on floor 0
Ticket bike2 = lot.park(new Vehicle("KA01-B-2", VehicleType.BIKE), nine).orElseThrow();
System.out.println(bike2.vehicle().plate() + " -> spot " + bike2.spot().id);
System.out.println("car fee after 2h10m: Rs " + lot.unpark(car.id(), nine.plus(Duration.ofMinutes(130))));
System.out.println("bike fee after 20m: Rs " + lot.unpark(bike.id(), nine.plus(Duration.ofMinutes(20))));
try {
lot.unpark(car.id(), nine.plus(Duration.ofHours(3)));
} catch (IllegalArgumentException e) {
System.out.println("reuse ticket: " + e.getMessage());
}
// concurrency: 50 cars race for 20 car-capable spots (10 floors x 2 medium); no spot may be given twice
var big = new ParkingLot(buildFloors(10, 0, 2, 0), new LowestFloorBestFit(), pricing);
var parked = new AtomicInteger();
var spotsUsed = ConcurrentHashMap.<String>newKeySet();
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 50; i++) {
int n = i;
exec.submit(() -> big.park(new Vehicle("CAR-" + n, VehicleType.CAR), nine).ifPresent(t -> {
parked.incrementAndGet();
spotsUsed.add(t.spot().id);
}));
}
}
System.out.println("50 cars, 20 spots -> parked " + parked.get() + ", distinct spots " + spotsUsed.size());
}
}Output on JDK 25:
KA01-B-1 -> spot 0-S0
KA01-C-7 -> spot 0-M0
KA01-T-3 -> spot 0-L0
KA01-B-2 -> spot 0-M1
car fee after 2h10m: Rs 150
bike fee after 20m: Rs 20
reuse ticket: unknown or already used ticket T2
50 cars, 20 spots -> parked 20, distinct spots 20Walk the interviewer through the output. The second bike lands in a medium spot because the only small spot on floor 0 is taken, and "lowest floor first" beats "exact size" in our strategy. If the business prefers sending the bike to floor 1's small spot, that is a different SpotAllocationStrategy, and nothing else changes. That is the Open/Closed principle paying for itself in front of the interviewer.
Why use the Strategy pattern for pricing and allocation?
Because both are policies the business will change without changing the rest of the system. Weekend rates, a free first 15 minutes, monthly passes, "fill the top floor last", reserved spots for EVs: each becomes a new class implementing an existing interface. ParkingLot depends only on the interfaces (Dependency Inversion), so you can unit test it with a fixed-price fake and a deterministic allocator.
Passing Instant now into park and unpark serves the same goal. Fee logic that calls Instant.now() internally cannot be tested without waiting for an hour. In a real codebase you would inject a java.time.Clock instead.
How do you handle concurrency in a parking lot system?
The critical section is "find a free spot and mark it taken"; it must be atomic, or two gates can assign the same spot. Here each spot holds its occupant in an AtomicReference, and tryOccupy uses compareAndSet(null, vehicle). Only one thread can win; the loser simply moves on to the next spot. The concurrency test in main sends 50 cars through 50 virtual threads at 20 spots and gets exactly 20 tickets on 20 distinct spots.
Other valid answers, and when to pick them:
- A lock per floor (
ReentrantLock): simple to reason about, fine for a few gates. A single globalsynchronized park()also works, but it serializes every gate. - A free-spot queue per size (
ConcurrentLinkedDeque<ParkingSpot>): O(1) allocation instead of scanning, a good follow-up when the interviewer asks about 10,000 spots. - A database: if the lot state lives in a database shared by several servers, in-memory atomics no longer help. Use a conditional update ("set occupant where occupant is null") or a row lock, and treat "0 rows updated" as "spot taken, try another".
Active tickets live in a ConcurrentHashMap, and unpark uses remove, which is atomic. That is also what blocks a reused ticket, as the output shows.
What the interviewer is checking: that you can name the exact race (check-then-act on a spot) and fix it at the smallest scope, instead of wrapping the whole lot in one lock "to be safe".
Parking lot follow-up questions to expect
- "Add a display board per floor showing free spots." Observer: spots or the lot publish occupancy events; boards subscribe.
- "Add EV charging spots." A new spot capability plus an allocation strategy that prefers charger spots for EVs.
- "Support multiple entry and exit gates." Gates become thin objects calling the same thread-safe
ParkingLot. - "Handle a lost ticket." Look up the active ticket by plate number (add an index) and apply a lost-ticket fee rule in pricing.
- "Add payment." A
PaymentProcessorinterface with cash, card and UPI implementations;unparkreturns the fee, and payment is a separate step.
Problem 2: Design an LRU cache in Java
What is an LRU cache?
An LRU (least recently used) cache holds a fixed number of entries and, when it is full, evicts the entry that has not been read or written for the longest time. The classic interview version requires both get(key) and put(key, value) to run in O(1) time. It is the same problem as LeetCode 146: LRU Cache.
Why use a HashMap and a doubly linked list?
Because each structure covers the other's weakness. A HashMap finds a key in O(1) but has no order. A doubly linked list keeps entries in recency order and can unlink any node in O(1) if you already hold a pointer to it, but finding a node by key would be O(n). Store the node itself as the map's value, and every operation becomes "find in the map, then rewire four pointers".
Two sentinel nodes, head and tail, remove every null check: the list is never empty, so unlink and addFirst are four lines each with no special cases. Interviewers notice this.
LRU cache implementation in Java (HashMap + doubly linked list)
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.StringJoiner;
public class LruCacheDemo {
/** O(1) get and put: a HashMap for lookup + a doubly linked list for recency order. */
static final class LruCache<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); // head.next = most recently used
private final Node<K, V> tail = new Node<>(null, null); // tail.prev = least recently used
LruCache(int capacity) {
if (capacity <= 0) throw new IllegalArgumentException("capacity must be > 0");
this.capacity = capacity;
head.next = tail;
tail.prev = head;
}
V get(K key) {
Node<K, V> node = index.get(key);
if (node == null) return null; // miss
unlink(node); // hit: move to the front
addFirst(node);
return node.value;
}
/** Returns the evicted key, or null if nothing was evicted. */
K put(K key, V value) {
Node<K, V> node = index.get(key);
if (node != null) { // update: new value, now most recent
node.value = value;
unlink(node);
addFirst(node);
return null;
}
K evicted = null;
if (index.size() == capacity) { // full: drop the node before tail
Node<K, V> lru = tail.prev;
unlink(lru);
index.remove(lru.key);
evicted = lru.key;
}
node = new Node<>(key, value);
index.put(key, node);
addFirst(node);
return evicted;
}
private void unlink(Node<K, V> n) {
n.prev.next = n.next;
n.next.prev = n.prev;
}
private void addFirst(Node<K, V> n) {
n.next = head.next;
n.prev = head;
head.next.prev = n;
head.next = n;
}
@Override public String toString() { // most recent -> least recent
StringJoiner sj = new StringJoiner(", ", "[", "]");
for (Node<K, V> n = head.next; n != tail; n = n.next) sj.add(n.key + "=" + n.value);
return sj.toString();
}
}
/** The same policy in a few lines, using LinkedHashMap's access order. */
static final class LinkedLru<K, V> extends LinkedHashMap<K, V> {
private final int capacity;
LinkedLru(int capacity) {
super(16, 0.75f, true); // true = access order, not insertion order
this.capacity = capacity;
}
@Override protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > capacity;
}
}
public static void main(String[] args) {
LruCache<Integer, String> cache = new LruCache<>(3);
cache.put(1, "A");
cache.put(2, "B");
cache.put(3, "C");
System.out.println("after put 1,2,3 " + cache);
System.out.println("get(1) " + cache.get(1) + " -> " + cache);
System.out.println("put(4,D) evicts " + cache.put(4, "D") + " -> " + cache);
System.out.println("get(2) " + cache.get(2));
System.out.println("put(3,C2) evicts " + cache.put(3, "C2") + " -> " + cache);
System.out.println("put(5,E) evicts " + cache.put(5, "E") + " -> " + cache);
LinkedLru<Integer, String> lhm = new LinkedLru<>(3);
lhm.put(1, "A"); lhm.put(2, "B"); lhm.put(3, "C");
lhm.get(1);
lhm.put(4, "D");
System.out.println("LinkedHashMap LRU " + lhm + " (iteration = least -> most recent)");
}
}Output:
after put 1,2,3 [3=C, 2=B, 1=A]
get(1) A -> [1=A, 3=C, 2=B]
put(4,D) evicts 2 -> [4=D, 1=A, 3=C]
get(2) null
put(3,C2) evicts null -> [3=C2, 4=D, 1=A]
put(5,E) evicts 1 -> [5=E, 3=C2, 4=D]
LinkedHashMap LRU {3=C, 1=A, 4=D} (iteration = least -> most recent)Trace it with the interviewer. After get(1), key 1 moves to the front, so when put(4, D) needs room, key 2 is the least recently used and is evicted, not key 1. Updating an existing key (put(3, C2)) evicts nothing but makes key 3 the most recent. The custom class returns the evicted key so callers (and you, in the demo) can see eviction happen.
How do you implement an LRU cache with LinkedHashMap?
Extend LinkedHashMap, call the constructor with accessOrder = true, and override removeEldestEntry to return true when the size exceeds the capacity. In access-order mode, get, put and similar calls move the entry to the end of the iteration order, so the "eldest" entry is the least recently used one.
The LinkedLru class in the program above is the whole implementation. In an interview, mention it first ("in production I would use this or a library"), then implement the linked list version if they ask for it, which they usually do.
Two details that separate strong answers:
- The LinkedHashMap Javadoc notes that in an access-ordered map, merely calling
getis a structural modification. Concurrent reads are therefore not safe, which surprises many candidates. - Since Java 21,
LinkedHashMapimplementsSequencedMap(JEP 431: Sequenced Collections), so you can callfirstEntry()orpollFirstEntry()to inspect or remove the least recently used entry directly.
Is your LRU cache thread-safe?
No, and you should say so before being asked. Both versions mutate shared state on every call, including get. The simplest fix is to make get and put synchronized or guard them with a ReentrantLock. A ReadWriteLock does not help much here because get is a write to the recency list.
For high concurrency, mention lock striping (several smaller caches, each with its own lock, chosen by key hash) and, for real projects, a proven library such as Caffeine, which handles concurrency and uses a frequency-aware eviction policy (Window TinyLFU) instead of plain LRU. If you want the hashing side of this question in depth, read how HashMap works internally.
LRU cache follow-up questions
- "Add a time-to-live per entry." Store an expiry time in the node; treat expired entries as misses on
getand remove them lazily, plus optionally a scheduled cleanup. - "Design an LFU cache." A map from key to node, plus a map from frequency to a doubly linked list of nodes, and a running minimum frequency. Still O(1).
- "Notify when something is evicted." Accept an eviction listener (a
BiConsumer<K, V>) in the constructor: Observer in one line.
Problem 3: Splitwise low level design
Requirements
- Users add expenses: who paid, how much, and how it is split.
- Split types: equal, exact amounts, and percentages. More types will come later.
- Show each person's net balance and who owes whom.
- "Simplify debts": settle the group with as few payments as reasonable.
- Reject invalid splits (exact amounts that do not add up, percentages that are not 100).
Entities and the split strategy
The core types are Expense (description, payer, amount, split), SplitStrategy with three implementations, BalanceSheet, and Payment. Each split strategy is a record implementing one method, shares(amount), so the strategy carries its own data (the list of people or the percentages) and validates itself. Adding a "split by shares" type (2 shares for adults, 1 for kids) is one more record.
Money is the trap in this problem. Never use double for currency. Store amounts as a long in the smallest unit (paise or cents), or use BigDecimal. The demo uses whole rupees to keep the output readable, but the rounding rule is still there: an equal split of 100 among 3 gives 34, 33, 33, never 33.33 three times with a missing paisa.
Splitwise code in Java
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.PriorityQueue;
import java.util.TreeMap;
public class SplitwiseDemo {
/** Strategy: turns an amount into each person's share. Amounts are whole rupees here; use paise in production. */
interface SplitStrategy {
Map<String, Long> shares(long amount);
}
record EqualSplit(List<String> people) implements SplitStrategy {
public Map<String, Long> shares(long amount) {
long base = amount / people.size(), extra = amount % people.size();
Map<String, Long> out = new LinkedHashMap<>();
for (int i = 0; i < people.size(); i++) out.put(people.get(i), base + (i < extra ? 1 : 0));
return out;
}
}
record ExactSplit(Map<String, Long> amounts) implements SplitStrategy {
public Map<String, Long> shares(long amount) {
long sum = amounts.values().stream().mapToLong(Long::longValue).sum();
if (sum != amount) throw new IllegalArgumentException("exact shares add up to " + sum + ", not " + amount);
return amounts;
}
}
record PercentSplit(Map<String, Integer> percents) implements SplitStrategy {
PercentSplit { percents = new TreeMap<>(percents); } // sorted by name: the remainder rule is deterministic
public Map<String, Long> shares(long amount) {
if (percents.values().stream().mapToInt(Integer::intValue).sum() != 100)
throw new IllegalArgumentException("percentages must add up to 100");
Map<String, Long> out = new LinkedHashMap<>();
long given = 0;
for (var e : percents.entrySet()) {
long share = amount * e.getValue() / 100;
out.put(e.getKey(), share);
given += share;
}
String first = percents.keySet().iterator().next();
out.merge(first, amount - given, Long::sum); // rounding remainder goes to the first person by name
return out;
}
}
record Expense(String description, String paidBy, long amount, SplitStrategy split) {}
record Payment(String from, String to, long amount) {
@Override public String toString() { return from + " pays " + to + " Rs " + amount; }
}
static final class BalanceSheet {
// net > 0: the group owes this person; net < 0: this person owes the group
private final Map<String, Long> net = new TreeMap<>();
// pairwise view (debtor -> creditor -> amount), only to show the "before" picture
private final Map<String, Map<String, Long>> owes = new TreeMap<>();
void add(Expense e) {
e.split().shares(e.amount()).forEach((person, share) -> {
net.merge(e.paidBy(), share, Long::sum);
net.merge(person, -share, Long::sum);
if (!person.equals(e.paidBy())) addDebt(person, e.paidBy(), share);
});
}
private void addDebt(String debtor, String creditor, long amount) {
long reverse = owes.getOrDefault(creditor, Map.of()).getOrDefault(debtor, 0L);
long cancel = Math.min(reverse, amount); // A owes B and B owes A: net them off
if (cancel > 0) owes.get(creditor).merge(debtor, -cancel, Long::sum);
if (amount > cancel) owes.computeIfAbsent(debtor, k -> new TreeMap<>()).merge(creditor, amount - cancel, Long::sum);
}
List<Payment> pairwiseDebts() {
List<Payment> out = new ArrayList<>();
owes.forEach((from, m) -> m.forEach((to, amt) -> { if (amt > 0) out.add(new Payment(from, to, amt)); }));
return out;
}
/** Greedy: largest debtor pays largest creditor. At most n-1 payments. */
List<Payment> simplify() {
Comparator<Map.Entry<String, Long>> byAbs = Comparator.comparingLong(en -> -Math.abs(en.getValue()));
var creditors = new PriorityQueue<>(byAbs);
var debtors = new PriorityQueue<>(byAbs);
net.forEach((p, v) -> {
if (v > 0) creditors.add(Map.entry(p, v));
if (v < 0) debtors.add(Map.entry(p, v));
});
List<Payment> payments = new ArrayList<>();
while (!creditors.isEmpty() && !debtors.isEmpty()) {
var c = creditors.poll();
var d = debtors.poll();
long amt = Math.min(c.getValue(), -d.getValue());
payments.add(new Payment(d.getKey(), c.getKey(), amt));
if (c.getValue() - amt > 0) creditors.add(Map.entry(c.getKey(), c.getValue() - amt));
if (d.getValue() + amt < 0) debtors.add(Map.entry(d.getKey(), d.getValue() + amt));
}
return payments;
}
}
public static void main(String[] args) {
var sheet = new BalanceSheet();
List<String> all = List.of("Asha", "Bala", "Chitra", "Dev");
sheet.add(new Expense("Dinner", "Asha", 2400, new EqualSplit(all)));
sheet.add(new Expense("Cab", "Bala", 900, new EqualSplit(List.of("Bala", "Chitra", "Dev"))));
sheet.add(new Expense("Tickets", "Chitra", 1000, new ExactSplit(Map.of("Asha", 400L, "Dev", 600L))));
sheet.add(new Expense("Hotel", "Dev", 3000, new PercentSplit(Map.of("Asha", 50, "Bala", 50))));
System.out.println("Net balances: " + sheet.net);
System.out.println("Before (pairwise debts):");
sheet.pairwiseDebts().forEach(p -> System.out.println(" " + p));
System.out.println("After simplify:");
sheet.simplify().forEach(p -> System.out.println(" " + p));
try {
new ExactSplit(Map.of("Asha", 100L)).shares(150);
} catch (IllegalArgumentException e) {
System.out.println("Validation: " + e.getMessage());
}
}
}Output:
Net balances: {Asha=-100, Bala=-1500, Chitra=100, Dev=1500}
Before (pairwise debts):
Asha pays Dev Rs 900
Bala pays Asha Rs 600
Bala pays Dev Rs 1200
Chitra pays Asha Rs 200
Chitra pays Bala Rs 300
Dev pays Chitra Rs 600
After simplify:
Bala pays Dev Rs 1500
Asha pays Chitra Rs 100
Validation: exact shares add up to 100, not 150How does Splitwise simplify debts?
Forget who paid for what and compute each person's net balance: total paid minus total owed. Positive means the group owes them; negative means they owe the group; the balances always sum to zero. Then repeatedly match the largest debtor with the largest creditor, transfer the smaller of the two amounts, and put whoever still has a balance back into the heap. Each payment settles at least one person, so a group of n people needs at most n − 1 payments.
In our trip, four expenses produced six pairwise debts moving Rs 3,800 around. Net balances are Asha −100, Bala −1,500, Chitra +100 and Dev +1,500, so two payments settle everything: Bala pays Dev 1,500 and Asha pays Chitra 100.
Is the greedy debt simplification optimal?
Not always, and saying so is a strong signal. Greedy guarantees at most n − 1 payments. The true minimum is n minus the largest number of groups you can split the people into where each group's balances sum to zero, because a group of k people always needs k − 1 payments. Finding those groups is a hard combinatorial search (it generalizes the subset-sum problem), so exact solutions use backtracking and only work for small groups.
Our example happens to split into two zero-sum pairs ({Asha, Chitra} and {Bala, Dev}), which is why greedy found the optimum of 2. For an interview, implement greedy, state the n − 1 bound, and mention the exact approach as a follow-up.
What the interviewer is checking: that you separate "recording expenses" from "settling up", reason in net balances, and handle money without floating-point errors.
Splitwise follow-up questions
- "Add groups." A
Groupowns members and its ownBalanceSheet; a user's overall balance is the sum across groups. - "Edit or delete an expense." Keep expenses as an append-only list and apply a reversing entry, or recompute balances from the list. Both are easier than patching balances in place.
- "Multiple currencies." Keep a balance per currency per person; only convert when settling, with an explicit rate.
- "Notify people when an expense is added." Observer, with an email or push notifier behind an interface.
How to write code in a machine coding round
Design skill only counts if the code shows it. A few habits make a large difference in how your solution reads:
- Start from a running skeleton. A
mainthat creates the system and calls one operation, compiled within the first 15 minutes. Then grow it. - Use modern Java where it helps. Records for value objects, enums with behavior,
Optionalfor "may not exist", sealed interfaces for closed hierarchies,varfor long generic types. It shows you are current without being clever. - Keep storage in memory, behind an interface. A
TicketRepositorybacked by aConcurrentHashMapis enough; say that a JPA implementation would slot in later. - Fail loudly on invalid input. Throw
IllegalArgumentExceptionor a small custom exception with a clear message. Silentreturn nullcosts points. - Write a few tests if time allows. Even three JUnit tests for the core rules (fee rounding, eviction order, split validation) set you apart.
- Do not over-engineer. Interfaces where variation is real, not on every class. An abstract factory of factories for a 90-minute exercise reads as a red flag.
- Leave 10 minutes for the walkthrough. Explain the design, the trade-offs you made, and what you would do with another hour.
15 more low level design interview questions to practice
Practice each the same way: requirements, entities, class diagram, the one or two patterns that matter, a runnable core flow. The hint is the idea that unlocks each one.
- Elevator system: State for each elevator (idle, moving up, moving down, doors open) and a Strategy for choosing which elevator answers a call.
- Vending machine: the textbook State pattern; illegal transitions (dispense before payment) must be impossible.
- Movie ticket booking (BookMyShow-style): temporary seat holds with expiry, and preventing double booking of the same seat.
- Rate limiter: token bucket versus sliding window behind one interface. See our rate limiter design walkthrough.
- Snake and ladder: a board of jumps (map from cell to cell), players in a queue, and an injectable dice for tests.
- Tic-tac-toe: O(1) win detection with row, column and diagonal counters instead of scanning the board.
- Logging framework: Chain of Responsibility for levels, Strategy for appenders (console, file), Singleton-like logger registry.
- Library management: book versus book copy (title versus physical item), borrowing rules and fines as a strategy.
- Food delivery order flow: order lifecycle as a state machine, restaurant and delivery-partner assignment as strategies.
- Cab booking: driver matching strategy (nearest, highest rated), trip states, and fare calculation.
- Notification service: Factory for channels (email, SMS, push), user preferences, retries.
- Shopping cart with coupons: Decorator or a list of discount rules applied in order; rounding money correctly.
- Meeting room scheduler: interval overlap checks, keeping bookings sorted per room (a
TreeMapkeyed by start time). - In-memory pub-sub queue: topics, subscribers with their own offsets, and thread-safe publishing.
- Hotel booking: room types, availability across date ranges, and cancellation policies as strategies.
Several of these lean on standard data structure tricks (heaps, interval sorting, two maps for O(1) operations). The DSA patterns sheet in Java is a good companion while you practice.
Practice with the full question bank
If you want structured practice beyond the three problems here:
- Java OOP, SOLID and Low-Level Design: 52 Interview Questions: the four OOP pillars, SOLID, polymorphism in depth, and eight low-level design problems worked out in Java.
- Design Patterns in Java Interview Questions: 50 Answers: Singleton pitfalls, Factory, Builder, Adapter, Decorator, Proxy and Spring AOP, Strategy, Observer, Visitor with sealed types, and how to choose a pattern.
Related reading
- System Design Interview: A Step-by-Step Framework + 15 Practice Problems
- DSA Patterns Sheet in Java: 15 Patterns That Solve Most Coding Interview Problems
- How HashMap Works Internally in Java (With Diagrams)
FAQ
How long is a low-level design interview?
Discussion-style LLD rounds usually take 45 to 60 minutes. Machine coding rounds, where you write runnable code, are often longer, commonly 60 to 120 minutes plus a review. Ask your recruiter for the exact format, since it varies by company and team.
Can I use Java for a machine coding round?
Yes. Java is one of the most common choices because its type system and interfaces make design decisions visible. Use the language you write fastest in, and use modern features such as records and enums to keep the code short.
Do I need to draw UML in an LLD interview?
You do not need formal UML. A quick class sketch showing the main classes, key fields and methods, and arrows for "has" and "uses" is enough. Spend most of the time on code and trade-offs.
Are low level design questions asked to freshers?
Sometimes, usually in a lighter form: design a parking lot or a library at the class level, or explain OOP and SOLID with examples. Full machine coding rounds are more common for candidates with one or more years of experience.
What is the most asked LLD interview question?
Parking lot, LRU cache, Splitwise, elevator, vending machine, BookMyShow and snake and ladder come up again and again. Master the first three on this page and the patterns behind them, and the rest become variations.
Should I write unit tests in a machine coding round?
If the time allows, yes. A handful of focused tests on the core rules shows engineering maturity. If time is short, a demo main that exercises the main flows and edge cases is the minimum.




