Low-Level Design (Object-Oriented Design) Module 1 – LLD rounds and a repeatable answer method
What low-level design (LLD) is and what it tests
What a low-level design (LLD) round asks for, how it differs from coding and high-level design rounds, what evaluators check and the formats it comes in.
What you will learn
- Explain how a low-level design round differs from DSA and high-level design rounds
- List what evaluators look for: modelling, correctness, extensibility, code quality and tests
- Recognise the common formats: design discussion, machine coding and take-home
On this page
Low-level design (LLD) is the design of the inside of one program. It settles which classes exist, what each of them knows and does, how they refer to one another, how an object’s state changes during its life, what every public method promises and how a failure is reported. When several threads share the same objects, it also settles how they stay correct. Interviewers often call the same thing object-oriented design (OOD).
This lesson explains what an LLD round asks for and how an answer is judged. The rest of this module turns that into a method you can repeat under a time limit.
Three rounds, three zoom levels
Hiring for software roles often includes three kinds of technical round whose names sound alike. They look at the same problem from different distances:
- A coding round, about data structures and algorithms (DSA), states the problem precisely and expects one function that returns the right answer with a good time and space complexity.
- A low-level design round starts from a short, deliberately vague prompt about a real-world thing and expects an object model: the classes, what each one is responsible for, how they relate, the rules they enforce, and code for the parts that matter.
- A high-level design round (HLD) is about a whole system spread over many machines: services, databases, caches, queues, traffic estimates and what happens when a machine fails. It has its own track, high-level system design.
The same library prompt at three zoom levels
Text description of the diagram
The diagram shows three boxes from top to bottom, each one zoomed out further than the one above it.
- Coding round (DSA), one function: overdue_members(loans, today), which returns the sorted names of members with an overdue loan.
- Low-level design round, classes inside one program: a Member, who can borrow(copy), holds at most two Copy objects, and each Copy knows its title and its borrower.
- High-level design round, services on many machines: an API in front of a loan service, which stores loans in a database and asks a reminder service to send reminders.
One prompt, answered at each level
Take the prompt “a library lends books to its members”. A coding round turns it into a function with exact inputs and outputs, such as “return the members who have an overdue book”:
def overdue_members(loans, today):
"""Return the sorted names of members with a loan that was due before today.
loans is a list of (member, title, due_day) tuples; days are numbered 1, 2, 3 ...
"""
late = {member for member, _title, due_day in loans if due_day < today}
return sorted(late)
loans = [
("Asha", "Malgudi Days", 12),
("Ravi", "Godan", 15),
("Asha", "Wings of Fire", 9),
("Mei", "Kokoro", 20),
]
print("day 15:", overdue_members(loans, today=15))
print("day 16:", overdue_members(loans, today=16)) Output
day 15: ['Asha'] day 16: ['Asha', 'Ravi']
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 overdue.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The two calls test the boundary. A book due on day 15 is not overdue on day 15, so Ravi appears only on day 16. Nothing here says who may borrow what; the function only answers a question about data it is given.
A high-level answer would draw an API, a loan service with its database and a reminder service, and would talk about how many requests each must survive. It contains no classes at all.
A low-level answer models the library itself. Here are two classes, Copy and Member, and a short script that
drives them with a few commands:
library_py/commands.py
from library import Copy, LendingError, Member
shelf = [Copy("c1", "Godan"), Copy("c2", "Kokoro"), Copy("c3", "Malgudi Days")]
copies = {c.copy_id: c for c in shelf}
members = {m.name: m for m in [Member("Asha"), Member("Ravi")]}
script = ["Asha c1", "Asha c2", "Asha c3", "Ravi c1", "Ravi c3"]
for line in script:
name, copy_id = line.split()
try:
members[name].borrow(copies[copy_id])
print(f"{name} borrows {copy_id}: ok")
except LendingError as refusal:
print(f"{name} borrows {copy_id}: refused, {refusal}")
for member in members.values():
print(f"{member.name} holds {', '.join(c.title for c in member.loans)}") Output
Asha borrows c1: ok Asha borrows c2: ok Asha borrows c3: refused, Asha already holds 2 copies Ravi borrows c1: refused, c1 is already lent to Asha Ravi borrows c3: ok Asha holds Godan, Kokoro Ravi holds Malgudi Days
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 commands.py
library_py/library.py
class LendingError(Exception):
"""A loan that the library's rules refuse."""
class Copy:
"""One physical copy of a title: on the shelf, or lent to one member."""
def __init__(self, copy_id, title):
self.copy_id = copy_id
self.title = title
self.borrower = None
class Member:
"""A member who may hold a limited number of copies at a time."""
def __init__(self, name, limit=2):
self.name = name
self.limit = limit
self.loans = []
def borrow(self, copy):
if copy.borrower is not None:
raise LendingError(f"{copy.copy_id} is already lent to {copy.borrower.name}")
if len(self.loans) >= self.limit:
raise LendingError(f"{self.name} already holds {self.limit} copies")
copy.borrower = self
self.loans.append(copy) Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Look at what the model decides, because these decisions are what an LLD round is about:
- The thing that is lent is a copy, not a title: a library can own three copies of a popular book.
- A member holds at most two copies at a time, and a copy is with at most one member.
Member.borrow()checks both rules before it changes anything, so a refused loan leaves no half-done state.- Every refusal is a
LendingErrorwhose message names the rule, so the caller can show it or test it.
The same model in JavaScript reads almost line for line the same. One difference: a JavaScript catch cannot
choose an error by its class, so the script rethrows anything that is not a LendingError instead of hiding it.
library_js/commands.mjs
import { Copy, LendingError, Member } from "./library.mjs";
const shelf = [new Copy("c1", "Godan"), new Copy("c2", "Kokoro"),
new Copy("c3", "Malgudi Days")];
const copies = new Map(shelf.map((c) => [c.copyId, c]));
const members = new Map([new Member("Asha"), new Member("Ravi")].map((m) => [m.name, m]));
const script = ["Asha c1", "Asha c2", "Asha c3", "Ravi c1", "Ravi c3"];
for (const line of script) {
const [name, copyId] = line.split(" ");
try {
members.get(name).borrow(copies.get(copyId));
console.log(`${name} borrows ${copyId}: ok`);
} catch (error) {
if (!(error instanceof LendingError)) throw error; // a bug: let it stop the script
console.log(`${name} borrows ${copyId}: refused, ${error.message}`);
}
}
for (const member of members.values()) {
console.log(`${member.name} holds ${member.loans.map((c) => c.title).join(", ")}`);
} Output
Asha borrows c1: ok Asha borrows c2: ok Asha borrows c3: refused, Asha already holds 2 copies Ravi borrows c1: refused, c1 is already lent to Asha Ravi borrows c3: ok Asha holds Godan, Kokoro Ravi holds Malgudi Days
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node commands.mjs
library_js/library.mjs
export class LendingError extends Error {}
export class Copy {
constructor(copyId, title) {
this.copyId = copyId;
this.title = title;
this.borrower = null;
}
}
export class Member {
constructor(name, limit = 2) {
this.name = name;
this.limit = limit;
this.loans = [];
}
borrow(copy) {
if (copy.borrower !== null) {
throw new LendingError(`${copy.copyId} is already lent to ${copy.borrower.name}`);
}
if (this.loans.length >= this.limit) {
throw new LendingError(`${this.name} already holds ${this.limit} copies`);
}
copy.borrower = this;
this.loans.push(copy);
}
} Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Why the rules belong inside the classes
A common weak answer keeps the data in plain dictionaries and checks the rules wherever the data is changed. It works until there is a second way in. Here the counter remembers the limit, and a later “app” path does not:
# A design without a Member class: the loans are a plain dictionary, and every
# way of borrowing has to remember the limit itself.
LIMIT = 2
loans = {"Asha": ["c1", "c2"]}
def borrow_at_counter(name, copy_id):
if len(loans.setdefault(name, [])) >= LIMIT:
return f"counter: {name} is at the limit"
loans[name].append(copy_id)
return f"counter: {name} borrows {copy_id}"
def borrow_in_app(name, copy_id):
# Added later for the mobile app; the limit check was not copied here.
loans.setdefault(name, []).append(copy_id)
return f"app: {name} borrows {copy_id}"
print(borrow_at_counter("Asha", "c3"))
print(borrow_in_app("Asha", "c3"))
print(f"Asha holds {len(loans['Asha'])} copies; the limit is {LIMIT}") Output
counter: Asha is at the limit app: Asha borrows c3 Asha holds 3 copies; the limit is 2
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 two_doors.py
Asha ends up with three copies although the limit is two. The class model above offers one way to lend a copy,
Member.borrow(), so every caller, today’s and next year’s, goes through the same check. Putting each rule where
callers cannot skip it is the heart of low-level design.
What evaluators look for
An LLD answer is usually judged on five things, whatever names a particular interviewer gives them:
- Modelling. The classes match the concepts of the problem and carry its words (copy, member, loan). Each class has one clear job, and each rule has one owner.
- Correctness. The rules hold in the awkward cases: the limit reached, the same action done twice, a copy that does not exist. Failures are reported precisely, not swallowed.
- Extensibility. A follow-up requirement (“teachers may hold five copies”) changes one place, not ten. Interviewers test this on purpose, which is why the case studies in this track end with such a change.
- Code quality. Clear names, short methods, no class that does everything, no rule written twice.
- Tests. The rules are checked by tests, edge cases included, and the tests would fail if the code broke.
These are close to what careful code reviewers look for in any change. Google’s public guide for reviewers, for example, asks about design, functionality (including edge cases and concurrency), complexity, tests and naming (What to look for in a code review). An LLD round compresses that review into one sitting, with you as the author.
Diagrams help you explain a model quickly. The standard notation for them is UML, the Unified Modeling Language, whose current version, 2.5.1, is published by the Object Management Group. A later module of this track covers the parts of it that an interview needs: class, sequence and state machine diagrams.
The common formats
Rounds that test low-level design come in a few shapes. They differ between employers and are not standardised, so ask the recruiter which one you will get, how long it lasts and which languages you may use.
- Design discussion, about 45 to 60 minutes. You produce requirements, a class model, the main flows and the code of a few key methods. What decides it is the model and how you reason about change; the typical follow-up is a new requirement and the question of how your model absorbs it.
- Machine coding, about 90 to 120 minutes. You produce a working program, often driven by text commands, and then walk the interviewer through it. Output that matches the expected output comes first, then structure, tests and how easily the code extends; the typical follow-up is a new command or rule in the last minutes.
- Take-home, a few hours, often spread over several days. You produce a small project with tests and a README. It is judged the way a code review judges a change (design, tests, naming, documentation), often followed by a call in which you explain your choices.
Some discussion rounds accept structured pseudocode; machine-coding rounds need code that runs. The case studies in this track give both: a model you can draw and explain, and code with tests.
The scope trap
Do not answer a low-level question with high-level parts
A database schema, a message queue or a set of microservices is a high-level answer. In an LLD round, keep the data in memory, in dictionaries and lists behind a small repository class, unless the interviewer asks for more. The time you save goes into the classes, their rules and the code, which is what the round measures.
How this track fits with OOP and design patterns
This track assumes you can write a class with a constructor and methods in at least one language; the object-oriented programming track teaches that. The design patterns track has a page for each pattern. Here the patterns are applied to real prompts, and a case study links to a pattern’s page instead of explaining it again.
Key takeaways
- Low-level design is the object model inside one program: classes, responsibilities, relationships, lifecycles, method contracts, errors and, when threads share objects, concurrency.
- A coding round wants one correct, efficient function; a high-level design round wants services and storage across machines; a low-level design round wants classes whose rules hold in every case.
- Answers are judged on modelling, correctness, extensibility, code quality and tests. Put each rule where no caller can skip it.
- Formats vary: a design discussion, a machine-coding round or a take-home. Ask which one you will get.
- Keep the data in memory unless asked otherwise; databases and queues are a different round.
Exercise
Exercise · Easy · Python
Let a member give a copy back
The lesson's two-class model lets a member borrow a copy, but nobody can bring one back yet. Add the method give_back(self, copy) to Member, keeping the rule inside the model, where every caller has to go through it:
- When the member holds the copy, remove it from the member's
loansand put it back on the shelf (copy.borrowerbecomesNone). The member then has room for another loan, and anyone can borrow the copy. - When the member does not hold the copy (another member has it, or it is on the shelf), raise
LendingErrorwith the message<name> does not hold <copy id>, for exampleRavi does not hold c1, and change nothing. - The method returns nothing.
Do not change borrow. The sample tests import Copy, LendingError and Member from library.py, borrow and give back copies, and check the loans, the borrowers and the messages.
Starter code · library.py
class LendingError(Exception):
"""A loan or a return that the library's rules refuse."""
class Copy:
"""One physical copy of a title: on the shelf, or lent to one member."""
def __init__(self, copy_id, title):
self.copy_id = copy_id
self.title = title
self.borrower = None
class Member:
"""A member who may hold a limited number of copies at a time."""
def __init__(self, name, limit=2):
self.name = name
self.limit = limit
self.loans = []
def borrow(self, copy):
if copy.borrower is not None:
raise LendingError(f"{copy.copy_id} is already lent to {copy.borrower.name}")
if len(self.loans) >= self.limit:
raise LendingError(f"{self.name} already holds {self.limit} copies")
copy.borrower = self
self.loans.append(copy)
def give_back(self, copy):
"""Return a copy this member holds to the shelf."""
# Replace this line with your code.
raise NotImplementedError The sample tests · test_library.py
from library import Copy, LendingError, Member
def refusal(action):
"""The message of the LendingError that action() raises, or None when it raises none."""
try:
action()
except LendingError as error:
return str(error)
return None
def test_returns_the_copy_to_the_shelf():
"""giving a copy back removes it from the member's loans and frees it"""
asha, godan = Member("Asha"), Copy("c1", "Godan")
asha.borrow(godan)
asha.give_back(godan)
assert asha.loans == []
assert godan.borrower is None
def test_another_member_can_borrow_it():
"""a copy that was given back can be lent to someone else"""
asha, ravi, godan = Member("Asha"), Member("Ravi"), Copy("c1", "Godan")
asha.borrow(godan)
asha.give_back(godan)
ravi.borrow(godan)
assert godan.borrower is ravi
def test_frees_a_place_under_the_limit():
"""a member at the limit can borrow again after giving a copy back"""
asha = Member("Asha")
c1, c2, c3 = Copy("c1", "Godan"), Copy("c2", "Kokoro"), Copy("c3", "Malgudi Days")
asha.borrow(c1)
asha.borrow(c2)
asha.give_back(c1)
asha.borrow(c3)
assert [c.copy_id for c in asha.loans] == ["c2", "c3"]
def test_refuses_a_copy_lent_to_someone_else():
"""refuses a copy that another member holds, and changes nothing"""
asha, ravi, godan = Member("Asha"), Member("Ravi"), Copy("c1", "Godan")
asha.borrow(godan)
assert refusal(lambda: ravi.give_back(godan)) == "Ravi does not hold c1"
assert godan.borrower is asha
assert asha.loans == [godan]
def test_refuses_a_copy_on_the_shelf():
"""refuses a copy that nobody holds"""
ravi, kokoro = Member("Ravi"), Copy("c2", "Kokoro")
assert refusal(lambda: ravi.give_back(kokoro)) == "Ravi does not hold c2"
assert kokoro.borrower is None A hint
copy in self.loans is True when this member holds the copy. Check it first and raise the error before you change anything; only then remove the copy with self.loans.remove(copy) and set copy.borrower = None.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
7 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
References
- Unified Modeling Language (UML), version 2.5.1 (Object Management Group)
- What to look for in a code review (Google Engineering Practices)
- The Python Tutorial: Classes (Python Software Foundation)
- try...catch: conditional catch blocks (MDN Web Docs (Mozilla))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress