Guide contents

Lesson 2 of 31

The Low-Level Design Interview Framework

A low-level design interview gives about 45 minutes and a short prompt. Without a process, candidates spend the first twenty minutes drawing classes that never turn into code. The framework below is a fixed order of steps with a time budget, so the design converges on working code and leaves time for the follow-up question the interviewer is going to ask.

Step Goal Time
1. Clarify requirements Agree on scope and rules 5 min
2. Identify entities Nouns become candidate types 5 min
3. Define relationships Who owns what, who refers to what 5 min
4. Design the public API The operations a caller uses 5 min
5. Choose patterns where the problem needs them Handle the parts that vary 5 min
6. Implement the core flow Running code for the main scenario 15 min
7. Extend and discuss Absorb a new requirement, name trade-offs 5 min

The order matters more than the exact minutes. The most common failure is skipping from the prompt straight to step 6, or spending the whole session on step 2.

Step 1: Clarify requirements

The prompt is deliberately incomplete. Asking questions is part of the evaluation. Split the answers into three lists and write them down.

  • Functional requirements. What operations must work? For a parking lot: park a vehicle, issue a ticket, free the spot on exit, compute the fee.
  • Rules and constraints. Facts that limit the design: several vehicle sizes, a fixed number of spots, fees by the hour, payment by cash or card.
  • Out of scope. What will not be built: a user interface, persistence, real payment processing, networking. Say it aloud so the interviewer can correct it.

Useful questions are about behavior at the edges: "What happens when the lot is full?", "Can a request fail, and how should the caller learn about it?", "Is this used by one thread or many?", "Which parts are likely to change?" The last one tells you where to spend design effort.

Step 2: Identify entities

Read the requirements and underline the nouns. Each noun is a candidate type, and each verb is a candidate operation on one of them. Then filter:

  • A noun with its own data and rules becomes a class (Ticket, Account).
  • A noun that is one of a fixed set of choices becomes an enum constant (VehicleKind, Coin).
  • A noun that is a behavior which varies becomes an interface (PricingStrategy).
  • A noun that is only a value (an amount, an ID) becomes a record or a plain field.

Do not create a type for every noun. A type should earn its place by holding data that must stay consistent or by hiding a decision that may change.

Step 3: Define relationships

For each pair of related types, decide the relationship, because it changes the code and the lifetime of the objects.

Relationship Meaning Java form
Composition ("has, and owns") the part lives and dies with the whole a private field created by the whole: private final List<Level> levels
Association ("refers to") independent lifetimes an ID or an object reference: record Ticket(SpotId spot, ...)
Dependency ("uses temporarily") a parameter or local variable Money fee(Ticket ticket)
Inheritance ("is a kind of") a shared interface with several implementations an interface, or a sealed interface for a closed set

The rule of thumb: own things in a tree and refer to independent things by ID. A ticket that stores a reference to its spot, which stores a reference to its level, which stores a reference back to the lot, is a cyclic graph. It works, but every object keeps the others reachable, and serializing or copying it needs care. A ticket that stores a SpotId has no cycle and is easy to serialize. Object graphs and references covers this in detail.

A short class diagram with these relationships is worth drawing, but keep it to the important types.

Step 4: Design the public API

Write the signatures of the operations a caller needs, before any bodies. This is the contract of the design and it exposes problems early.

  • Which operations can fail? Name the failure: a specific exception type, or a result type that lists the reasons. See errors in domain design.
  • Which operations mutate? Which only read? Name them so the reader can tell (park, leave against freeSpots), and return immutable values (records, List.copyOf) from the reads.
  • Which values end their life? An operation that ends something (checkout, close) can return a result and make the object reject later calls with IllegalStateException.
  • Which arguments are data and which are behavior? Behavior is a good candidate for an interface or lambda parameter.

The sketch below is a signature list and is not compiled, so it is marked java,ignore.

Java
interface ParkingLot {
    Ticket park(Vehicle vehicle);            // throws LotFullException
    Money leave(Ticket ticket);              // throws UnknownTicketException
    int freeSpots();
}

Two operations that mutate and one that reads, with the failure modes written next to the signatures. That already communicates most of the design.

Step 5: Choose patterns where the problem needs them

Look for the parts that vary and ask what should change when the requirement changes.

Signal in the requirements Pattern Java form
"different fee rules", "several algorithms" Strategy interface with lambdas or implementations
"behaves differently depending on its state" State sealed interface with a record per state, or an enum
"notify others when something happens" Observer list of listener interfaces or lambdas
"undo", "queue of operations" Command Command interface or sealed interface of records
"create one of several types by name or config" Factory static method returning an interface type
"many optional settings" Builder builder class or a record with a nested builder
"a tree of parts treated uniformly" Composite interface with a leaf class and a container class that holds a List of the interface

If nothing in the requirements varies, use no pattern. A pattern that solves a problem the design does not have is over-engineering, and interviewers notice it. Each low-level design lesson in this course applies the patterns it needs to a complete problem.

Step 6: Implement the core flow

Write the code for the main scenario and run it. In a live interview, that means a main method that exercises the happy path and one failure path. Prioritize:

  1. Domain types with private fields and a constructor that validates.
  2. The orchestrating service with the operations from step 4.
  3. One or two policies behind interfaces, with a simple implementation each.
  4. A main method that prints results.

Skip what the interviewer did not ask for: full error message polish, exhaustive tests, every edge case. Note skipped work aloud ("I would add persistence behind an interface here") so it is clear the omission was a choice.

Step 7: Extend and discuss

Interviewers close with a change request: "Now support reserved spots", "Add a second payment method", "What if two people call this at once?" A good design absorbs the change in one or two places. Explain which:

  • A new variant of a closed set: add an enum constant or a record that implements the sealed interface, and the compiler reports every switch expression that must handle it.
  • A new implementation of an open behavior: add a class that implements the interface, and no existing code changes.
  • Concurrent callers: guard the service with one lock (synchronized or ReentrantLock), or split it so that independent parts have their own locks. See thread-safe components.

End by naming the trade-offs you made and what you would improve with more time.

The framework applied to a tiny problem

Prompt: "Design a library that lends books." Here is what each step would produce in about a minute of speaking.

Requirements. Add books, register members, lend a book, return a book. A book has one copy. A member may hold at most three books. Lending an already-lent book fails. No persistence, no fines (mention as an extension).

Entities. Book (class: has an ID and title), Member, Library (the service). The "lent or available" status is a choice from a fixed set, so it is a sealed interface, not a boolean.

Relationships. The Library owns books and members in maps keyed by BookId and MemberId. A loan is an association between them, stored as the book's status LentTo(MemberId).

API.

Java
final class Library {
    void lend(BookId book, MemberId member);   // throws LibraryException
    void giveBack(BookId book);                // throws LibraryException
}

Patterns. The limit of three books might change, so it is a parameter of the Library, not a hard-coded number. No pattern is needed yet. Notifications when a reserved book returns would be an observer, and that goes into the extension discussion.

Core flow. The full implementation follows. The failure reasons are a sealed interface of records, carried by one unchecked exception. The exception is unchecked so that the signatures stay short, and the reason is a value that a caller can match with a switch.

Java
import java.util.HashMap;
import java.util.Map;

public class Main {
    record BookId(int value) {}
    record MemberId(int value) {}

    sealed interface LibraryError {
        record UnknownBook(BookId book) implements LibraryError {}
        record UnknownMember(MemberId member) implements LibraryError {}
        record AlreadyLent(BookId book) implements LibraryError {}
        record NotLent(BookId book) implements LibraryError {}
        record LimitReached(MemberId member) implements LibraryError {}
    }

    static final class LibraryException extends RuntimeException {
        private final LibraryError error;

        LibraryException(LibraryError error) {
            super(error.toString());
            this.error = error;
        }

        LibraryError error() {
            return error;
        }
    }

    sealed interface Status {
        record Available() implements Status {}
        record LentTo(MemberId member) implements Status {}
    }

    static final class Book {
        final String title;
        Status status = new Status.Available();

        Book(String title) {
            this.title = title;
        }
    }

    static final class Library {
        private final Map<BookId, Book> books = new HashMap<>();
        private final Map<MemberId, String> members = new HashMap<>();
        private final int maxLoans;

        Library(int maxLoans) {
            this.maxLoans = maxLoans;
        }

        BookId addBook(int id, String title) {
            var bookId = new BookId(id);
            books.put(bookId, new Book(title));
            return bookId;
        }

        MemberId addMember(int id, String name) {
            var memberId = new MemberId(id);
            members.put(memberId, name);
            return memberId;
        }

        String title(BookId id) {
            return books.get(id).title;
        }

        int loansOf(MemberId member) {
            int count = 0;
            for (Book book : books.values()) {
                if (book.status instanceof Status.LentTo lent && lent.member().equals(member)) {
                    count++;
                }
            }
            return count;
        }

        void lend(BookId bookId, MemberId member) {
            if (!members.containsKey(member)) {
                throw new LibraryException(new LibraryError.UnknownMember(member));
            }
            if (loansOf(member) >= maxLoans) {
                throw new LibraryException(new LibraryError.LimitReached(member));
            }
            Book book = books.get(bookId);
            if (book == null) {
                throw new LibraryException(new LibraryError.UnknownBook(bookId));
            }
            switch (book.status) {
                case Status.LentTo lent -> throw new LibraryException(new LibraryError.AlreadyLent(bookId));
                case Status.Available a -> book.status = new Status.LentTo(member);
            }
        }

        void giveBack(BookId bookId) {
            Book book = books.get(bookId);
            if (book == null) {
                throw new LibraryException(new LibraryError.UnknownBook(bookId));
            }
            switch (book.status) {
                case Status.Available a -> throw new LibraryException(new LibraryError.NotLent(bookId));
                case Status.LentTo lent -> book.status = new Status.Available();
            }
        }
    }

    static String attempt(Runnable action) {
        try {
            action.run();
            return "Ok";
        } catch (LibraryException e) {
            return "Err " + e.error();
        }
    }

    public static void main(String[] args) {
        var library = new Library(2);
        var dune = library.addBook(1, "Dune");
        var emma = library.addBook(2, "Emma");
        var ulysses = library.addBook(3, "Ulysses");
        var ana = library.addMember(10, "Ana");

        System.out.println(attempt(() -> library.lend(dune, ana)));
        System.out.println(attempt(() -> library.lend(dune, ana)));
        System.out.println(attempt(() -> library.lend(emma, ana)));
        System.out.println(attempt(() -> library.lend(ulysses, ana)));
        System.out.println(attempt(() -> library.giveBack(dune)));
        System.out.println(attempt(() -> library.lend(ulysses, ana)));
        System.out.println(attempt(() -> library.giveBack(dune)));
        System.out.println("ana holds " + library.loansOf(ana) + " books");
        System.out.println(library.title(dune));
    }
}
Output
Ok
Err AlreadyLent[book=BookId[value=1]]
Ok
Err LimitReached[member=MemberId[value=10]]
Ok
Ok
Err NotLent[book=BookId[value=1]]
ana holds 2 books
Dune

The code follows the framework's shape: IDs as records so a BookId cannot be passed where a MemberId is expected, a status type instead of a boolean, errors that name the exact reason, and the loan limit as a parameter. The second lend of dune fails with AlreadyLent. The fourth lend fails with LimitReached because Ana holds two books and the limit is two. The library management article extends this problem to holds, fines and search.

Extensions to discuss. Fines are a FinePolicy interface. A waiting list for a lent book is an ArrayDeque<MemberId> per book. Notification on return is an observer. Concurrent use is a lock around the Library, or a lock per book if contention appears.

Common mistakes

  • Coding before clarifying. The interviewer's constraints often change the design. Five minutes of questions saves a rewrite.
  • A class diagram with twenty boxes. Depth on five well-chosen types beats breadth over twenty.
  • Patterns as decoration. Naming a pattern without a requirement that needs it counts against the design.
  • Booleans and strings for state. boolean lent plus a nullable borrower field allows the combination "not lent, but has a borrower". A sealed status type does not.
  • Deep object graphs. Cyclic references keep objects reachable from each other and complicate copying and serialization. Prefer IDs for independent entities.
  • Ignoring failure. Every operation that can be refused (full lot, insufficient funds) needs a defined result.
  • No running code. A design that is only diagrams cannot show that the pieces fit.
  • Silence. Think aloud. The interviewer cannot credit reasoning they do not hear.

Summary

  • Follow seven steps in order: requirements, entities, relationships, API, patterns, core flow, extensions.
  • Turn nouns into classes, enums, interfaces or records depending on what varies and what must stay consistent.
  • Own parts in a tree and connect independent things by ID. Avoid cyclic references.
  • Write signatures first. They show what can fail and what mutates.
  • Use a pattern only when a requirement varies. Otherwise keep the code plain.
  • Reserve the last minutes for an extension and an honest list of trade-offs.