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.

Watch it evolve1 / 9
The problem

A station's parcel lockers

Parcels go into the first free compartment they fit, and a pickup code opens it once.

80
Start
Stage 1
Stage 2
Final

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:

Courier appLocker servicelow-level design zooms inDatabaseLocker bankscreen and doorsNotificationsdrop a parcelsaveopen door 3send the codeCourier appLocker servicelow-level design zooms inDatabaseLocker bankscreen and doorsNotificationsdrop a parcelsaveopen door 3send the code

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.

Straight from the rulesJava
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.

Every piece of state exists onceJava
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 Compartment with 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.

The design: four typesJava
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();
    }
}
0..1*«enum»SizeSML«record»Parcel− id: String− size: SizeCompartment− number: int− size: Size− parcel: Parcel− code: String~ number(): int~ code(): String~ fits(Parcel): boolean~ store(Parcel, String)~ opensWith(String): boolean~ release(): ParcelLockerBank− compartments: List<Compartment>− nextCode: int~ drop(Parcel): Optional<Compartment>~ pickup(String): Optional<Parcel>

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:

A second locker bankJava
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.

Script
6
Design
0

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?

Size, Parcel, Compartment, LockerBank

Responsibilities

What does each one know and do?

A compartment answers fits(); the bank hands out codes

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?

drop() asks each compartment fits(), then store()

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

1. Which of these is a low-level design question rather than a high-level one?
2. Why does the first version struggle with a second station?
3. Where does fits(parcel) belong in the design?
4. The operator adds an extra-large size. In the design, what has to change?

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.