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

A repeatable framework for LLD answers

Answer any low-level design (LLD) question with one fixed sequence of eleven steps, time budgets for 45-, 60- and 90-minute rounds and a one-page answer sheet.

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

What you will learn

  • Apply one fixed sequence: clarify, use cases, entities, relationships, diagrams, API, code, tests, extensions
  • Plan the time of each step for 45-, 60- and 90-minute rounds
  • Record assumptions in a numbered list and state trade-offs out loud

Before you start

On this page

Under a time limit, people tend to do one of two things with a design question: freeze, or start typing code at once. Both waste the round. A fixed sequence of steps fixes that. Each step produces something small and visible, each one feeds the next, and the interviewer can see where you are and steer you early, when a change is cheap.

This lesson gives you MySmartCoPilot’s sequence, how long to spend on each step in the common round lengths, and three habits that make an answer convincing: the public API before the internals, a written list of assumptions and trade-offs said out loud.

The eleven steps

Eleven steps in four phases, Understand, Model, Build and Extend; decisions go to an assumptions list; a follow-up starts again.Understand1 Clarify2 Use casesModel3 Entities andresponsibilities4 Relationships5 Class diagram6 Key flows7 LifecyclesBuild8 Public API9 Code10 TestsExtend11 Extensionsand trade-offsAssumptionsA1, A2 …requirementsclasses, rulestested codefollow-updecisions

The eleven steps of the answer framework

Text description of the diagram

The diagram shows four phases from top to bottom, each passing its result to the next.

  1. Understand: step 1, Clarify, and step 2, Use cases. It hands numbered requirements to the next phase.
  2. Model: step 3, Entities and responsibilities; step 4, Relationships; step 5, Class diagram; step 6, Key flows; step 7, Lifecycles. It hands the classes and their rules to the next phase.
  3. Build: step 8, Public API; step 9, Code; step 10, Tests. It hands code that runs and is tested to the last phase.
  4. Extend: step 11, Extensions and trade-offs. A follow-up question from the interviewer leads back to Understand.

Dashed arrows from Understand, Model and Build lead to an assumptions list (A1, A2 and so on), where every decision you make along the way is written down.

The steps fall into four phases. Each step has an output you can point at:

  1. Clarify. Numbered requirements (R1, R2 …) and an out-of-scope list. The next lesson is about this step.
  2. Use cases. Who uses the system and the three to six main things each of them does, one line each.
  3. Entities and responsibilities. The candidate classes and what each one knows and does. A later lesson in this module shows how to find them.
  4. Relationships. Which classes hold, contain or use which, with multiplicities: one route has many stops.
  5. Class diagram. The classes with their key attributes and operations, and the relationships of step 4.
  6. Key flows. The one or two most important use cases, message by message, as a list or a sequence diagram.
  7. Lifecycles. For each object whose status changes, its states and the events that move it between them.
  8. Public API. The signatures of the public methods, what they return and which errors they raise.
  9. Code. The core classes and the main flow first, then the rest.
  10. Tests. At least one test for each requirement, with the edge cases.
  11. Extensions and trade-offs. How the design takes a follow-up requirement, and what you chose not to do.

Steps 5 to 7 use three diagrams of the Unified Modeling Language (UML): the class diagram for structure, the sequence diagram for an interaction and the state machine diagram for a lifecycle. A later module of this track covers each of them; in an interview, a neat box-and-arrow drawing that uses the same ideas is often enough.

Shrink a step, never skip it

A small prompt does not need eleven separate artefacts. For a two-class problem, steps 4 to 7 may take one minute and one line each (“a Member has 0 to 2 Copies; a Copy is on the shelf or lent”). Saying that one line still matters: it shows you checked, and it is often where a missed rule turns up.

Time budgets for 45, 60 and 90 minutes

These budgets are MySmartCoPilot’s recommendation, not a rule of any employer. Use them to notice when one step is eating the round, and adjust them once you know your own speed.

  • 45-minute discussion. Clarify and use cases (steps 1–2): 5 minutes. Entities, relationships and the class model (steps 3–5): 15. Flows and lifecycles (steps 6–7): 10. Public API and the key methods (steps 8–9): 10. Tests (step 10) are named, not written. Extensions and questions (step 11): 5.
  • 60-minute discussion. Steps 1–2: 5 minutes. Steps 3–5: 15. Steps 6–7: 10. Public API and code (steps 8–9): 20. Tests: 5. Extensions and questions: 5.
  • 90-minute machine coding. Design, which squeezes steps 1–5 and the public API into one: 10 minutes. Building (steps 8–9: the main flow first, then validation and errors; flows and lifecycles drawn only when you need them): 45. Tests (step 10): 15. A seam for the likely follow-up, and clean-up (step 11): 10. The demo: 10. The machine-coding playbook later in this module walks through these stages.

In a discussion round the model is the product, so it gets the most time. In a machine-coding round the running program is the product, so design is squeezed into the first ten minutes: requirements, the main classes and the public API, written as code skeletons that you then fill in.

Timer Practise with a timer per phase (5, 15, 10, 10 and 5 minutes) to learn how long each step really takes you.

Write the public API before the internals

The public API is the contract between your classes and their callers: method names, parameters, return values and the errors each method can raise. Writing it before the method bodies settles who is responsible for what while it is still cheap to change, and gives the interviewer something concrete to review.

Here is step 8 for a vending-machine prompt, after about ten minutes of the framework. The requirements were: the machine takes four kinds of coin, sells products from slots with keypad codes such as A1, gives change, can refuse a sale for four reasons, and an operator restocks it. The API is plain Python that imports and runs: value types, an error hierarchy and the machine’s methods as a typing.Protocol, with no bodies yet. The last lines print the API as a summary, read from the code itself:

A vending machine's public API, before any internals Python · vending/vending_api.py
"""A vending machine's public API, written before any of its internals."""
import inspect
from annotationlib import Format
from dataclasses import dataclass
from enum import IntEnum
from typing import Protocol


class Coin(IntEnum):
    """The coins the machine takes, by value in paise."""

    ONE = 100
    TWO = 200
    FIVE = 500
    TEN = 1000


@dataclass(frozen=True)
class Product:
    code: str  # the keypad code of its slot, such as "A1"
    name: str
    price: int  # in paise


class VendingMachine(Protocol):
    def insert(self, coin: Coin) -> int:
        """Add a coin and return the balance in paise."""

    def select(self, code: str) -> Sale:
        """Sell one product. Raises UnknownProduct, SoldOut, NotEnoughMoney or NoChange."""

    def cancel(self) -> list[Coin]:
        """Give back the coins inserted so far; the balance becomes 0."""

    def restock(self, code: str, count: int) -> None:
        """Add count items to a slot (operator only). Raises UnknownProduct."""


@dataclass(frozen=True)
class Sale:
    product: Product
    change: list[Coin]


class VendingError(Exception):
    """Every refusal of the machine is one of these."""


class UnknownProduct(VendingError): ...
class SoldOut(VendingError): ...
class NotEnoughMoney(VendingError): ...
class NoChange(VendingError): ...


def signature(function, skip_self=True):
    """'select(code: str) -> Sale', with the annotations as they are written in the source."""
    sig = inspect.signature(function, annotation_format=Format.STRING)
    params = [p for p in sig.parameters.values() if not (skip_self and p.name == "self")]
    shown = ", ".join(f"{p.name}: {p.annotation}" for p in params)
    return f"{function.__name__}({shown}) -> {sig.return_annotation}"


print("Coin:", ", ".join(f"{c.name}={c.value}" for c in Coin))
for value_class in (Product, Sale):
    print(signature(value_class, skip_self=False).replace(" -> None", ""))
print("VendingError:", ", ".join(e.__name__ for e in VendingError.__subclasses__()))
print("VendingMachine")
for name, member in VendingMachine.__dict__.items():
    if inspect.isfunction(member) and not name.startswith("_"):
        print("  " + signature(member))
        print("    " + inspect.getdoc(member))

Output

Coin: ONE=100, TWO=200, FIVE=500, TEN=1000
Product(code: str, name: str, price: int)
Sale(product: Product, change: list[Coin])
VendingError: UnknownProduct, SoldOut, NotEnoughMoney, NoChange
VendingMachine
  insert(coin: Coin) -> int
    Add a coin and return the balance in paise.
  select(code: str) -> Sale
    Sell one product. Raises UnknownProduct, SoldOut, NotEnoughMoney or NoChange.
  cancel() -> list[Coin]
    Give back the coins inserted so far; the balance becomes 0.
  restock(code: str, count: int) -> None
    Add count items to a slot (operator only). Raises UnknownProduct.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 vending_api.py

The summary is the whole design conversation in a dozen lines. It shows the decisions an interviewer will probe: money is an integer number of paise (no floating-point rounding), a sale returns its change as coins, and each of the four refusals has its own error class, so a caller can handle “sold out” differently from “not enough money”.

Version note

New in Python 3.14: annotations are evaluated only when something asks for them, so select() can name Sale although Sale is defined further down. In Python 3.13 and older, that line raises NameError unless the annotation is written as a string or the file starts with from __future__ import annotations. The annotationlib module and the annotation_format parameter of inspect.signature(), which the summary uses to print each annotation as it is written, are also new in 3.14.

Keep a numbered list of assumptions

Many questions get the answer “your call”. Do not leave the decision in your head: write it down as a numbered assumption and carry on. A good assumption is specific enough to test, for example:

  • A1 One entry gate.
  • A2 Amounts are whole numbers of paise.
  • A3 One time zone (IST); “a day” is a calendar day there.

The list turns ambiguity into explicit decisions, lets a test check each one, and gives a follow-up question an obvious place to land (“what if there are two gates?” changes A1 and the code that relies on it).

Say trade-offs out loud

When you choose, name the option you did not take and why. “I considered a class for each state of the pass, but four states and three events fit an enum and one method, so I left it out” shows that you know the pattern and its cost. Adding patterns because designs are supposed to have them does the opposite. Careful code reviewers watch for exactly this kind of over-engineering, code that is more general than the problem needs (What to look for in a code review, “Complexity”).

A worked answer sheet

The whole framework fits on one page. On the first tab is a blank template; on the second, the template filled in for a school bus-pass prompt, at the level of detail you can reach in a 45-minute round:

The answer sheet: blank, and filled in for school bus passes

Template · answer_sheet/answer_sheet_template.md

# LLD answer sheet: <prompt>

## 1. Clarify
- R1 …  (one rule per line, numbered, each one testable)
- Out of scope: …

## Assumptions (add to this list the whole time)
- A1 …

## 2. Use cases
- <actor> <does what>

## 3. Entities and responsibilities
| Class | Kind (entity, value, service) | Knows | Does |
|-------|-------------------------------|-------|------|

## 4. Relationships
- <A> has 1..* <B>; <C> uses <D>

## 5. Class diagram
(draw it: the classes of step 3 with the relationships of step 4)

## 6. Key flows
1. … (the most important use case, message by message)

## 7. Lifecycles
- <Class>: state → state (event)

## 8. Public API
- `method(param: type) -> type`  raises …

## 9. Code
(the classes and the flow of step 6 first)

## 10. Tests
- test_r1_… (at least one test per rule, edge cases included)

## 11. Extensions and trade-offs
- Follow-up: … → changes …
- Rejected: … because …

School bus passes · answer_sheet/school_bus_pass.md

# LLD answer sheet: school bus passes

Prompt: "Design bus passes for a school whose buses run on fixed routes."

## 1. Clarify
- R1 A student gets a pass for one route for one term.
- R2 At boarding, the driver's device checks the pass: it is valid only on its own route and inside its term.
- R3 A pass is used at most twice a day (to school and back home).
- R4 A lost pass is blocked at once and replaced; the blocked pass is refused from then on.
- R5 The term fee depends on the distance band of the student's stop: near, middle or far.
- R6 The office can list the boardings of one route on one day.
- Out of scope: collecting the fee, live bus tracking, an app for parents, planning the routes.

## Assumptions
- A1 One school in one time zone (IST); "a day" is a calendar day there.
- A2 Fees are whole numbers of paise.
- A3 A pass covers exactly one route; a student who changes route gets a new pass.
- A4 The device is online when it checks a pass (offline checks are an extension).

## 2. Use cases
- The office issues a pass.
- The driver's device checks a pass at boarding.
- The office blocks a lost pass and issues its replacement.
- The office lists a day's boardings for a route.

## 3. Entities and responsibilities
| Class        | Kind    | Knows                              | Does                       |
|--------------|---------|------------------------------------|----------------------------|
| Student      | entity  | name, home stop                    |                            |
| Route        | entity  | its stops in order, band of a stop | band_of(stop)              |
| Pass         | entity  | student, route, term, status       | allows(route, day), block()|
| Term         | value   | first day, last day                | contains(day)              |
| FeeTable     | value   | fee of each band                   | fee_for(band)              |
| Boarding     | value   | pass, route, time                  |                            |
| BoardingDesk | service | the passes, the day's boardings    | board(pass_id, route, at)  |

## 4. Relationships
- A Student has at most one active Pass; a Pass names one Route; a Route has 2..* stops.
- BoardingDesk uses a PassRepository (in memory) and records Boarding entries.

## 5. Class diagram
(drawn on the whiteboard: the seven classes above with the relationships of step 4)

## 6. Key flow: boarding
1. The device calls BoardingDesk.board(pass_id, route_id, at).
2. BoardingDesk finds the Pass and refuses an unknown or blocked pass.
3. Pass.allows(route_id, day) checks the route and the term.
4. BoardingDesk counts the pass's boardings that day and refuses a third one.
5. BoardingDesk records the Boarding and returns it.

