Part 01 · Low Level Design
What Is Low Level Design?
Code that works today vs code you can still change next month: where LLD fits next to HLD, what a 45-minute LLD round looks like, and how to turn requirements into classes.
⏱ 1-2 hours · Station 1 of 80
Same task, two programs
A college library in Dhaka wants a small borrowing system: members borrow books, a member can hold at most 3 books, a loan lasts 14 days, and a late return costs 5 taka a day.
Sabbir and Tania both build it in an afternoon. Both versions work. Both pass the same five manual checks.
A month later the librarian asks for two changes: premium members can borrow 5 books, and a member who owes a fine can't borrow until they pay.
- Sabbir's code is one long function with a few global maps. The limit
3appears in four places, and "is this member allowed?" is checked inside the borrow screen, the renewal screen and the nightly report. The change takes two days and breaks renewals. - Tania's code has a
Memberthat knows its own limit and aLibrarythat is the only place deciding who may borrow. The change is two small edits and one new test.
Both programs worked. Only one was designed. The difference between them is what this course calls low level design.
The problem: working code is only day one
Code that works today will be read, changed and extended for years, usually by someone else. Here is what happens to a small system in its first year:
| What happens | What it needs from the code |
|---|---|
| New rules arrive ("premium gets 5") | One obvious place to change each rule |
| New people join the team | Names and boundaries a stranger can follow |
| A bug shows up in fines | Small pieces you can test on their own |
| Two gates/requests run at once | Clear ownership of shared state |
| The product grows a new feature | Room to add code without rewriting old code |
None of this shows up on day one. All of it decides how expensive the next change is. Low level design is the habit of shaping code so the next change is cheap, without building things nobody asked for.
Think of a site plan and a floor plan
Before a housing project is built, there are two kinds of drawings.
The site plan shows the whole area: where each building goes, the roads between them, the water and power lines. That's high level design (HLD): which services and databases exist and how they talk.
The floor plan shows the inside of one building: the rooms, which room opens into which, where the kitchen is, and why the bathroom isn't next to the dining table. That's low level design (LLD): the classes inside one service, what each one is responsible for, and how they work together.
Where the analogy stops working. Walls are expensive to move, so architects get the floor plan right first. In software, moving a "wall" (a responsibility from one class to another) is cheap if the code was designed for it, and very expensive if it wasn't. LLD is less about a perfect first drawing and more about keeping the rooms easy to rearrange.
In Tania's design, the Member class has a limit() method and the Library is the only place that checks it. The librarian says premium members may now borrow 5 books. How many places in the code change?
The building blocks
-
Low level design is deciding the classes (or structs, modules) inside one program: what each one holds, what it does, what it is responsible for, and how they relate and collaborate. The output is a class diagram, a few key sequences, and code.
-
High level design decides the boxes: services, databases, caches, queues, and the arrows between them. It worries about scale, failure and latency across machines.
HLD LLD Zoom level The whole system Inside one service or module Main pieces Services, DBs, caches, queues Classes, interfaces, enums Typical question "Where does this data live, and how do 1M users reach it?" "Which class decides this, and what changes when the rule changes?" Goes wrong as Outages, slow pages, lost data Bugs, scary changes, code nobody wants to touch -
A good design is easy to change in the ways that are likely. Not every way: the ways the business is actually going to ask for. Most LLD skill is guessing those well and leaving the rest simple.
-
From requirements to classes: underline the nouns (book, member, loan, fine), they're candidate classes. The verbs (borrow, return, pay) become methods. The rules (limit 3, 14 days, 5 taka/day) need a single home: put each rule in the object that owns the data it depends on.
-
One owner per decision. "May this member borrow this book?" is decided in exactly one place. Screens, APIs and scripts ask that place; they don't re-implement it.
Behind the scenes: from a sentence to classes
One requirement becomes a design
Step 1/5 Start from the sentence
"A member can borrow up to 3 books for 14 days. A late return costs 5 taka per day."
- rule
- limit 3, 14 days, 5 taka/day
The picture to keep in your head
Two zoom levels on the same system:
- Zoomed out (HLD): boxes and arrows. "The library API talks to Postgres and an email service."
- Zoomed in (LLD): inside one box, a handful of classes, each with a job you can say in one sentence, and one clear owner for every decision.
When you design, keep asking two questions: who owns this decision? and what will change next? Everything else in this part of the course, from OOP to SOLID to design patterns, is a tool for answering those two questions well.
Try it: LLD or HLD?
Which zoom level does this question belong to?
database choice, sharding, replicas
Code from first principles
Tania's version of the library, in six languages. Four types from the nouns, two methods from the verbs, and each rule in one place: the limit in Member.limit(), the loan length and fine next to Lend and Return.
The program plays a short story: Rahim borrows until he hits his limit, Nadia tries a book that's taken, Rahim returns late and pays a fine, and premium Nadia borrows.
package main
import (
"errors"
"fmt"
)
// Each class has one job, found from the nouns in the requirements.
type Book struct {
Title string
onLoan bool
}
type Member struct {
Name string
Premium bool
loans int
}
// The borrowing limit lives with the member, so "premium gets 5" is a one-line change.
func (m *Member) limit() int {
if m.Premium {
return 5
}
return 3
}
type Loan struct {
book *Book
member *Member
due int // day number
}
// Library is the only place that decides who may borrow what.
type Library struct {
loans []*Loan
}
const loanDays, finePerDay = 14, 5 // taka
func (l *Library) Lend(m *Member, b *Book, today int) error {
if b.onLoan {
return errors.New("already on loan")
}
if m.loans >= m.limit() {
return fmt.Errorf("limit reached (%d)", m.limit())
}
b.onLoan = true
m.loans++
l.loans = append(l.loans, &Loan{book: b, member: m, due: today + loanDays})
return nil
}
// Return closes the loan and says how much fine is due.
func (l *Library) Return(b *Book, today int) (int, error) {
for i, ln := range l.loans {
if ln.book == b {
l.loans = append(l.loans[:i], l.loans[i+1:]...)
b.onLoan = false
ln.member.loans--
return max(0, today-ln.due) * finePerDay, nil
}
}
return 0, errors.New("not on loan")
}
func main() {
lib := &Library{}
rahim := &Member{Name: "Rahim"}
nadia := &Member{Name: "Nadia", Premium: true}
books := []*Book{{Title: "Himu"}, {Title: "Feluda"}, {Title: "Byomkesh"}, {Title: "Tintin"}}
for _, b := range books {
if err := lib.Lend(rahim, b, 1); err != nil {
fmt.Printf("day 1 Rahim %-9s refused: %v\n", b.Title, err)
} else {
fmt.Printf("day 1 Rahim %-9s ok\n", b.Title)
}
}
if err := lib.Lend(nadia, books[0], 2); err != nil {
fmt.Printf("day 2 Nadia %-9s refused: %v\n", books[0].Title, err)
}
fine, _ := lib.Return(books[0], 19) // due on day 15
fmt.Printf("day 19 Rahim returns %s, %d days late, fine %d taka\n", books[0].Title, fine/finePerDay, fine)
if err := lib.Lend(nadia, books[3], 19); err == nil {
fmt.Printf("day 19 Nadia %-9s ok (premium limit %d)\n", books[3].Title, nadia.limit())
}
}from dataclasses import dataclass, field
LOAN_DAYS, FINE_PER_DAY = 14, 5 # taka
# Each class has one job, found from the nouns in the requirements.
@dataclass(eq=False)
class Book:
title: str
on_loan: bool = False
@dataclass(eq=False)
class Member:
name: str
premium: bool = False
loans: int = 0
# The borrowing limit lives with the member, so "premium gets 5" is a one-line change.
def limit(self) -> int:
return 5 if self.premium else 3
@dataclass(eq=False)
class Loan:
book: Book
member: Member
due: int # day number
class LendError(Exception):
pass
@dataclass
class Library:
"""The only place that decides who may borrow what."""
loans: list[Loan] = field(default_factory=list)
def lend(self, m: Member, b: Book, today: int) -> None:
if b.on_loan:
raise LendError("already on loan")
if m.loans >= m.limit():
raise LendError(f"limit reached ({m.limit()})")
b.on_loan = True
m.loans += 1
self.loans.append(Loan(b, m, today + LOAN_DAYS))
def return_book(self, b: Book, today: int) -> int:
for ln in self.loans:
if ln.book is b:
self.loans.remove(ln)
b.on_loan = False
ln.member.loans -= 1
return max(0, today - ln.due) * FINE_PER_DAY
raise LendError("not on loan")
lib = Library()
rahim, nadia = Member("Rahim"), Member("Nadia", premium=True)
books = [Book(t) for t in ("Himu", "Feluda", "Byomkesh", "Tintin")]
for b in books:
try:
lib.lend(rahim, b, 1)
print(f"day 1 Rahim {b.title:<9} ok")
except LendError as e:
print(f"day 1 Rahim {b.title:<9} refused: {e}")
try:
lib.lend(nadia, books[0], 2)
except LendError as e:
print(f"day 2 Nadia {books[0].title:<9} refused: {e}")
fine = lib.return_book(books[0], 19) # due on day 15
print(f"day 19 Rahim returns {books[0].title}, {fine // FINE_PER_DAY} days late, fine {fine} taka")
lib.lend(nadia, books[3], 19)
print(f"day 19 Nadia {books[3].title:<9} ok (premium limit {nadia.limit()})")const LOAN_DAYS = 14, FINE_PER_DAY = 5; // taka
// Each class has one job, found from the nouns in the requirements.
class Book {
readonly title: string;
onLoan = false;
constructor(title: string) {
this.title = title;
}
}
class Member {
readonly name: string;
readonly premium: boolean;
loans = 0;
constructor(name: string, premium = false) {
this.name = name;
this.premium = premium;
}
// The borrowing limit lives with the member, so "premium gets 5" is a one-line change.
limit(): number {
return this.premium ? 5 : 3;
}
}
interface Loan { book: Book; member: Member; due: number } // due = day number
// The only place that decides who may borrow what.
class Library {
private loans: Loan[] = [];
lend(m: Member, b: Book, today: number): void {
if (b.onLoan) throw new Error("already on loan");
if (m.loans >= m.limit()) throw new Error(`limit reached (${m.limit()})`);
b.onLoan = true;
m.loans++;
this.loans.push({ book: b, member: m, due: today + LOAN_DAYS });
}
returnBook(b: Book, today: number): number {
const i = this.loans.findIndex((ln) => ln.book === b);
if (i < 0) throw new Error("not on loan");
const [ln] = this.loans.splice(i, 1);
b.onLoan = false;
ln!.member.loans--;
return Math.max(0, today - ln!.due) * FINE_PER_DAY;
}
}
const lib = new Library();
const rahim = new Member("Rahim"), nadia = new Member("Nadia", true);
const books = ["Himu", "Feluda", "Byomkesh", "Tintin"].map((t) => new Book(t));
for (const b of books) {
try {
lib.lend(rahim, b, 1);
console.log(`day 1 Rahim ${b.title.padEnd(9)} ok`);
} catch (e) {
console.log(`day 1 Rahim ${b.title.padEnd(9)} refused: ${(e as Error).message}`);
}
}
try {
lib.lend(nadia, books[0]!, 2);
} catch (e) {
console.log(`day 2 Nadia ${books[0]!.title.padEnd(9)} refused: ${(e as Error).message}`);
}
const fine = lib.returnBook(books[0]!, 19); // due on day 15
console.log(`day 19 Rahim returns ${books[0]!.title}, ${fine / FINE_PER_DAY} days late, fine ${fine} taka`);
lib.lend(nadia, books[3]!, 19);
console.log(`day 19 Nadia ${books[3]!.title.padEnd(9)} ok (premium limit ${nadia.limit()})`);const LOAN_DAYS: u32 = 14;
const FINE_PER_DAY: u32 = 5; // taka
// Each struct has one job, found from the nouns in the requirements.
// Books and members are referred to by index, so the Library can own them all.
struct Book { title: String, on_loan: bool }
struct Member { premium: bool, loans: u32 }
impl Member {
// The borrowing limit lives with the member, so "premium gets 5" is a one-line change.
fn limit(&self) -> u32 { if self.premium { 5 } else { 3 } }
}
struct Loan { book: usize, member: usize, due: u32 }
// The only place that decides who may borrow what.
struct Library { books: Vec<Book>, members: Vec<Member>, loans: Vec<Loan> }
impl Library {
fn lend(&mut self, m: usize, b: usize, today: u32) -> Result<(), String> {
if self.books[b].on_loan {
return Err("already on loan".into());
}
let member = &mut self.members[m];
if member.loans >= member.limit() {
return Err(format!("limit reached ({})", member.limit()));
}
member.loans += 1;
self.books[b].on_loan = true;
self.loans.push(Loan { book: b, member: m, due: today + LOAN_DAYS });
Ok(())
}
fn return_book(&mut self, b: usize, today: u32) -> Result<u32, String> {
let i = self.loans.iter().position(|l| l.book == b).ok_or("not on loan")?;
let loan = self.loans.remove(i);
self.books[b].on_loan = false;
self.members[loan.member].loans -= 1;
Ok(today.saturating_sub(loan.due) * FINE_PER_DAY)
}
}
fn main() {
let titles = ["Himu", "Feluda", "Byomkesh", "Tintin"];
let mut lib = Library {
books: titles.iter().map(|t| Book { title: t.to_string(), on_loan: false }).collect(),
members: vec![Member { premium: false, loans: 0 }, Member { premium: true, loans: 0 }],
loans: vec![],
};
let (rahim, nadia) = (0, 1);
for b in 0..titles.len() {
match lib.lend(rahim, b, 1) {
Ok(()) => println!("day 1 Rahim {:<9} ok", titles[b]),
Err(e) => println!("day 1 Rahim {:<9} refused: {e}", titles[b]),
}
}
if let Err(e) = lib.lend(nadia, 0, 2) {
println!("day 2 Nadia {:<9} refused: {e}", lib.books[0].title);
}
let fine = lib.return_book(0, 19).unwrap(); // due on day 15
println!("day 19 Rahim returns {}, {} days late, fine {fine} taka", lib.books[0].title, fine / FINE_PER_DAY);
lib.lend(nadia, 3, 19).unwrap();
println!("day 19 Nadia {:<9} ok (premium limit {})", lib.books[3].title, lib.members[nadia].limit());
}import java.util.ArrayList;
import java.util.List;
public class Library {
static final int LOAN_DAYS = 14, FINE_PER_DAY = 5; // taka
// Each class has one job, found from the nouns in the requirements.
static final class Book {
final String title;
boolean onLoan;
Book(String title) { this.title = title; }
}
static final class Member {
final String name;
final boolean premium;
int loans;
Member(String name, boolean premium) { this.name = name; this.premium = premium; }
// The borrowing limit lives with the member, so "premium gets 5" is a one-line change.
int limit() { return premium ? 5 : 3; }
}
record Loan(Book book, Member member, int due) {}
static final class LendException extends Exception {
private static final long serialVersionUID = 1L;
LendException(String msg) { super(msg); }
}
// The only place that decides who may borrow what.
private final List<Loan> loans = new ArrayList<>();
void lend(Member m, Book b, int today) throws LendException {
if (b.onLoan) throw new LendException("already on loan");
if (m.loans >= m.limit()) throw new LendException("limit reached (" + m.limit() + ")");
b.onLoan = true;
m.loans++;
loans.add(new Loan(b, m, today + LOAN_DAYS));
}
int returnBook(Book b, int today) throws LendException {
for (Loan ln : loans) {
if (ln.book() == b) {
loans.remove(ln);
b.onLoan = false;
ln.member().loans--;
return Math.max(0, today - ln.due()) * FINE_PER_DAY;
}
}
throw new LendException("not on loan");
}
public static void main(String[] args) throws LendException {
Library lib = new Library();
Member rahim = new Member("Rahim", false), nadia = new Member("Nadia", true);
List<Book> books = List.of(new Book("Himu"), new Book("Feluda"), new Book("Byomkesh"), new Book("Tintin"));
for (Book b : books) {
try {
lib.lend(rahim, b, 1);
System.out.printf("day 1 Rahim %-9s ok%n", b.title);
} catch (LendException e) {
System.out.printf("day 1 Rahim %-9s refused: %s%n", b.title, e.getMessage());
}
}
try {
lib.lend(nadia, books.get(0), 2);
} catch (LendException e) {
System.out.printf("day 2 Nadia %-9s refused: %s%n", books.get(0).title, e.getMessage());
}
int fine = lib.returnBook(books.get(0), 19); // due on day 15
System.out.printf("day 19 Rahim returns %s, %d days late, fine %d taka%n", books.get(0).title, fine / FINE_PER_DAY, fine);
lib.lend(nadia, books.get(3), 19);
System.out.printf("day 19 Nadia %-9s ok (premium limit %d)%n", books.get(3).title, nadia.limit());
}
}#include <algorithm>
#include <cstdio>
#include <stdexcept>
#include <string>
#include <vector>
constexpr int kLoanDays = 14, kFinePerDay = 5; // taka
// Each class has one job, found from the nouns in the requirements.
struct Book {
std::string title;
bool onLoan = false;
};
struct Member {
std::string name;
bool premium = false;
int loans = 0;
// The borrowing limit lives with the member, so "premium gets 5" is a one-line change.
int limit() const { return premium ? 5 : 3; }
};
struct Loan {
Book* book;
Member* member;
int due; // day number
};
// The only place that decides who may borrow what.
class Library {
public:
void lend(Member& m, Book& b, int today) {
if (b.onLoan) throw std::runtime_error("already on loan");
if (m.loans >= m.limit()) throw std::runtime_error("limit reached (" + std::to_string(m.limit()) + ")");
b.onLoan = true;
++m.loans;
loans_.push_back({&b, &m, today + kLoanDays});
}
int returnBook(Book& b, int today) {
auto it = std::find_if(loans_.begin(), loans_.end(), [&](const Loan& l) { return l.book == &b; });
if (it == loans_.end()) throw std::runtime_error("not on loan");
Loan ln = *it;
loans_.erase(it);
b.onLoan = false;
--ln.member->loans;
return std::max(0, today - ln.due) * kFinePerDay;
}
private:
std::vector<Loan> loans_;
};
int main() {
Library lib;
Member rahim{"Rahim"}, nadia{"Nadia", true};
std::vector<Book> books{{"Himu"}, {"Feluda"}, {"Byomkesh"}, {"Tintin"}};
for (auto& b : books) {
try {
lib.lend(rahim, b, 1);
std::printf("day 1 Rahim %-9s ok\n", b.title.c_str());
} catch (const std::exception& e) {
std::printf("day 1 Rahim %-9s refused: %s\n", b.title.c_str(), e.what());
}
}
try {
lib.lend(nadia, books[0], 2);
} catch (const std::exception& e) {
std::printf("day 2 Nadia %-9s refused: %s\n", books[0].title.c_str(), e.what());
}
int fine = lib.returnBook(books[0], 19); // due on day 15
std::printf("day 19 Rahim returns %s, %d days late, fine %d taka\n", books[0].title.c_str(), fine / kFinePerDay, fine);
lib.lend(nadia, books[3], 19);
std::printf("day 19 Nadia %-9s ok (premium limit %d)\n", books[3].title.c_str(), nadia.limit());
}All six print exactly this:
day 1 Rahim Himu ok
day 1 Rahim Feluda ok
day 1 Rahim Byomkesh ok
day 1 Rahim Tintin refused: limit reached (3)
day 2 Nadia Himu refused: already on loan
day 19 Rahim returns Himu, 4 days late, fine 20 taka
day 19 Nadia Tintin ok (premium limit 5)
Reading the output:
- Rahim's fourth book is refused with
limit reached (3). The number comes fromlimit(), so the message is always right, even after the rule changes. - Nadia can't take Himu because Rahim has it.
Bookknows it's on loan;Librarychecks it before anything else changes. - Day 19: the loan was due on day 15 (day 1 + 14), so Rahim is 4 days late: 4 × 5 = 20 taka.
- Nadia borrows Tintin with a limit of 5. Premium is just a field on
Member; no other code needed to know. - What each language changed: Go and Python use plain structs with methods; Java uses a
recordforLoanbecause it never changes; Rust refers to books and members by index so theLibrarycan own all the data without fighting the borrow checker; C++ keeps pointers into vectors that never resize while loans exist. The design (who owns which rule) is identical in all six.
Real engineering
- Design for the changes you can see coming. Talk to whoever owns the product: which rules change often? Those deserve a clear home. The rest can stay simple until they move.
- Write the class list before the code. Five lines ("Library: lends and returns, owns loans. Member: knows its limit…") catch most design mistakes before they cost anything.
- Keep business rules away from screens and handlers. A borrow API, a librarian screen and a nightly script should all call
Library.Lend. (Backend First Principles covers the same idea as "business logic vs plumbing".) - Don't build for imagined futures. No
IBookinterface with one implementation, no factory for two-field objects. You can add them in an afternoon when a real need appears.
The 45-minute LLD interview, in practice:
| Minutes | What you do |
|---|---|
| 0–5 | Clarify requirements and scope. Ask what's out of scope. |
| 5–12 | List entities with one-line responsibilities; sketch relationships. |
| 12–20 | Draw the class diagram; name the key methods. |
| 20–38 | Code the core flow (here: Lend and Return), with the rules in their homes. |
| 38–45 | Handle "what if…" follow-ups: new rule, new type, two threads at once. |
The last block is where interviews are won: if your design lets you answer "what if premium members get 5?" with "one line in limit()", you've shown exactly what LLD is for. The last chapter of this part goes through this method in detail.
When it breaks
Pick a design smell
func handleBorrow(req) { ... 300 lines ... }Think before you read on
A teammate says: "Let's make Book an abstract class now, with PaperBook and EBook subclasses. The library might lend e-books one day." The library has never mentioned e-books. What do you say?
Reveal
Not yet. Today there is one kind of book and no behaviour that differs. Subclasses now would be guesses, and guesses are usually wrong in the details (do e-books have copies? loan limits?). Keep Book simple. When e-books become a real requirement, you'll know how they actually differ, and adding the abstraction then is a small change, because the rest of the design (one owner per rule) is clean. That's YAGNI: "you aren't gonna need it", until you do.
Check yourself
1. In one sentence each, what does HLD decide and what does LLD decide?
Answer
HLD decides which services, databases and queues exist and how they talk across machines. LLD decides which classes exist inside one program, what each is responsible for, and how they collaborate.
2. Both Sabbir's and Tania's programs worked. What made Tania's better?
Answer
Each rule had one home (the limit on Member, lending rules in Library), so a new rule meant a small, safe change instead of hunting through copies.
3. How do you get from a requirement sentence to candidate classes and methods?
Answer
Nouns become candidate classes, verbs become methods, and each rule goes to the object that owns the data it depends on. Then remove any class that has no state or behaviour of its own.
4. In a 45-minute LLD interview, where should most of the time go, and what wins the last few minutes?
Answer
Most of it goes to the class design and coding the core flow. The last minutes are won by handling "what if" follow-ups with small, local changes, which shows the design is easy to change.
Your challenge: the fine rule
Add the librarian's second request: a member who owes a fine can't borrow until they pay.
- Give
Memberthe amount it owes.Returnadds the fine to it. Lendrefuses withpay fine first (20 taka)when the member owes anything.- Add one line at the end of the story: Rahim tries to borrow Himu again on day 19.
Before you run it, predict the new last line.
You've succeeded when the last line is day 19 Rahim Himu refused: pay fine first (20 taka), every earlier line prints exactly as before, and the check lives in one place (Lend), not in the story code.
The mental model
HLD draws the boxes; LLD designs what's inside one box. Turn nouns into classes and verbs into methods, give every rule exactly one home, and keep the design just flexible enough for the changes you can actually see coming. Working code is day one; good design is every day after.
Station stamp
Finish the chapter
Done reading? Press Complete to save your progress. XP is Pro-only — on Pro this chapter is worth +10 XP.
Go Pro to earn XP →