Low-Level Design (Object-Oriented Design) Module 1 – LLD rounds and a repeatable answer method
Clarifying requirements and scope
Turn a vague low-level design prompt into numbered, testable requirements and an out-of-scope list by asking about rules, then trace each rule to a unit test.
What you will learn
- Ask questions that separate functional from non-functional requirements
- Write numbered, testable requirements with explicit out-of-scope items
- Convert each requirement into at least one acceptance test case
Before you start
On this page
Low-level design prompts are short on purpose: “design a parking lot”, “design a library”. The rules that make a design right or wrong are missing. Who pays how much? What happens on a tie, after midnight, when something is missing or done twice? Until those rules are known, no design can be checked, and an interviewer may hold some back to see whether you ask.
Clarifying is the first step of the answer framework. Its output is small and concrete: a short list of numbered, testable requirements and an equally explicit list of what is out of scope, in about five minutes.
Ask about behaviour, not technology
Useful questions are about what the system does and the rules it follows. A checklist to run through:
- Actors and actions. Who uses it, and what can each of them do? (Driver, attendant, administrator.)
- Rules and limits. How many, how much, how long, and exactly where the boundary is: is 60 minutes one hour or two?
- Failures. What if the thing is missing, full, already done, expired or paid twice?
- Money and time. Units and rounding, time zones, stays that cross midnight, periods that start or end.
- Lifecycle. Which states does an object go through, can a step be undone, does anything expire?
- Qualities inside the program. May two threads use it at once? How much work may one operation do? Must the results be repeatable in tests?
Questions about databases, clouds or services belong to a high-level design round. In an LLD round they use up time and say nothing about the classes.
Functional and non-functional requirements
A functional requirement says what the system does: “a car pays 40 for each started hour”. A non-functional requirement says how well, or under what constraints, it does it. In low-level design these qualities are local to one program:
- Determinism: the fee calculator takes the time as an argument instead of reading the clock, so a test can choose any moment.
- Cost of an operation: pricing a stay takes the same few steps whatever its length, with no loop over hours.
- Thread safety: two exits paying the same ticket at the same moment cannot both succeed.
- Memory bounds: a rate limiter keeps a fixed amount of data per user, not every request it has seen.
Good requirement guides keep both kinds and ask that each one can be verified. NASA’s checklist for requirements, for example, asks whether each requirement is uniquely numbered, whether it states what is needed rather than how to build it, and whether a test, demonstration, inspection or analysis can show that it is met. It lists words such as “fast”, “user-friendly” and “flexible” as ones to avoid (NASA Systems Engineering Handbook, Appendix C).
A worked clarification: parking fees
Prompt: “Design the fee calculation for a parking lot.” Here is a five-minute exchange, condensed:
- Which vehicles? Cars and motorcycles. A ticket at the entry records the type and the time.
- How is the fee counted? Per started hour: car 40, motorcycle 20. One minute into an hour counts as the hour.
- Is a short stop free? Up to 15 minutes, yes. A longer stay is charged from its first minute.
- Is there a maximum? Yes: each started 24-hour block costs at most 250 for a car and 120 for a motorcycle.
- What if a stay crosses midnight? Nothing special; the blocks are counted from the entry time.
- A lost ticket? A flat 500.
- What can go wrong at the pay desk? An unknown ticket, a ticket that is already paid, an exit time before the entry time. Each is refused with its own message.
- Payments by card, monthly passes, several lots? Not now.
The result is written down at once, numbered, one rule per line:
- R1 A ticket records the vehicle type (car or motorcycle) and the entry time.
- R2 Each started hour costs 40 for a car and 20 for a motorcycle.
- R3 A stay of 15 minutes or less costs nothing; a longer stay is charged from its first minute.
- R4 Each started 24-hour block, counted from the entry time, costs at most 250 for a car and 120 for a motorcycle.
- R5 A stay that crosses midnight is one stay; nothing restarts at midnight.
- R6 A lost ticket costs a flat 500, whatever the stay.
- R7 These are refused, each with its own message: an unknown ticket, a second payment of a ticket, an exit time before the entry time.
- R8 (non-functional) The calculator never reads the clock; the times are passed in.
- R9 (non-functional) Pricing a stay takes the same few steps whatever its length.
- Out of scope: O1 how the money is collected (cash, cards, wallets); O2 monthly passes and reservations; O3 several lots and choosing a parking spot; O4 storage and hardware (barriers, cameras, ticket printers): everything stays in memory.
Out of scope is a requirement too. It stops you from building what nobody asked for (gold-plating), and it shows the interviewer that you saw those areas and left them out on purpose.
From requirements to tests
Here is a fee calculator and a pay desk written from R1 to R9. The comments name the requirement each constant or check comes from:
"""Parking fees, written from the lesson's requirements R1 to R9."""
from dataclasses import dataclass
from datetime import datetime, timedelta
RATE = {"car": 40, "motorcycle": 20} # R2: per started hour
DAILY_CAP = {"car": 250, "motorcycle": 120} # R4: per started 24-hour block
GRACE = timedelta(minutes=15) # R3
LOST_TICKET_FEE = 500 # R6
HOUR, DAY = timedelta(hours=1), timedelta(hours=24)
class FeeError(Exception):
"""A payment that the rules refuse (R7)."""
def started_hours(duration):
whole, rest = divmod(duration, HOUR)
return whole + (1 if rest else 0)
def fee(kind, entered, left):
"""The fee of one stay: the exit time is passed in (R8), and any stay costs the same work (R9)."""
if left < entered:
raise FeeError("exit before entry")
stay = left - entered
if stay <= GRACE:
return 0
full_days, rest = divmod(stay, DAY)
last_block = min(started_hours(rest) * RATE[kind], DAILY_CAP[kind])
return full_days * DAILY_CAP[kind] + last_block
@dataclass
class Ticket:
number: int
kind: str
entered: datetime
paid: bool = False
class PayDesk:
def __init__(self):
self.tickets = {}
def issue(self, kind, entered):
if kind not in RATE:
raise FeeError(f"no rate for {kind}")
ticket = Ticket(len(self.tickets) + 1, kind, entered) # R1
self.tickets[ticket.number] = ticket
return ticket
def pay(self, number, left, lost=False):
ticket = self.tickets.get(number)
if ticket is None:
raise FeeError("unknown ticket")
if ticket.paid:
raise FeeError("already paid")
amount = LOST_TICKET_FEE if lost else fee(ticket.kind, ticket.entered, left)
ticket.paid = True
return amount
if __name__ == "__main__":
nine = datetime(2026, 1, 5, 9, 0)
stays = [("car", 15), ("car", 16), ("car", 61), ("car", 7 * 60), ("car", 30 * 60),
("motorcycle", 125)]
for kind, minutes in stays:
amount = fee(kind, nine, nine + timedelta(minutes=minutes))
print(f"{kind:<10} {minutes:>5} min fee {amount}")
late = nine.replace(hour=23, minute=30)
amount = fee("car", late, late + timedelta(minutes=100))
print(f"car 23:30 to 01:10 next day fee {amount}")
desk = PayDesk()
ticket = desk.issue("car", nine)
paid = desk.pay(ticket.number, nine + timedelta(minutes=80))
print("ticket", ticket.number, "pays", paid)
for number in (ticket.number, 7):
try:
desk.pay(number, nine + timedelta(hours=2))
except FeeError as refusal:
print("ticket", number, "refused:", refusal) Output
car 15 min fee 0 car 16 min fee 40 car 61 min fee 80 car 420 min fee 250 car 1800 min fee 490 motorcycle 125 min fee 60 car 23:30 to 01:10 next day fee 80 ticket 1 pays 80 ticket 1 refused: already paid ticket 7 refused: unknown ticket
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 parking_fee.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
Each requirement then becomes at least one test whose name starts with its number. A reviewer, or an interviewer, can read down the list and see that nothing was forgotten:
import sys
import time
import unittest
from datetime import datetime, timedelta
from parking_fee import FeeError, PayDesk, fee
NINE = datetime(2026, 1, 5, 9, 0)
def after(**duration):
return NINE + timedelta(**duration)
class Fees(unittest.TestCase):
def test_r1_ticket(self):
"""R1: a ticket records the vehicle type and the entry time"""
ticket = PayDesk().issue("motorcycle", NINE)
self.assertEqual((ticket.kind, ticket.entered), ("motorcycle", NINE))
def test_r2_started_hours(self):
"""R2: 61 minutes are two started hours"""
self.assertEqual(fee("car", NINE, after(minutes=61)), 80)
self.assertEqual(fee("motorcycle", NINE, after(minutes=61)), 40)
def test_r3_grace_period(self):
"""R3: 15 minutes cost nothing, 16 minutes cost one hour"""
self.assertEqual(fee("car", NINE, after(minutes=15)), 0)
self.assertEqual(fee("car", NINE, after(minutes=16)), 40)
def test_r4_daily_cap(self):
"""R4: 7 hours are capped at 250; 30 hours cost 250 + 6 x 40"""
self.assertEqual(fee("car", NINE, after(hours=7)), 250)
self.assertEqual(fee("car", NINE, after(hours=30)), 490)
def test_r5_midnight(self):
"""R5: 23:30 to 01:10 is one stay of two started hours"""
late = NINE.replace(hour=23, minute=30)
self.assertEqual(fee("car", late, late + timedelta(minutes=100)), 80)
def test_r6_lost_ticket(self):
"""R6: a lost ticket costs 500, whatever the stay"""
desk = PayDesk()
ticket = desk.issue("car", NINE)
self.assertEqual(desk.pay(ticket.number, after(minutes=20), lost=True), 500)
def test_r7_refusals(self):
"""R7: an unknown ticket, a second payment and an exit before entry are refused"""
desk = PayDesk()
ticket = desk.issue("car", NINE)
desk.pay(ticket.number, after(hours=1))
cases = [
("unknown ticket", lambda: desk.pay(99, after(hours=1))),
("already paid", lambda: desk.pay(ticket.number, after(hours=2))),
("exit before entry", lambda: fee("car", NINE, after(minutes=-5))),
]
for message, action in cases:
with self.subTest(message):
with self.assertRaises(FeeError) as refused:
action()
self.assertEqual(str(refused.exception), message)
def test_r8_time_passed_in(self):
"""R8: the times are passed in, so a test can use any moment"""
far = datetime(2040, 6, 1, 9, 0)
later = fee("car", far, far + timedelta(hours=3))
self.assertEqual(later, fee("car", NINE, after(hours=3)))
def test_r9_constant_work(self):
"""R9: a thousand ten-year stays are priced in well under a second"""
start = time.perf_counter()
for _ in range(1000):
amount = fee("car", NINE, after(days=3650))
self.assertEqual(amount, 3650 * 250)
self.assertLess(time.perf_counter() - start, 1.0)
if __name__ == "__main__":
suite = unittest.defaultTestLoader.loadTestsFromTestCase(Fees)
unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite) Output
test_r1_ticket (__main__.Fees.test_r1_ticket) R1: a ticket records the vehicle type and the entry time ... ok test_r2_started_hours (__main__.Fees.test_r2_started_hours) R2: 61 minutes are two started hours ... ok test_r3_grace_period (__main__.Fees.test_r3_grace_period) R3: 15 minutes cost nothing, 16 minutes cost one hour ... ok test_r4_daily_cap (__main__.Fees.test_r4_daily_cap) R4: 7 hours are capped at 250; 30 hours cost 250 + 6 x 40 ... ok test_r5_midnight (__main__.Fees.test_r5_midnight) R5: 23:30 to 01:10 is one stay of two started hours ... ok test_r6_lost_ticket (__main__.Fees.test_r6_lost_ticket) R6: a lost ticket costs 500, whatever the stay ... ok test_r7_refusals (__main__.Fees.test_r7_refusals) R7: an unknown ticket, a second payment and an exit before entry are refused ... ok test_r8_time_passed_in (__main__.Fees.test_r8_time_passed_in) R8: the times are passed in, so a test can use any moment ... ok test_r9_constant_work (__main__.Fees.test_r9_constant_work) R9: a thousand ten-year stays are priced in well under a second ... ok ---------------------------------------------------------------------- Ran 9 tests in 0.002s OK
This output changes from run to run: unittest prints how long the tests took, which differs from run to run
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 test_parking_fee.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
unittest treats every method whose name starts with test as a test, and with verbosity=2 it prints each test’s
name and the first line of its docstring. The last lines load the tests of Fees and run them with a runner that
writes to standard output (by default unittest reports on standard error). Loading the class directly also works
where unittest.main() cannot find the tests by itself, as in this page’s browser runner; on your computer,
python3 -m unittest -v test_parking_fee runs the same tests.
What each test pins down:
test_r1_ticket: the ticket keeps the vehicle type and the entry time.test_r2_started_hours: 61 minutes are two started hours, at both rates.test_r3_grace_period: 15 minutes cost 0, and 16 minutes cost one hour.test_r4_daily_cap: 7 hours pay the cap; 30 hours pay the cap once plus 6 started hours.test_r5_midnight: 23:30 to 01:10 is priced as one stay of two started hours.test_r6_lost_ticket: a lost ticket costs 500, even after only 20 minutes.test_r7_refusals: each refusal raisesFeeErrorwith its own message, one sub-test each.test_r8_time_passed_in: a three-hour stay costs the same on any date, even years ahead.test_r9_constant_work: a thousand ten-year stays are priced in well under a second.
R9’s test has a generous margin on purpose: a calculation that loops over every hour would need millions of steps for those stays and fail it, while normal slowdowns of a busy computer or a browser would not.
Break one rule and watch its test fail
Save both files in one folder on your computer, change GRACE in parking_fee.py to 10 minutes and run
python3 test_parking_fee.py. Only test_r3_grace_period fails, and its name tells you which requirement you
broke.
Boundaries hide inside the rules
Most wrong answers are right in the middle and wrong at the edges. “Per started hour” is a typical trap: dividing the minutes by 60 with floor division rounds the hour down and undercharges every stay that is not a whole number of hours. The edge case is exactly 60 minutes, where both rules agree:
from datetime import timedelta
HOUR = timedelta(hours=1)
def hours_rounded_down(stay):
return stay // HOUR # the common mistake: a started hour is dropped
def hours_started(stay):
whole, rest = divmod(stay, HOUR)
return whole + (1 if rest else 0)
for minutes in (59, 60, 61, 125):
stay = timedelta(minutes=minutes)
down, started = hours_rounded_down(stay), hours_started(stay)
print(f"{minutes} min: rounded down {down}, started {started}") Output
59 min: rounded down 0, started 1 60 min: rounded down 1, started 1 61 min: rounded down 1, started 2 125 min: rounded down 2, started 3
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 started_hours.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
timedelta // timedelta gives a whole number rounded down, and divmod() gives that number and the remainder, so
“add one when there is a remainder” counts started hours exactly, with no floating-point division. Whenever a rule
has a number in it, test just below, at and just above that number.
Rules that are not stated at all are where follow-up questions come from. Listen for:
- Ties: two bids at the same price, two players with the same score.
- Crossing a boundary: a booking that spans midnight, a stay that starts on one tariff and ends on another.
- Failure halfway: what happens to a reserved seat if the payment fails afterwards?
- Repetition: the same request twice, a second cancellation, a retry after a timeout.
Key takeaways
- Ask about behaviour and rules (actors, limits, failures, money, time, lifecycle), not about technology.
- Write requirements down as you hear them, numbered (R1, R2 …), one rule each, with numbers instead of words such as “fast”.
- Non-functional requirements in LLD are local: deterministic time, the cost of one operation, thread safety and memory bounds.
- List what is out of scope; it is a decision too.
- Turn every requirement into at least one test named after it, and test both sides of every boundary.
Exercise
Exercise · Medium · Python
Turn a printer queue's rules into acceptance cases
The prompt: "Our floor shares one printer. People send jobs, the printer prints them in order, and people can cancel their own jobs. Big jobs clog it, so there should be a limit." You asked questions, and these are the answers:
- How is a job sent?
SUBMIT <job> <user> <pages>, and the printer answersQUEUED <job>. - In what order are jobs printed? In the order they were sent.
PRINTprints the oldest waiting job and answersPRINTED <job>; with nothing waiting it answersIDLE. - What is a big job? More than 50 pages:
REJECTED <job> too many pages. Exactly 50 pages is fine. - Can one person fill the queue? No: at most 2 waiting jobs per user. Another one gets
REJECTED <job> too many jobs. - Who may cancel? Only the job's owner:
CANCEL <job> <user>answersCANCELLED <job>. For someone else's job it answersREFUSED <job> not yours, and for a job that is not waiting,UNKNOWN <job>. - Can people see the queue?
STATUSanswers the waiting job ids, oldest first, separated by spaces, orEMPTY.
In printer_cases.py, write the answers as numbered requirements in REQUIREMENTS ("R1", "R2" …, at least six, each with a short text) and acceptance cases in CASES. A case is a dictionary with the requirement it checks, the command lines to run on a fresh printer, and the exact answer expected for each line. R1 and its case are done for you.
The sample tests check that every case names a requirement and every requirement has a case, run your cases on a correct printer (every case must pass) and on three printers with a bug each. Every buggy printer must fail at least one of your cases, so think about the boundaries and the people the rules are about.
Starter code · printer_cases.py
"""Numbered requirements and acceptance cases for the shared printer queue of the prompt."""
REQUIREMENTS = {
"R1": "SUBMIT <job> <user> <pages> adds the job at the back and answers QUEUED <job>",
}
CASES = [
{
"requirement": "R1",
"commands": ["SUBMIT a ana 3"],
"expected": ["QUEUED a"],
},
] The sample tests · test_printer_cases.py
import re
from printer_cases import CASES, REQUIREMENTS
class Printer:
"""A correct shared printer queue, built from the answers in the prompt."""
PAGE_LIMIT = 50
JOBS_PER_USER = 2
def __init__(self):
self.queue = [] # (job, user), oldest first
def submit(self, job, user, pages):
if pages > self.PAGE_LIMIT:
return f"REJECTED {job} too many pages"
if sum(1 for _, owner in self.queue if owner == user) >= self.JOBS_PER_USER:
return f"REJECTED {job} too many jobs"
self.queue.append((job, user))
return f"QUEUED {job}"
def take_next(self):
return self.queue.pop(0)
def print_next(self):
if not self.queue:
return "IDLE"
job, _ = self.take_next()
return f"PRINTED {job}"
def may_cancel(self, owner, user):
return owner == user
def cancel(self, job, user):
for i, (waiting, owner) in enumerate(self.queue):
if waiting == job:
if not self.may_cancel(owner, user):
return f"REFUSED {job} not yours"
del self.queue[i]
return f"CANCELLED {job}"
return f"UNKNOWN {job}"
def status(self):
return " ".join(job for job, _ in self.queue) or "EMPTY"
class NewestFirst(Printer):
"""Bug: prints the newest job first."""
def take_next(self):
return self.queue.pop()
class FiftyPagesRefused(Printer):
"""Bug: refuses a job of exactly 50 pages."""
PAGE_LIMIT = 49
class AnyoneMayCancel(Printer):
"""Bug: lets anyone cancel any job."""
def may_cancel(self, owner, user):
return True
def run(printer, commands):
"""What the printer answers to each command line."""
out = []
for line in commands:
name, *args = line.split()
if name == "SUBMIT":
job, user, pages = args
out.append(printer.submit(job, user, int(pages)))
elif name == "PRINT":
out.append(printer.print_next())
elif name == "CANCEL":
out.append(printer.cancel(*args))
elif name == "STATUS":
out.append(printer.status())
else:
raise ValueError(f"unknown command {line!r}")
return out
def caught(buggy_printer):
"""True when at least one case gives a different answer on the buggy printer."""
return any(run(buggy_printer(), case["commands"]) != case["expected"] for case in CASES)
def test_requirements_are_numbered():
"""at least six requirements, numbered R1, R2 …, each with its text"""
assert len(REQUIREMENTS) >= 6, f"only {len(REQUIREMENTS)} requirements"
for rid, text in REQUIREMENTS.items():
assert re.fullmatch(r"R[1-9][0-9]*", rid), f"{rid!r} is not numbered like R1"
assert isinstance(text, str) and text.strip(), f"{rid} has no text"
def test_cases_name_their_requirement():
"""every case names one of the requirements and expects one line per command"""
for i, case in enumerate(CASES, start=1):
assert case["requirement"] in REQUIREMENTS, f"case {i} names {case['requirement']!r}"
assert case["commands"], f"case {i} has no commands"
assert len(case["expected"]) == len(case["commands"]), f"case {i}: one line per command"
def test_every_requirement_has_a_case():
"""every requirement is checked by at least one case"""
covered = {case["requirement"] for case in CASES}
assert sorted(set(REQUIREMENTS) - covered) == []
def test_cases_pass_on_the_correct_printer():
"""every case passes on a correct printer"""
for i, case in enumerate(CASES, start=1):
answers = run(Printer(), case["commands"])
assert answers == case["expected"], f"case {i} ({case['requirement']})"
def test_catches_newest_first():
"""a case fails on a printer that prints the newest job first"""
assert caught(NewestFirst)
def test_catches_fifty_pages_refused():
"""a case fails on a printer that refuses a job of exactly 50 pages"""
assert caught(FiftyPagesRefused)
def test_catches_anyone_may_cancel():
"""a case fails on a printer that lets anyone cancel any job"""
assert caught(AnyoneMayCancel) A hint
A case catches a bug only if the buggy printer would answer differently. For the order of printing, send two jobs before the first PRINT; for the page limit, try exactly 50 pages as well as 51; for cancelling, let a second user try to cancel the first user's job before the owner does.
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
- NASA Systems Engineering Handbook, Appendix C: How to Write a Good Requirement (National Aeronautics and Space Administration)
- unittest: Unit testing framework (Python Software Foundation)
- datetime: timedelta objects (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress