Low-level designCore8 min
What low-level design is
High-level design decides which services and stores a system has. Low-level design zooms into one of them and decides which objects exist, what each one knows and does, and how they call each other.
A station's parcel lockers
Parcels go into the first free compartment they fit, and a pickup code opens it once.
1 · Two zoom levels
The same system, seen from two heights
A train station has a bank of parcel lockers. A courier drops a parcel, the locker picks a compartment, and the recipient gets a pickup code by text. Seen from above, that's a handful of parts talking over a network:
The high-level view: services, a database and a queue, and what travels between them.
That picture is high-level design (HLD): which services and stores exist, what they send each other, and how the whole keeps up with load and survives failures. It says nothing about the code inside the locker service. Low-level design (LLD) is that inside: the classes, what each one knows and does, and how they call each other to get a job done.
Aspect | High-level design | Low-level design |
|---|---|---|
The parts | Services, databases, queues, caches | Classes, interfaces, enums, and their methods |
What it decides | Where data lives, how parts talk, how it scales | Which objects exist, what each owns, who calls whom |
Its diagrams | Boxes and arrows between services | Class diagrams and sequence diagrams |
A typical question | How do 200 stations report free compartments? | Which class picks the compartment for a parcel? |
Judged on | Latency, throughput, availability, cost | Clarity, correctness, and how easily it changes |
2 · The situation
Code that works, before anyone designs it
Inside the locker service, the rules are simple:
- compartments come in three sizes, small, medium and large, numbered from the smallest up;
- a parcel goes into the first free compartment it fits, and gets a pickup code;
- the code opens that compartment once, and frees it.
Here is a first version written straight from those rules, with no design step. The state lives in a few arrays and a map, and two functions change it. Java needs a class to hold them, so it uses one with only static members; the other languages keep them at the top level of the file.
import java.util.HashMap;
import java.util.Map;
class Lockers {
// Compartment i has size SIZES[i] (smallest first) and holds PARCELS[i], or null.
static final String[] SIZES = {"S", "S", "M", "L"};
static final String[] PARCELS = new String[SIZES.length];
static final Map<String, Integer> CODES = new HashMap<>();
static int nextCode = 1001;
// Puts the parcel in the first free compartment that fits; returns its pickup code, or null.
static String drop(String parcel, String size) {
for (int i = 0; i < SIZES.length; i++) {
if (PARCELS[i] == null && "SML".indexOf(SIZES[i]) >= "SML".indexOf(size)) {
PARCELS[i] = parcel;
String code = String.valueOf(nextCode++);
CODES.put(code, i);
return code;
}
}
return null;
}
// Empties the compartment the code opens; returns its parcel, or null.
static String pickup(String code) {
Integer i = CODES.remove(code);
if (i == null) return null;
String parcel = PARCELS[i];
PARCELS[i] = null;
return parcel;
}
}
public class Main {
static void drop(String parcel, String size) {
String code = Lockers.drop(parcel, size);
System.out.println(parcel + " (" + size + "): "
+ (code == null ? "no compartment free"
: "code " + code + ", compartment " + (Lockers.CODES.get(code) + 1)));
}
static void pickup(String code, String label) {
String parcel = Lockers.pickup(code);
System.out.println(label + ": " + (parcel == null ? "nothing" : parcel));
}
public static void main(String[] args) {
drop("P1", "M");
drop("P2", "S");
drop("P3", "L");
drop("P4", "L");
pickup("1001", "Code 1001");
pickup("1001", "Code 1001 again");
}
}It works. A medium parcel lands in compartment 3, a small one in compartment 1, a large one in compartment 4, and the next large one is turned away:
P1 (M): code 1001, compartment 3 P2 (S): code 1002, compartment 1 P3 (L): code 1003, compartment 4 P4 (L): no compartment free Code 1001: P1 Code 1001 again: nothing
3 · The change
A second station has nowhere to go
The operator installs a second locker bank at the harbor, with one medium and one large compartment. The program has exactly one set of compartments: SIZES, PARCELS, CODES and nextCode exist once, and drop() and pickup() read them directly.
import java.util.HashMap;
import java.util.Map;
class Lockers {
// Compartment i has size SIZES[i] (smallest first) and holds PARCELS[i], or null.
static final String[] SIZES = {"S", "S", "M", "L"};
static final String[] PARCELS = new String[SIZES.length];
static final Map<String, Integer> CODES = new HashMap<>();
static int nextCode = 1001;
// Puts the parcel in the first free compartment that fits; returns its pickup code, or null.
static String drop(String parcel, String size) {
for (int i = 0; i < SIZES.length; i++) {
if (PARCELS[i] == null && "SML".indexOf(SIZES[i]) >= "SML".indexOf(size)) {
PARCELS[i] = parcel;
String code = String.valueOf(nextCode++);
CODES.put(code, i);
return code;
}
}
return null;
}
// Empties the compartment the code opens; returns its parcel, or null.
static String pickup(String code) {
Integer i = CODES.remove(code);
if (i == null) return null;
String parcel = PARCELS[i];
PARCELS[i] = null;
return parcel;
}
}
public class Main {
static void drop(String parcel, String size) {
String code = Lockers.drop(parcel, size);
System.out.println(parcel + " (" + size + "): "
+ (code == null ? "no compartment free"
: "code " + code + ", compartment " + (Lockers.CODES.get(code) + 1)));
}
static void pickup(String code, String label) {
String parcel = Lockers.pickup(code);
System.out.println(label + ": " + (parcel == null ? "nothing" : parcel));
}
public static void main(String[] args) {
drop("P1", "M");
drop("P2", "S");
drop("P3", "L");
drop("P4", "L");
pickup("1001", "Code 1001");
pickup("1001", "Code 1001 again");
}
}Supporting the harbor means changing all six: each piece of state becomes one per locker bank, and each function needs to be told which bank it works on. Nothing here is a bug. The code simply never said what a locker bank is, so there's no way to make another one.
That is the question low-level design asks first: what are the things in this problem, and what does each one know and do?
4 · The design
Name the things, then give each its job
Read the rules again and pick out the things they talk about:
- a size, one of three, in order: an enum
Size; - a parcel, with an id and a size that never change: a small value type,
Parcel; - a compartment, with a number, a size, and the parcel and code it holds right now: a class
Compartmentwith state; - a locker bank, which owns its compartments and hands out codes: a class
LockerBank.
Then give each one the work that uses what it knows. A compartment knows its size and whether it's empty, so it answers fits(parcel). The bank knows all its compartments and the next code, so it runs drop() and pickup() by asking them.
enum Size { S, M, L }
record Parcel(String id, Size size) {}
class Compartment {
private final int number;
private final Size size;
private Parcel parcel;
private String code;
Compartment(int number, Size size) {
this.number = number;
this.size = size;
}
int number() {
return number;
}
String code() {
return code;
}
boolean fits(Parcel p) {
return parcel == null && size.compareTo(p.size()) >= 0;
}
void store(Parcel p, String pickupCode) {
parcel = p;
code = pickupCode;
}
boolean opensWith(String pickupCode) {
return parcel != null && code.equals(pickupCode);
}
Parcel release() {
Parcel p = parcel;
parcel = null;
code = null;
return p;
}
}
class LockerBank {
private final List<Compartment> compartments = new ArrayList<>();
private int nextCode = 1001;
// Compartments are numbered from 1, smallest first.
LockerBank(Size... sizes) {
for (Size s : sizes) compartments.add(new Compartment(compartments.size() + 1, s));
}
Optional<Compartment> drop(Parcel p) {
for (Compartment c : compartments) {
if (c.fits(p)) {
c.store(p, String.valueOf(nextCode++));
return Optional.of(c);
}
}
return Optional.empty();
}
Optional<Parcel> pickup(String code) {
for (Compartment c : compartments) {
if (c.opensWith(code)) return Optional.of(c.release());
}
return Optional.empty();
}
}The class diagram, drawn from the code: a LockerBank owns its compartments, and each Compartment may hold a Parcel.
One drop, as calls between objects. Compartments 1 and 2 answer fits() with false first; they're left out here.
It prints exactly what the script printed. The behavior is the same; what changed is that the code now says what a compartment and a locker bank are.
5 · The payoff
Two stations, no class changed
With LockerBank as a class, the harbor is one more object. The classes stay exactly as they were; only the code that sets things up grows:
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
enum Size { S, M, L }
record Parcel(String id, Size size) {}
class Compartment {
private final int number;
private final Size size;
private Parcel parcel;
private String code;
Compartment(int number, Size size) {
this.number = number;
this.size = size;
}
int number() {
return number;
}
String code() {
return code;
}
boolean fits(Parcel p) {
return parcel == null && size.compareTo(p.size()) >= 0;
}
void store(Parcel p, String pickupCode) {
parcel = p;
code = pickupCode;
}
boolean opensWith(String pickupCode) {
return parcel != null && code.equals(pickupCode);
}
Parcel release() {
Parcel p = parcel;
parcel = null;
code = null;
return p;
}
}
class LockerBank {
private final List<Compartment> compartments = new ArrayList<>();
private int nextCode = 1001;
// Compartments are numbered from 1, smallest first.
LockerBank(Size... sizes) {
for (Size s : sizes) compartments.add(new Compartment(compartments.size() + 1, s));
}
Optional<Compartment> drop(Parcel p) {
for (Compartment c : compartments) {
if (c.fits(p)) {
c.store(p, String.valueOf(nextCode++));
return Optional.of(c);
}
}
return Optional.empty();
}
Optional<Parcel> pickup(String code) {
for (Compartment c : compartments) {
if (c.opensWith(code)) return Optional.of(c.release());
}
return Optional.empty();
}
}
public class Main {
static void drop(LockerBank bank, Parcel p) {
System.out.println(p.id() + " (" + p.size() + "): " + bank.drop(p)
.map(c -> "code " + c.code() + ", compartment " + c.number())
.orElse("no compartment free"));
}
static void pickup(LockerBank bank, String code, String label) {
System.out.println(label + ": " + bank.pickup(code).map(Parcel::id).orElse("nothing"));
}
public static void main(String[] args) {
LockerBank central = new LockerBank(Size.S, Size.S, Size.M, Size.L);
System.out.println("Central:");
drop(central, new Parcel("P1", Size.M));
drop(central, new Parcel("P2", Size.S));
drop(central, new Parcel("P3", Size.L));
drop(central, new Parcel("P4", Size.L));
pickup(central, "1001", "Code 1001");
pickup(central, "1001", "Code 1001 again");
LockerBank harbor = new LockerBank(Size.M, Size.L);
System.out.println("Harbor:");
drop(harbor, new Parcel("P5", Size.L));
}
}Central: P1 (M): code 1001, compartment 3 P2 (S): code 1002, compartment 1 P3 (L): code 1003, compartment 4 P4 (L): no compartment free Code 1001: P1 Code 1001 again: nothing Harbor: P5 (L): code 1001, compartment 2
Each bank has its own compartments and its own codes, so the harbor's first code is 1001 too.
Places that assume there is only one locker bank. Script: four pieces of state and two functions. Design: none.
6 · What an answer contains
From rules to classes, in six parts
Whether it's written down for a team or worked through in an interview, a low-level design answers the same questions, roughly in this order. The next page, How to approach an LLD problem, walks through them on a new problem.
Part | The question | For the lockers |
|---|---|---|
Scope | What must it do, and what is left out? | Drop and pick up; no payments, no expiry |
Entities | What are the things in the problem? |
|
Responsibilities | What does each one know and do? | A compartment answers |
Relationships | Who owns or refers to whom? | A bank owns its compartments; a compartment holds a parcel |
Interactions | Which calls carry out each use case? |
|
Code | Do the core classes work? | The classes above, run on a few parcels |
7 · What it's judged on
Clear, correct, and easy to change
A low-level design isn't judged on how many patterns it uses or how polished its diagrams are. A reviewer, or an interviewer, is asking whether the next person could understand it, trust it, and change it.
Quality | What a reviewer looks for |
|---|---|
Correct | The use cases work, including the edges: nothing fits, a code used twice |
Clear responsibilities | Each class has one job you can name; the rules live next to the data they use |
Encapsulated | State changes only through methods that keep it valid; nobody else edits a compartment's parcel |
Easy to change | A likely change, such as a new size or another station, touches few classes |
Simple | No class, interface or pattern the problem doesn't need yet |
Explained | The trade-offs are said out loud: why an enum, why the bank and not the compartment hands out codes |
Interviews add time pressure, so the usual advice is to agree on scope first, sketch the classes, walk one or two use cases through them, and code the core rather than every getter.
8 · In practice
Where the rest of this section goes
The pages that follow build the toolkit in order: how to approach a problem and draw its diagrams, how objects and types work, the design principles, then the patterns. The practice problems ask for a full design, such as a parking lot or a vending machine, and the checks read your classes the way a reviewer would.
Design the parking lot, in code, when you're ready to try one.
Check yourself
4 questions
Takeaways
Remember this
- High-level design decides the services and stores; low-level design decides the classes inside one of them.
- Low-level design starts by naming the things in a problem and what each one knows and does.
- Behavior goes next to the data it uses: the compartment answers whether a parcel fits.
- A design is judged on being correct, clear, encapsulated, simple and easy to change, not on how many patterns it uses.
- State that exists only once, at the top level, is the first thing a second instance of anything breaks.