Design Patterns & LLD

How to Run a Low-Level Design Interview, and SOLID in Plain Words

The five-step routine, composition over inheritance, SOLID with before and after examples, and ASCII UML.

Lesson 0020 min read#lld#solid#interviews

On this page

  1. Words used in this lesson
  2. The 5-step routine
  3. Naming and small design rules
  4. Composition over inheritance
  5. SOLID in plain words
  6. What interviewers score
  7. Interview questions
  8. Key takeaways
  9. What's next

In one line: a low-level design (LLD, also called object-oriented design or OOD) interview asks you to turn a vague problem into small, clear classes in about 45 minutes. Follow five steps (requirements, entities, class sketch, key flows, extensions), keep names honest, prefer composition to inheritance, and use the five SOLID ideas as a checklist, not as a religion.

In a system design interview you draw boxes and arrows between servers. In a low-level design interview you work inside one program: which classes exist, what each one knows, and who talks to whom. The classic prompts are a parking lot, an LRU cache, a vending machine, a deck of cards and a chat server. Those are the exercises in the later lessons of this series.

This lesson gives you the routine to run on any prompt and the five SOLID ideas with small before-and-after code. Lessons 1 to 4 cover design patterns (named, reusable solutions). Lessons 5 to 9 apply everything to full problems. For closures, which come up again later, see JavaScript, Lesson 10.

What I ran / checked. I ran every TypeScript block in this lesson with Node 22.22.0, using node --experimental-transform-types file.ts (plain type stripping does not accept constructor parameter properties such as constructor(private x: number)). The output blocks are the real output. I also type-checked all the files with tsc --strict (TypeScript 7.0.2) and it reported no errors. The 5-step routine, the "what interviewers score" list and the naming advice are my own opinion and common practice, not a standard. I read the list of object-oriented design exercises in the solutions/object_oriented_design folder of donnemartin/system-design-primer (a March 2026 snapshot): hash map, LRU cache, call center, deck of cards, parking lot and chat server. SOLID and the other principles are described from general knowledge (Robert C. Martin popularised SOLID; the Liskov principle is named after Barbara Liskov). I quoted no book. One other popular pattern guide on GitHub could not be opened from this sandbox, so nothing here relies on it.

Words used in this lesson

  • Low-level design (LLD) / OOD means designing the classes, interfaces and relations inside a program. High-level design means the servers, databases and queues around it.
  • A class is a blueprint for objects. An object is one thing made from a class, with its own data. An interface is a list of methods that something promises to have, with no code inside.
  • An entity is a "thing" in the problem that has an identity and a life (a Book, a Loan). A value object is a small thing with no identity, defined only by its values (a Money amount, a date range).
  • Coupling is how much one class must know about another. Cohesion is how well the parts inside one class belong together. Aim for low coupling and high cohesion.
  • Inheritance means a class extends another and gets its methods ("is a"). Composition means a class holds another object and calls it ("has a").
  • A design pattern is a named, common shape of solution to a repeating design problem. The classic list comes from the book "Design Patterns" (1994).
  • SOLID is five guidelines: Single responsibility, Open/closed, Liskov substitution, Interface segregation, Dependency inversion.
  • A dependency is something a class needs to do its job. Dependency injection means giving it the dependency from outside instead of letting it build one.

The 5-step routine

Interviewers want to see you think in order, not type a class in the first minute. The times in the headings are a guess for a 45-minute round.

Step 1. Requirements (about 5 min)

A prompt like "design a library" is deliberately vague. Ask about who uses it, what it must do, and what it need not do. Good questions are short:

  • Which actions must work (borrow, return, search, pay a fine)?
  • What are the rules (how many books per member, how many days)?
  • What is out of scope (login, payments, UI)? Most LLD rounds mean one process and no database, unless you say otherwise.

Then write down 4 to 6 requirements and read them back. This one minute protects you from building the wrong thing for 40 minutes.

Step 2. Entities and relations (about 7 min)

Read your requirements and underline the nouns: Book, Member, Loan, Library. Each noun is a candidate class. The verbs (borrow, return) are candidate methods. Then decide the relations:

  • Has-a (composition): the owner creates it and it dies with the owner. A Library has Books.
  • Refers to: holds an id or reference, does not own. A Loan refers to a Member and a Book.
  • Is-a (inheritance): a kind of the parent. Use it rarely; see below.

Here is a UML-lite picture of the library, drawn as plain text:

TEXT
 Library <>------> Book          (Library owns many Books)    |  <>    |  +------> Loan ------> Member   (Loan refers to a Book and a Member)    |    +- - -> <<interface>> LoanPolicy                 ^                 | implements           StandardPolicy

How to read it: <> is "owns", a plain arrow is "refers to", a dashed arrow is "depends on", and ^ points from an implementation to its interface. You need boxes, names and arrows that you can explain, not perfect UML.

Step 3. Class and interface sketch (about 10 min)

Now write the skeleton: names, the important fields and the method signatures. Put a rule that may change (loan length, fees) behind an interface. Put a thing that will not change (a Book has a title) in a plain class. Here is the library sketch, with one real flow (borrow) filled in:

JavaScript
// Step 2 and 3 for "library lending": entities, then a thin class sketch.class Book { constructor(readonly id: string, readonly title: string) {} }class Loan {  constructor(readonly bookId: string, readonly memberId: string, readonly dueDay: number) {}}interface LoanPolicy { maxLoans(): number; loanDays(): number; }class StandardPolicy implements LoanPolicy {  maxLoans() { return 2; }  loanDays() { return 14; }}class Library {  private books = new Map<string, Book>();  private loans = new Map<string, Loan>();   // key: book id  constructor(private policy: LoanPolicy) {}  addBook(b: Book) { this.books.set(b.id, b); }  borrow(memberId: string, bookId: string, today: number): Loan {    if (!this.books.has(bookId)) throw new Error("no such book: " + bookId);    if (this.loans.has(bookId)) throw new Error("already on loan: " + bookId);    const mine = [...this.loans.values()].filter(l => l.memberId === memberId);    if (mine.length >= this.policy.maxLoans()) throw new Error("limit reached for " + memberId);    const loan = new Loan(bookId, memberId, today + this.policy.loanDays());    this.loans.set(bookId, loan);    return loan;  }}const lib = new Library(new StandardPolicy());for (const [id, title] of [["b1", "Dune"], ["b2", "Emma"], ["b3", "Ulysses"]]) {  lib.addBook(new Book(id, title));}console.log(lib.borrow("ann", "b1", 0));console.log(lib.borrow("ann", "b2", 0).dueDay);for (const [who, book] of [["ann", "b3"], ["bob", "b1"], ["bob", "zz"]]) {  try { lib.borrow(who, book, 0); }  catch (e) { console.log("error:", (e as Error).message); }}

Output:

TEXT
Loan { bookId: 'b1', memberId: 'ann', dueDay: 14 }14error: limit reached for annerror: already on loan: b1error: no such book: zz

Notice that Library gets its LoanPolicy from outside (the constructor), so a different policy needs no change to Library. And borrow checks every rule and fails with a clear message.

Step 4. Key flows (about 15 min)

Pick the two or three most important use cases and walk through them out loud, naming each method call. In the example the happy flow is borrow for Ann on day 0: the book exists, it is free, Ann has fewer than two loans, so a Loan is created with due day 14. The unhappy flows are the errors in the output. Walking them shows you thought about the rules.

Step 5. Extensions and trade-offs (about 8 min)

The interviewer will now say "what if we add late fees?" or "what if two people borrow at once?". Good answers are small changes. If your design is good, a new rule is a new class that fits an existing interface. Here is the late fee:

JavaScript
// Step 5, extension: "late fees". Add a rule without editing Library.interface FeePolicy { fee(daysLate: number): number; }class NoFee implements FeePolicy { fee() { return 0; } }class PerDayFee implements FeePolicy {  constructor(private rate: number, private cap: number) {}  fee(daysLate: number) { return Math.min(daysLate * this.rate, this.cap); }}function close(dueDay: number, returnDay: number, policy: FeePolicy) {  return policy.fee(Math.max(0, returnDay - dueDay));}console.log(close(14, 10, new PerDayFee(2, 20)));console.log(close(14, 20, new PerDayFee(2, 20)));console.log(close(14, 60, new PerDayFee(2, 20)));console.log(close(14, 60, new NoFee()));

Output:

TEXT
012200

Nothing existing had to change. Then talk about trade-offs in plain words: "I used a Map for loans, so lookup by book is fast, but finding all loans for a member walks the whole map. If that is hot I add a second index." Naming a cost is a strong signal.

Naming and small design rules

Names are most of what a reader sees. These habits are cheap:

  • Classes are nouns (Library, Loan). Methods are verbs (borrow, renew).
  • Say what, not how: Catalogue, not ArrayBookList. Booleans read like questions: isOverdue.
  • Avoid Manager, Helper and Util for a class with a real job. LoanPolicy says more than LoanManager.
  • One concept, one word. Do not mix fetch, get and load.

Also keep fields private, keep methods short, and make small classes immutable (readonly) when you can.

Composition over inheritance

Inheritance is easy to start and hard to undo. A child class is stuck with the parent's whole behaviour, and every new mix of features needs a new class. Composition plugs in the behaviour as a part:

JavaScript
// Inheritance: every new mix needs a new class.class Duck { move() { return "swim"; } }class FlyingDuck extends Duck { move() { return "fly"; } }// RobotDuck? FlyingRobotDuck? The tree grows with every mix.// Composition: the behaviour is a part you plug in.interface Movement { move(): string; }const swim: Movement = { move: () => "swim" };const fly: Movement = { move: () => "fly" };class Animal {  constructor(readonly name: string, private movement: Movement) {}  go() { return `${this.name} will ${this.movement.move()}`; }  changeMovement(m: Movement) { this.movement = m; }   // change at run time}const d = new Animal("duck", swim);console.log(d.go());d.changeMovement(fly);console.log(d.go());

Output:

TEXT
duck will swimduck will fly

With composition you swap one part at run time. A good rule: use extends only when the child is truly a kind of the parent in every situation, and when you control both classes. Otherwise hold the other object.

SOLID in plain words

SOLID is a checklist for "is this design going to hurt?". Each letter has a small before-and-after below.

LetterPlain meaningSmell that says you broke it
S Single responsibilityA class has one reason to changeA class that formats, saves and calculates
O Open/closedAdd behaviour by adding code, not by editing old codeA long if / else if on a type
L Liskov substitutionA subtype can be used wherever the parent is usedA child that throws "not supported"
I Interface segregationSmall interfaces; no one is forced to fake methodsEmpty or throwing methods
D Dependency inversionHigh-level code depends on an interface, not on a detailnew SmtpMailer() inside business code

S: Single responsibility

"Reason to change" means "who would ask for a change". The finance team changes totals. The design team changes the text layout. The platform team changes storage. If one class serves all three, any change can break the others.

JavaScript
type Item = { name: string; price: number };// BEFORE: one class, three reasons to change (rules, format, storage).class InvoiceBefore {  constructor(private items: Item[]) {}  total() { return this.items.reduce((s, i) => s + i.price, 0); }  toText() { return this.items.map(i => `${i.name}: ${i.price}`).join("\n") + `\nTotal: ${this.total()}`; }  save(db: string[]) { db.push(this.toText()); }}// AFTER: each class has one reason to change.class Invoice {  constructor(readonly items: Item[]) {}  total() { return this.items.reduce((s, i) => s + i.price, 0); }}class TextFormatter {  format(inv: Invoice) {    return inv.items.map(i => `${i.name}: ${i.price}`).join("\n") + `\nTotal: ${inv.total()}`;  }}class InvoiceStore {  constructor(private db: string[]) {}  save(text: string) { this.db.push(text); }}const items = [{ name: "pen", price: 3 }, { name: "book", price: 12 }];const db: string[] = [];new InvoiceBefore(items).save(db);new InvoiceStore(db).save(new TextFormatter().format(new Invoice(items)));console.log(db[0] === db[1]);console.log(db[1]);

Output:

TEXT
truepen: 3book: 12Total: 15

Both ways save the same text (true). After the split, a new output format is a new formatter and Invoice is untouched.

O: Open for extension, closed for modification

Adding a case should not mean editing code that already works and is already tested.

JavaScript
// BEFORE: every new shape edits this function.type ShapeData = { kind: "circle"; r: number } | { kind: "rect"; w: number; h: number };function areaBefore(s: ShapeData): number {  if (s.kind === "circle") return Math.PI * s.r ** 2;  if (s.kind === "rect") return s.w * s.h;  throw new Error("unknown shape");}// AFTER: a new shape is a new class. Old code stays untouched.interface Shape { area(): number; }class Circle implements Shape { constructor(private r: number) {} area() { return Math.PI * this.r ** 2; } }class Rect implements Shape { constructor(private w: number, private h: number) {} area() { return this.w * this.h; } }class Triangle implements Shape { constructor(private b: number, private h: number) {} area() { return (this.b * this.h) / 2; } }const totalArea = (shapes: Shape[]) => shapes.reduce((s, x) => s + x.area(), 0);console.log(areaBefore({ kind: "rect", w: 2, h: 3 }));console.log(totalArea([new Rect(2, 3), new Triangle(4, 5)]));

