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.
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
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.
- Understand: step 1, Clarify, and step 2, Use cases. It hands numbered requirements to the next phase.
- 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.
- Build: step 8, Public API; step 9, Code; step 10, Tests. It hands code that runs and is tested to the last phase.
- 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:
- Clarify. Numbered requirements (R1, R2 …) and an out-of-scope list. The next lesson is about this step.
- Use cases. Who uses the system and the three to six main things each of them does, one line each.
- Entities and responsibilities. The candidate classes and what each one knows and does. A later lesson in this module shows how to find them.
- Relationships. Which classes hold, contain or use which, with multiplicities: one route has many stops.
- Class diagram. The classes with their key attributes and operations, and the relationships of step 4.
- Key flows. The one or two most important use cases, message by message, as a list or a sequence diagram.
- Lifecycles. For each object whose status changes, its states and the events that move it between them.
- Public API. The signatures of the public methods, what they return and which errors they raise.
- Code. The core classes and the main flow first, then the rest.
- Tests. At least one test for each requirement, with the edge cases.
- 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, 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
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 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:
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) -> strunlocks a bike and returns its id; its docstring says that it raisesNoBikeAvailablewhen the dock is empty.return_bike(self, bike_id: str) -> intlocks a bike into a free slot and returns the slot number; its docstring says that it raisesDockFullwhen every slot is taken.free_slots(self) -> intandbikes_available(self) -> intgive the two counts the app shows.DockFullandNoBikeAvailableare subclasses ofDockError.- The constructor refuses a dock without slots:
BikeDock("D1", 0)raisesValueError.
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.
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
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.
References
- Unified Modeling Language (UML) specification, version 2.5.1 (Object Management Group)
- What to look for in a code review (Google Engineering Practices)
- typing: Protocol (Python Software Foundation)
- inspect: Introspecting callables with the Signature object (Python Software Foundation)
- What's New In Python 3.14: deferred evaluation of annotations (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress