Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Beginner
  • 20 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot

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.
Three rounds from small to large: a coding round answers with one function, LLD with classes, HLD with services and a database.Coding round (DSA)one functionLow-level design roundclasses inside one programHigh-level design roundservices on many machinesoverdue_members()returns sorted namesMemberborrow(copy)Copytitle, borrowerAPILoan serviceLoansdatabaseReminderserviceholds at most 2zoom outzoom out

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.

  1. Coding round (DSA), one function: overdue_members(loans, today), which returns the sorted names of members with an overdue loan.
  2. 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.
  3. 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”:

The coding-round answer: one function Python · overdue/overdue.py
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

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:

The low-level design answer: two classes and a script

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)

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 LendingError whose 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.

The same model in JavaScript

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);
  }
}

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 rule checked in one place out of two Python · two_doors/two_doors.py
# 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.

Python Online Compiler Try your own variations of these examples in a full Python editor that runs in your browser.

What evaluators look for

An LLD answer is usually judged on five things, whatever names a particular interviewer gives them:

  1. 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.
  2. 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.
  3. 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.
  4. Code quality. Clear names, short methods, no class that does everything, no rule written twice.
  5. 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 loans and put it back on the shelf (copy.borrower becomes None). 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 LendingError with the message <name> does not hold <copy id>, for example Ravi 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.

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.

  1. Question 1 of 7 A prompt says: "Return the k most frequent words of a text in O(n log k) time." Which kind of round is it?

    Choose one answer.

    Show the answer to question 1

    Answer: A coding (DSA) round

    The input, the output and the expected complexity are all fixed, and the answer is one function. That is a coding round: it is judged on a correct result and on time and space complexity, not on an object model.

  2. Question 2 of 7 A prompt says: "Design the classes of a vending machine that takes coins, sells snacks and gives change." Which kind of round is it?

    Choose one answer.

    Show the answer to question 2

    Answer: A low-level design round

    It asks for classes inside one program (machine, slots, products, coins), the rules between them (enough money, sold out, change) and how their state changes. That is low-level design.

  3. Question 4 of 7 Which of these do evaluators of a low-level design answer usually look at?

    Choose every answer that is right.

    Show the answer to question 4

    Answer:

    • Whether tests check the rules
    • How many places a new requirement would change
    • Whether each rule lives in the class that owns the data it needs
    • What happens in edge cases, such as a member who is already at the limit

    Modelling, correctness in edge cases, extensibility, code quality and tests are what an LLD answer is judged on. Server counts and peak traffic belong to high-level design.

  4. Question 5 of 7 In two_doors.py, Asha already holds c1 and c2, and the limit is 2. What does the script print?

    What does this program print? Choose one answer.

    # 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}")
    Show the answer to question 5

    Answer: it prints

    counter: Asha is at the limit
    app: Asha borrows c3
    Asha holds 3 copies; the limit is 2

    The counter checks the limit and refuses, but borrow_in_app() never checks it, so the app lends a third copy. With the rule inside Member.borrow(), as in the lesson's model, every way of borrowing would be refused.

  5. Question 6 of 7 You get a written problem with a sample input and the exact output it must produce, about 90 minutes to build it in your own editor, and a walk-through of your code at the end. Which format is it?

    Choose one answer.

    Show the answer to question 6

    Answer: A machine-coding round

    A program that must run and match the expected output within a fixed time, followed by a walk-through, is a machine-coding round. A design discussion is judged on the model and how it absorbs a change, and a take-home gives you hours or days and is judged the way a code review judges a change.

  6. Question 7 of 7 In a low-level design round about a parking lot, what does the interviewer usually expect you to design?

    Choose one answer.

    Show the answer to question 7

    Answer: Classes and rules inside one program, with the data kept in memory, for example in dictionaries

    Databases, queues and services are high-level answers to a low-level question. Unless the interviewer asks for them, keep the data in memory (often behind a small repository class) and spend the time on the classes, their rules and the code.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.