Output:

TEXT
616

In the first version, a triangle means editing areaBefore. In the second, a triangle is a new class, and totalArea is untouched.

Do not turn every if into a class. A two-case if that never grows is fine. Split when you see the third case, or when you know the cases will grow.

L: Liskov substitution

If code works with a parent, it must work with every child. A child that removes or breaks a promise is a trap for callers.

JavaScript
// A subtype that breaks the promise of its parent.class Account {  constructor(protected balance: number) {}  withdraw(n: number) { this.balance -= n; return this.balance; }}class FrozenAccount extends Account {  withdraw(_n: number): number { throw new Error("frozen"); }   // surprise!}const payAll = (acc: Account, bills: number[]) => bills.map(b => acc.withdraw(b));console.log(payAll(new Account(100), [10, 20]));try { payAll(new FrozenAccount(100), [10, 20]); }catch (e) { console.log("caller broke:", (e as Error).message); }// Fix: do not claim "is an Account". Only what can withdraw has withdraw.interface Withdrawable { withdraw(n: number): number; }class ActiveAccount implements Withdrawable {  constructor(private balance: number) {}  withdraw(n: number) { this.balance -= n; return this.balance; }}const payAll2 = (a: Withdrawable, bills: number[]) => bills.map(b => a.withdraw(b));console.log(payAll2(new ActiveAccount(100), [10, 20]));   // a frozen one cannot be passed

Output:

TEXT
[ 90, 70 ]caller broke: frozen[ 90, 70 ]

payAll is correct for an Account. It explodes for FrozenAccount, because the child broke the promise "you can withdraw". The fix is not a better try/catch. The fix is to not claim "is an Account". Model what really differs, so the compiler stops a wrong call.

I: Interface segregation

Many small interfaces beat one big one. A class should not have to write methods it cannot support.

JavaScript
// BEFORE: a fat interface forces classes to fake methods they cannot do.interface MachineBefore { print(d: string): string; scan(): string; }class OldPrinterBefore implements MachineBefore {  print(d: string) { return "printed " + d; }  scan(): string { throw new Error("not supported"); }   // a lie}// AFTER: small interfaces. A class picks only what it can do.interface Printer { print(d: string): string; }interface Scanner { scan(): string; }class OldPrinter implements Printer { print(d: string) { return "printed " + d; } }class OfficeMachine implements Printer, Scanner {  print(d: string) { return "printed " + d; }  scan() { return "scan.png"; }}const sendToPrinter = (p: Printer) => p.print("report");console.log(sendToPrinter(new OldPrinter()), "|", sendToPrinter(new OfficeMachine()));

Output:

TEXT
printed report | printed report

OldPrinterBefore had to pretend it can scan. In the second version the old printer only promises print, and sendToPrinter accepts anything that can print.

D: Dependency inversion

Business rules should not know which mail server, database or clock is behind them. They should know an interface.

JavaScript
// BEFORE: the high-level class builds its own low-level detail.class SmtpMailer { send(to: string, _text: string) { console.log("SMTP ->", to); } }class SignupBefore {  private mailer = new SmtpMailer();            // hard-wired  register(email: string) { this.mailer.send(email, "welcome"); }}// AFTER: depend on an interface, get the detail from outside.interface Mailer { send(to: string, text: string): void; }class Signup {  constructor(private mailer: Mailer) {}  register(email: string) { this.mailer.send(email, "welcome"); }}class FakeMailer implements Mailer {  sent: string[] = [];  send(to: string, text: string) { this.sent.push(`${to}:${text}`); }}new SignupBefore().register("a@x.com");           // cannot test without real SMTP codeconst fake = new FakeMailer();new Signup(fake).register("b@x.com");console.log(fake.sent);

Output:

TEXT
SMTP -> a@x.com[ 'b@x.com:welcome' ]

The first class cannot be tested without sending real mail. The second takes a Mailer, so a test passes a fake and checks what was sent. Production passes the real one. This is also the simplest form of dependency injection, and Lesson 1 shows it in more detail.

What interviewers score

This is my opinion from public interview guides and from common sense, not an official rubric. The usual list:

  • Clarifying: you ask before you draw.
  • Modelling: classes match the nouns of the problem.
  • Clean interfaces: small methods, rules behind interfaces.
  • Trade-offs: you say why, and what it costs.
  • Working code: at least one flow is real, not hand-waved.
  • Handling change: a new rule is a small edit.
  • Communication and restraint: you think out loud, take hints, and do not add ten patterns to a small problem.

A common failure is to name patterns before the problem needs them. Say what hurts first ("the rule for fees will change"), and then the pattern that helps.

Interview questions

How do you start a low-level design question? (Must know)

I do not start with code. I ask what the system must do, what is out of scope, and what the rules are. Then I write four to six requirements and read them back. Next I list the nouns as candidate classes, sketch the main classes and interfaces, and walk one main flow through them. At the end I show how a new rule would fit. This order keeps me from solving the wrong problem.

How do you find the classes? (Must know)

I underline the nouns in the requirements. Nouns with identity and life become entities (Book, Loan). Small values become value objects (Money). Verbs become methods, placed on the class that owns the data they use. Then I remove nouns that are only fields, and I look for rules that may change, which I put behind an interface.

Explain composition over inheritance. (Must know)

Inheritance means "is a" and locks the child to the parent's behaviour. Composition means "has a": the class holds a part and calls it. Composition is easier to change, because I can swap the part, even at run time, and I do not get a new class for every combination. I use inheritance only for a true "is a" in all cases, in a small and stable family, when I own both classes.

Explain the Single Responsibility Principle with an example. (Must know)

A class should have one reason to change. If an Invoice class calculates totals, formats text and writes to a database, then the finance team, the design team and the platform team all edit it. I split it into Invoice, TextFormatter and InvoiceStore. Each now changes for one reason.

What is the Open/Closed Principle, and when do you not apply it? (Senior signal)

Add behaviour by adding code, not by editing tested code. A new shape is a new class that implements Shape. I do not apply it early. For two cases that never change, a plain if is clearer. I wait for the third case or for a clear sign of growth. Too many abstractions cost more than a few if lines.

Give an example that breaks Liskov substitution. (Must know)

FrozenAccount extends Account but its withdraw throws. Code that works with any Account now fails for this child. The child broke the promise of the parent. The fix is to model the difference: only active accounts implement Withdrawable, so the type system stops a bad call.

Follow-up: "What about Square extends Rectangle?" It is the classic case. A square cannot honour "set width and height separately". The lesson is the same: the maths relation does not give a safe code relation.

What is dependency inversion, and how is it different from dependency injection? (Senior signal)

Dependency inversion is the design rule: business code depends on an interface that it owns, and the detail (SMTP, Postgres) implements that interface. Dependency injection is the technique to give the class its dependency from outside, most simply as a constructor argument. You can do the technique without a framework. In TypeScript a plain constructor is usually enough.

How do you decide between an interface and an abstract class? (Senior signal)

An interface says what something can do and has no code. I use it for a rule or a role that has several implementations. An abstract class shares code and state between children, but it ties them into a tree. In TypeScript I default to an interface plus composition. I use an abstract class when there is real shared code in a fixed order of steps (the template method pattern in Lesson 3).

When is a design too big for the problem? (Trap)

When there are more classes than rules. A parking lot with a factory for every class, three levels of inheritance and a plugin system is a trap in a 45-minute round. I check each class by asking "what change does this make cheap?". If I cannot name a real change, I remove it.

What do you do when the interviewer pushes back on your design? (Senior signal)

I treat it as a hint, not an attack. I restate their concern, say what it costs in my design, and either change the design or explain the trade-off I made on purpose. I do not defend a choice just because I made it, and I do not drop it at the first question either.

Key takeaways

  • Run the same five steps every time: requirements, entities, class sketch, key flows, extensions.
  • Nouns become classes, verbs become methods, and rules that may change go behind an interface.
  • Prefer composition to inheritance. Use extends only for a true "is a".
  • SOLID is a checklist: one reason to change, add rather than edit, children keep the promise, small interfaces, depend on interfaces.
  • Do not add a pattern before you can name the pain it fixes. Show one flow as working code and say a trade-off out loud.

What's next

Next: Creational patterns: Factory, Builder, Singleton and friends, Lesson 1. Practise: take "design a bookshelf app", write five requirements, draw the boxes in text, and name one rule that will change.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series