## 7. Lifecycles
- Pass: issued → active (first day of the term) → expired (after the last day); issued or active → blocked (lost), and blocked is final.

## 8. Public API
- `issue_pass(student_id: str, route_id: str, term: Term) -> Pass`
- `board(pass_id: str, route_id: str, at: datetime) -> Boarding`  raises UnknownPass, BlockedPass, WrongRoute, OutsideTerm, DailyLimitReached
- `report_lost(pass_id: str) -> Pass`  blocks the old pass and returns the replacement
- `boardings(route_id: str, day: date) -> list[Boarding]`

## 9. Code
Term, Pass and BoardingDesk.board() first, with an in-memory PassRepository.

## 10. Tests
- test_r1_pass_names_one_route_and_one_term
- test_r2_other_route_is_refused, test_r2_day_after_the_term_is_refused
- test_r3_third_boarding_on_one_day_is_refused, test_r3_count_starts_again_the_next_day
- test_r4_blocked_pass_is_refused_and_replacement_works
- test_r5_fee_follows_the_band_of_the_home_stop
- test_r6_lists_only_that_route_and_that_day

## 11. Extensions and trade-offs
- Follow-up "devices may go offline": keep a denylist of blocked passes on the device and upload boardings later; only BoardingDesk changes.
- Rejected: one class per pass state (the State pattern). Four states and three events fit an enum and one check in Pass.allows().
- Rejected: one pass for every route. R1 and A3 say one route per pass, which keeps the daily count simple.

Notice how the parts refer to each other: the tests in step 10 carry the numbers of the requirements in step 1, the API in step 8 raises one error per refusal in the key flow of step 6, and each rejected option in step 11 is justified by an earlier step: the lifecycle of step 7, or R1 and A3.

Markdown Editor Copy the template into the Markdown editor and fill it in for a prompt of your own; it stays in your browser.

Mistakes the framework prevents

  • Coding before the rules are known. You build the wrong thing quickly, then rebuild it under more pressure.
  • A model nobody can check. Without numbered requirements, neither you nor the interviewer can say whether the design is finished.
  • Silent assumptions. The interviewer sees a design that ignores a case; you knew about it but never said so.
  • Pattern-stuffing. Patterns added for their own sake make the model harder to follow and solve nothing stated.
  • No time left for follow-ups. The last step is where extensibility is tested; protect its five minutes.

Key takeaways

  • Use the same eleven steps every time: clarify, use cases, entities and responsibilities, relationships, class diagram, key flows, lifecycles, public API, code, tests, extensions and trade-offs.
  • Shrink steps for small prompts, but do not skip them; one line per step still shows you checked.
  • Budget the time: about 5 minutes to clarify and list the use cases in a 45-minute discussion, about 10 minutes of design before 45 minutes of building in a 90-minute machine-coding round.
  • Write the public API, with its error cases, before the method bodies.
  • Write every “your call” down as a numbered assumption, and name the options you rejected.

Exercise

Exercise · Easy · Python

Write the public API of a bike-sharing dock

Prompt: "Design the docks of a city's bicycle-sharing scheme." After clarifying, the rules are: a dock has a fixed number of slots; a member rents a bike from a dock that has one; a bike is returned into any free slot of any dock; and the app shows how many bikes and how many free slots a dock has.

Fill in the lesson's answer sheet for this prompt on paper or in an editor, then write step 8, the public API, in bike_dock.py. The method bodies can wait (raise NotImplementedError is fine); the signatures and the error cases cannot:

  • rent_bike(self, member_id: str) -> str unlocks a bike and returns its id; its docstring says that it raises NoBikeAvailable when the dock is empty.
  • return_bike(self, bike_id: str) -> int locks a bike into a free slot and returns the slot number; its docstring says that it raises DockFull when every slot is taken.
  • free_slots(self) -> int and bikes_available(self) -> int give the two counts the app shows.
  • DockFull and NoBikeAvailable are subclasses of DockError.
  • The constructor refuses a dock without slots: BikeDock("D1", 0) raises ValueError.

The sample tests read the signatures with inspect.signature(), so the parameter names and the annotations must be exactly as above.

Starter code · bike_dock.py

"""The public API of a bicycle-sharing dock: signatures and error cases first, internals later."""


class DockError(Exception):
    """Every refusal of a dock is one of these."""


class BikeDock:
    def __init__(self, dock_id: str, capacity: int) -> None:
        self.dock_id = dock_id
        self.capacity = capacity

    # Add the rest of the API here.
The sample tests · test_bike_dock.py
import inspect
from annotationlib import Format

import bike_dock
from bike_dock import BikeDock, DockError


def api(name):
    """The parameters (without self) and the return annotation of BikeDock.<name>, as written."""
    method = getattr(BikeDock, name, None)
    assert callable(method), f"BikeDock has no method {name}()"
    sig = inspect.signature(method, annotation_format=Format.STRING)
    params = [(p.name, p.annotation) for p in sig.parameters.values() if p.name != "self"]
    return params, sig.return_annotation


def error_class(name):
    cls = getattr(bike_dock, name, None)
    assert isinstance(cls, type), f"bike_dock.py defines no class {name}"
    return cls


def test_rent_bike():
    """rent_bike(member_id: str) -> str"""
    assert api("rent_bike") == ([("member_id", "str")], "str")


def test_return_bike():
    """return_bike(bike_id: str) -> int"""
    assert api("return_bike") == ([("bike_id", "str")], "int")


def test_counts():
    """free_slots() -> int and bikes_available() -> int"""
    assert api("free_slots") == ([], "int")
    assert api("bikes_available") == ([], "int")


def test_error_classes():
    """DockFull and NoBikeAvailable are kinds of DockError"""
    assert issubclass(DockError, Exception)
    assert issubclass(error_class("DockFull"), DockError)
    assert issubclass(error_class("NoBikeAvailable"), DockError)


def test_errors_are_documented():
    """each method's docstring names the error it raises"""
    assert "NoBikeAvailable" in (inspect.getdoc(BikeDock.rent_bike) or "")
    assert "DockFull" in (inspect.getdoc(BikeDock.return_bike) or "")


def test_capacity_must_be_positive():
    """the constructor refuses a capacity below 1 with ValueError"""
    BikeDock("D1", 1)
    for capacity in (0, -3):
        try:
            BikeDock("D1", capacity)
        except ValueError:
            continue
        raise AssertionError(f"BikeDock('D1', {capacity}) was accepted")
A hint

An error class needs no body but a docstring: class DockFull(DockError): followed by a one-line docstring is a complete class. In the constructor, check capacity < 1 before you store anything and raise ValueError(...) with a short message.

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

6 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 6 Put these steps of the answer framework in the order you do them.

    Give each item its position, from 1 (first).

    Show the answer to question 1

    Answer:

    1. Clarify the requirements
    2. Find the entities and their responsibilities
    3. Draw the class diagram
    4. Write the public API
    5. Write the code
    6. Write the tests

    Requirements come first because every later step depends on them. Entities and responsibilities give the diagram its boxes, the API fixes what each class promises, and only then do the code and its tests fill the contract in.

  2. Question 2 of 6 In a 90-minute machine-coding round, about how long does the lesson suggest spending on design before you start building?

    Choose one answer.

    Show the answer to question 2

    Answer: About 10 minutes

    About 10 minutes of design (requirements, classes, the public API) leaves about 45 minutes to build, 15 for tests, 10 for a follow-up seam and clean-up and 10 for the demo. Without any design you tend to rewrite; with too much, the program is not finished.

  3. Question 3 of 6 You ask whether a bus pass may cover several routes, and the interviewer says "your call". What is the best next move?

    Choose one answer.

    Show the answer to question 3

    Answer: Choose one answer, add it to the assumptions list (for example "A3 one route per pass") and carry on

    "Your call" invites a decision. Writing it down as a numbered assumption makes it explicit and testable, and a later follow-up can change it in one known place. Designing for both options at once costs time for a requirement nobody stated.

  4. Question 4 of 6 Why write the public API (signatures and error cases) before the internals?

    Choose one answer.

    Show the answer to question 4

    Answer: It fixes what each class promises and how it fails, so responsibilities are settled before any code depends on them

    The API is the contract between classes. Agreeing on it early, including the errors, shows the design to the interviewer while it is still cheap to change, and the code that follows only fills it in.

  5. Question 5 of 6 Which of these are useful entries for an assumptions list?

    Choose every answer that is right.

    Show the answer to question 5

    Answer:

    • Amounts are whole numbers of paise
    • A pass covers exactly one route
    • One time zone (IST); a day is a calendar day there

    A good assumption is a decision that a test could check: integer amounts, one time zone, one route per pass. "Fast enough" and "flexible" decide nothing and cannot be tested; replace them with a measurable rule if they matter.

  6. Question 6 of 6 Which remark shows judgement about design patterns?

    Choose one answer.

    Show the answer to question 6

    Answer: "I considered a class per pass state, but four states and three events fit an enum and one check, so I left it out."

    Naming an option you considered and why you rejected it shows that you know the pattern and its cost. Adding patterns for their own sake makes the design harder to follow without solving a stated requirement.

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.