Low-Level Design (Object-Oriented Design) Module 17 – Machine-coding and interview practice
Handling follow-up questions and extensions
How to answer low-level design (LLD) follow-up questions: find the seam, change little, prove it with tests, and use HLD terms for scale.
What you will learn
- Respond to a new requirement by finding the seam and changing little
- Answer scaling and failure questions by bridging to HLD concepts
- Explain trade-offs clearly under time pressure
Before you start
What you’ll learn · Before you start
What you will learn
- Respond to a new requirement by finding the seam and changing little
- Answer scaling and failure questions by bridging to HLD concepts
- Explain trade-offs clearly under time pressure
Before you start
On this page
A follow-up question changes the problem once your design works: add electric cars, charge for late cancellations, open a second gate, survive a restart, run on three servers. The interviewer is watching where the change lands. A strong answer finds the seam, the class or interface that already owns the decision being changed, makes the smallest change there, and says three things out loud: where the change goes, what stays untouched, and how the tests prove it. Scale questions are the one exception, because they leave the single program your design lives in, so you answer them in the terms of high-level design (HLD): shared storage, unique constraints and idempotency keys.
This lesson sorts follow-ups into five kinds, applies ten of them to three small designs with every diff measured and every test run, then shows how to cross into HLD and how to state a trade-off in one breath.
Five kinds of follow-up, and where each one lands
Most follow-ups belong to one of five kinds, and the kind tells you where to look first:
| Kind, and a typical prompt | Where the change usually lands |
|---|---|
| A new variant: “Add electric cars”, “add e-books” | The list or type the rules already switch on: a new value, or a new class behind an interface the code already calls |
| A new rule: “Late cancellations refund half”, “students hold five books” | The class that already owns the related decision |
| Concurrency: “A second gate opens”, “two transfers arrive at once” | The object that owns the invariant, which must check and act in one step |
| Persistence: “Survive a restart”, “keep the history” | The repository: a second implementation behind the same methods |
| Scale and failure: “Three servers share a database”, “the app resends a request” | Outside the program: the shared store owns the invariant, and each request carries an id |
The kinds differ in how far they reach. A variant or a rule usually stops inside one class; concurrency reaches the objects that share memory; persistence reaches the edge of the running program; scale and failure cross it:
How far a follow-up reaches, and where its change lands
Text description of the diagram
The diagram shows four boxes, each inside the next, from the smallest to the largest.
- One class. Variants and rules usually stop here: a new value in a list or table, a new subclass behind an interface the code already uses, or one changed method.
- Objects in memory, around the class. Concurrency follow-ups reach this far: the object that owns a rule, such as one ticket per spot, must check and act in one step.
- One running program, around the objects. Persistence follow-ups reach this far: a second store behind the repository interface keeps the data when the program stops.
- Several servers that share data, around everything. Scale and failure follow-ups reach this far: the shared store must own the rule, and each request carries an id so that a repeat changes nothing. This is where the answer moves to high-level design.
Two habits make the sorting quick. First, restate the follow-up as a numbered requirement with its edge case, as you did for the original prompt: “R7: an electric car takes a charging spot, or a car spot when every charging spot is taken.” Second, ask which class owns that decision today. If one class owns it, that class is the seam. If none does, because the rule is spread over several methods or the concept it needs does not exist yet, say so: the follow-up has found a missing seam, and the honest answer starts with a small refactoring.
The three-part answer
An answer to a follow-up has three parts, in this order, and it takes about thirty seconds before you touch the keyboard:
- Where the change goes. Name the class and the method, and what you add or change there.
- What stays untouched. Name the classes and rules that do not change. This is the evidence that the design’s boundaries are in the right places, and it is the part most answers leave out.
- How the tests prove it. Every old test still passes on the new code, and each new test fails on the old code and passes on the new code. A new test that passes before the change proves nothing about the change.
For the electric cars of R7, spoken: “The change goes into Kind, which gains EV, and into the one method that
chooses a spot, which now tries spot kinds in order: charging spots, then car spots, for an electric car. Ticket,
the fee, the ticket store, park() and leave() stay as they are. The four existing tests still pass, and two new
ones, the fallback and ‘a car never takes a charging spot’, fail now and pass after the change.”
Resist building seams in advance for follow-ups nobody has asked for. The public code-review guide What to look for in a code review tells reviewers to watch for over-engineering and to solve the problem that needs solving now, leaving a future problem until it arrives. A follow-up is exactly that arrival: the moment a seam earns its place.
Ten follow-ups on three designs, measured
“A small change” is easy to claim and more convincing to measure. Each program below takes a design as it stands
after a timed round, applies each follow-up to it as a list of edits, on its own, and prints what an interviewer
checks: the seam, the lines changed by class, the classes left untouched and the tests. Old tests run on the new
code, and new tests run on both versions, so each one is seen failing before the change. The edits and tests are in
the first tab, the design in the second, and seams.py, which compares the versions with difflib and names the
class and method of every changed line with ast, in the third.
followups/parking_followups.py
"""Three follow-ups to the parking lot, each applied to the base design on its own."""
import os
from seams import FollowUp, report
RATE = {"bike": 1000, "car": 4000, "ev": 5000} # paise per started hour, by kind
CARS = ["C1", "C2"]
def lot(m, spots=None, **options):
spots = spots or {m.Kind.CAR: CARS, m.Kind.BIKE: ["B1"]}
fee = m.HourlyFee({kind: RATE[kind.value] for kind in m.Kind})
return m.ParkingLot(spots, fee, printer=options.pop("printer", lambda line: None), **options)
# ---- the tests the base design already passes ------------------------------------------------------------------------
def check_first_free_spot_of_its_kind(m):
p = lot(m)
assert [p.park(plate, m.Kind.CAR, 0).spot for plate in ("KA01", "KA02")] == CARS
assert p.park("KA03", m.Kind.BIKE, 0).spot == "B1"
def check_full_kind_is_refused(m):
p = lot(m)
p.park("KA01", m.Kind.BIKE, 0)
try:
p.park("KA02", m.Kind.BIKE, 0)
except m.LotFull:
return
raise AssertionError("a second bike got a spot")
def check_leaving_frees_the_spot(m):
p = lot(m)
ticket = p.park("KA01", m.Kind.CAR, 0)
p.leave(ticket.id, 30)
assert p.free_spots(m.Kind.CAR) == 2 and p.park("KA02", m.Kind.CAR, 40).spot == "C1"
def check_fee_counts_started_hours(m):
p = lot(m)
fees = [p.leave(p.park(f"KA0{i}", m.Kind.CAR, 0).id, left) for i, left in enumerate((60, 61))]
assert fees == [4000, 8000], fees
BASE_TESTS = [check_first_free_spot_of_its_kind, check_full_kind_is_refused, check_leaving_frees_the_spot,
check_fee_counts_started_hours]
# ---- A1: a new variant -----------------------------------------------------------------------------------------------
def check_ev_falls_back_to_a_car_spot(m):
p = lot(m, {m.Kind.CAR: CARS, m.Kind.EV: ["E1"]})
assert [p.park(plate, m.Kind.EV, 0).spot for plate in ("EV01", "EV02")] == ["E1", "C1"]
def check_car_never_takes_a_charging_spot(m):
p = lot(m, {m.Kind.CAR: CARS, m.Kind.EV: ["E1"]})
assert [p.park(plate, m.Kind.CAR, 0).spot for plate in ("KA01", "KA02")] == CARS
try:
p.park("KA03", m.Kind.CAR, 0)
except m.LotFull:
return
raise AssertionError("a car took the charging spot")
A1 = FollowUp(
"A1", "Add charging spots: only electric cars use them, and one takes a car spot when they are full.",
kind="variant", seam="Kind, which the lot already allocates by, and the order of spot kinds to try",
edits=[
(' CAR = "car"\n', ' CAR = "car"\n EV = "ev"\n'),
("class LotFull(", "FALLBACK = {Kind.EV: (Kind.EV, Kind.CAR)} # spot kinds to try, in order\n\n\nclass LotFull("),
(""" for spot in self._spots.get(kind, []):
if spot not in self._holder:
return spot
""", """ for spot_kind in FALLBACK.get(kind, (kind,)):
for spot in self._spots.get(spot_kind, []):
if spot not in self._holder:
return spot
"""),
],
tests=[check_ev_falls_back_to_a_car_spot, check_car_never_takes_a_charging_spot],
)
# ---- A2: concurrency -------------------------------------------------------------------------------------------------
def check_two_gates_get_two_spots(m):
"""Gate B's driver arrives while gate A prints its ticket, the widest gap between finding a spot and taking it."""
printed, second = [], []
def printer(line):
printed.append(line)
if len(printed) == 1:
second.append(p.park("KA02", m.Kind.CAR, 0))
p = lot(m, printer=printer)
first = p.park("KA01", m.Kind.CAR, 0)
assert first.spot != second[0].spot, f"both drivers were sent to {first.spot}"
assert p.free_spots(m.Kind.CAR) == 0
A2 = FollowUp(
"A2", "A second entry gate opens, so two drivers can arrive at the same moment.",
kind="concurrency", seam="ParkingLot.park: find a spot and take it in one step, with no slow call inside",
edits=[
("from dataclasses import dataclass\n", "import threading\nfrom dataclasses import dataclass\n"),
(" self._holder = {} # spot -> id of the ticket parked there\n",
" self._holder = {} # spot -> id of the ticket parked there\n self._lock = threading.Lock()\n"),
(""" spot = self._free_spot(kind)
self._printer(f"ticket for {plate}: go to {spot}")
return self._occupy(spot, plate, kind, at)
""", """ with self._lock:
spot = self._free_spot(kind)
ticket = self._occupy(spot, plate, kind, at)
self._printer(f"ticket for {plate}: go to {spot}")
return ticket
"""),
(""" ticket = self._tickets.remove(ticket_id)
del self._holder[ticket.spot]
""", """ with self._lock:
ticket = self._tickets.remove(ticket_id)
del self._holder[ticket.spot]
"""),
],
tests=[check_two_gates_get_two_spots],
)
# ---- A3: persistence -------------------------------------------------------------------------------------------------
def check_restart_keeps_open_tickets(m):
path = "tickets.json"
try:
before = lot(m, tickets=m.JsonTickets(path))
stays = before.park("KA01", m.Kind.CAR, 0)
before.leave(before.park("KA02", m.Kind.CAR, 5).id, 30)
after = lot(m, tickets=m.JsonTickets(path)) # the program restarted
assert after.free_spots(m.Kind.CAR) == 1
assert after.park("KA03", m.Kind.CAR, 60).id == 3, "a ticket number was given out twice"
assert after.leave(stays.id, 125) == 12000
finally:
if os.path.exists(path):
os.remove(path)
A3 = FollowUp(
"A3", "The program restarts every night; cars parked before the restart must still leave and pay.",
kind="persistence", seam="the ticket store: a second class behind the methods the lot already calls",
edits=[
("from dataclasses import dataclass\n", "import json\nimport os\nfrom dataclasses import dataclass\n"),
("class ParkingLot:\n", '''class JsonTickets(InMemoryTickets):
"""The same tickets, written to a file after every change and read back when the program starts."""
def __init__(self, path):
super().__init__()
self._path = path
if os.path.exists(path):
with open(path, encoding="utf-8") as file:
saved = json.load(file)
self._last_id = saved["last_id"]
for t in saved["tickets"]:
self._by_id[t["id"]] = Ticket(t["id"], t["plate"], Kind(t["kind"]), t["spot"], t["entered"])
def save(self, ticket):
super().save(ticket)
self._write()
def remove(self, ticket_id):
ticket = super().remove(ticket_id)
self._write()
return ticket
def _write(self):
tickets = [{**vars(t), "kind": t.kind.value} for t in self.all()]
with open(self._path + ".tmp", "w", encoding="utf-8") as file:
json.dump({"last_id": self._last_id, "tickets": tickets}, file)
os.replace(self._path + ".tmp", self._path) # a crash mid-write leaves the old file whole
class ParkingLot:
'''),
(" self._holder = {} # spot -> id of the ticket parked there\n",
" self._holder = {t.spot: t.id for t in self._tickets.all()} # rebuilt from the saved tickets\n"),
],
tests=[check_restart_keeps_open_tickets],
)
if __name__ == "__main__":
report("parking.py", BASE_TESTS, [A1, A2, A3]) Output
parking.py: 4 of 4 tests pass on the base design
A1 [variant] Add charging spots: only electric cars use them, and one takes a car spot when they are full.
seam: Kind, which the lot already allocates by, and the order of spot kinds to try
changed: +6 -3 lines; untouched: LotFull, Ticket, HourlyFee, InMemoryTickets
Kind
+ EV = "ev"
top level
+ FALLBACK = {Kind.EV: (Kind.EV, Kind.CAR)} # spot kinds to try, in order
ParkingLot._free_spot
- for spot in self._spots.get(kind, []):
- if spot not in self._holder:
- return spot
+ for spot_kind in FALLBACK.get(kind, (kind,)):
+ for spot in self._spots.get(spot_kind, []):
+ if spot not in self._holder:
+ return spot
tests: old 4/4 pass on the new code; new 0/2 on the old code, 2/2 on the new
on the old code: check_ev_falls_back_to_a_car_spot: AttributeError: type object 'Kind' has no attribute 'EV'
A2 [concurrency] A second entry gate opens, so two drivers can arrive at the same moment.
seam: ParkingLot.park: find a spot and take it in one step, with no slow call inside
changed: +9 -4 lines; untouched: Kind, LotFull, Ticket, HourlyFee, InMemoryTickets
top level
+ import threading
ParkingLot.__init__
+ self._lock = threading.Lock()
ParkingLot.park
- spot = self._free_spot(kind)
+ with self._lock:
+ spot = self._free_spot(kind)
+ ticket = self._occupy(spot, plate, kind, at)
self._printer(f"ticket for {plate}: go to {spot}")
- return self._occupy(spot, plate, kind, at)
+ return ticket
ParkingLot.leave
- ticket = self._tickets.remove(ticket_id)
- del self._holder[ticket.spot]
+ with self._lock:
+ ticket = self._tickets.remove(ticket_id)
+ del self._holder[ticket.spot]
tests: old 4/4 pass on the new code; new 0/1 on the old code, 1/1 on the new
on the old code: check_two_gates_get_two_spots: AssertionError: both drivers were sent to C1
A3 [persistence] The program restarts every night; cars parked before the restart must still leave and pay.
seam: the ticket store: a second class behind the methods the lot already calls
changed: +26 -1 lines; untouched: Kind, LotFull, Ticket, HourlyFee, InMemoryTickets
top level
+ import json
+ import os
JsonTickets: new, 23 lines
ParkingLot.__init__
- self._holder = {} # spot -> id of the ticket parked there
+ self._holder = {t.spot: t.id for t in self._tickets.all()} # rebuilt from the saved tickets
tests: old 4/4 pass on the new code; new 0/1 on the old code, 1/1 on the new
on the old code: check_restart_keeps_open_tickets: AttributeError: module 'parking_base' has no attribute 'JsonTickets'
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 parking_followups.py
followups/parking.py
"""A parking lot as it stands after a timed round: spots by vehicle kind, tickets and an hourly fee in paise."""
from dataclasses import dataclass
from enum import Enum
class Kind(Enum):
BIKE = "bike"
CAR = "car"
class LotFull(Exception):
"""No free spot for the vehicle's kind."""
@dataclass(frozen=True)
class Ticket:
id: int
plate: str
kind: Kind
spot: str
entered: int # minutes after the lot opened
class HourlyFee:
"""Every started hour costs the kind's rate; the first hour is always charged."""
def __init__(self, rates):
self.rates = rates
def fee(self, ticket, left):
hours = max(1, -(-(left - ticket.entered) // 60))
return hours * self.rates[ticket.kind]
class InMemoryTickets:
"""The open tickets, and the counter that numbers them."""
def __init__(self):
self._by_id = {}
self._last_id = 0
def new_id(self):
self._last_id += 1
return self._last_id
def save(self, ticket):
self._by_id[ticket.id] = ticket
def remove(self, ticket_id):
return self._by_id.pop(ticket_id)
def all(self):
return list(self._by_id.values())
class ParkingLot:
def __init__(self, spots, fee, tickets=None, printer=print):
self._spots = spots # {Kind.CAR: ["C1", "C2"], ...}, in the order they are given out
self._fee = fee
self._tickets = tickets if tickets is not None else InMemoryTickets()
self._printer = printer # the gate's ticket printer
self._holder = {} # spot -> id of the ticket parked there
def park(self, plate, kind, at):
spot = self._free_spot(kind)
self._printer(f"ticket for {plate}: go to {spot}")
return self._occupy(spot, plate, kind, at)
def leave(self, ticket_id, at):
ticket = self._tickets.remove(ticket_id)
del self._holder[ticket.spot]
return self._fee.fee(ticket, at)
def free_spots(self, kind):
return sum(spot not in self._holder for spot in self._spots.get(kind, []))
def _free_spot(self, kind):
for spot in self._spots.get(kind, []):
if spot not in self._holder:
return spot
raise LotFull(kind.value)
def _occupy(self, spot, plate, kind, at):
ticket = Ticket(self._tickets.new_id(), plate, kind, spot, at)
self._holder[spot] = ticket.id
self._tickets.save(ticket)
return ticket followups/seams.py
"""Measures a follow-up the way an interviewer judges it: where the change goes, what stays untouched, and what the
tests say. Each follow-up is a list of edits to a design's source. The edits are applied to the base design on their
own, the two versions are compared line by line, and the old and the new tests run against both versions."""
import ast
import difflib
import sys
import types
class FollowUp:
def __init__(self, tag, ask, kind, seam, edits, tests, replaces=()):
self.tag, self.ask, self.kind, self.seam = tag, ask, kind, seam
self.edits = edits # (old text, new text) pairs; each old text occurs exactly once in the base
self.tests = tests # functions that take the design's module and raise when a rule is broken
self.replaces = set(replaces) # names of old tests whose rule the follow-up changes on purpose
def apply(source, edits):
for old, new in edits:
found = source.count(old)
if found != 1:
raise ValueError(f"an edit must match exactly once; this one matches {found} times: {old.strip()[:60]!r}")
source = source.replace(old, new)
return source
def owners(source):
"""The definition that holds each line, such as "ParkingLot.park", by line number."""
owner = {}
def visit(node, prefix):
for child in ast.iter_child_nodes(node):
if isinstance(child, (ast.ClassDef, ast.FunctionDef)):
name = f"{prefix}.{child.name}" if prefix else child.name
first = min([child.lineno] + [d.lineno for d in child.decorator_list])
for line in range(first, child.end_lineno + 1):
owner[line] = name
visit(child, name)
visit(ast.parse(source), "")
return owner
def changes(before, after):
"""The changed lines that are not blank, grouped by owner: {owner: ["- old", "+ new", ...]}, in file order. One or
two unchanged lines between two changes of the same owner are kept as context (" ")."""
a, b = before.splitlines(), after.splitlines()
own_a, own_b = owners(before), owners(after)
out = {}
blocks = difflib.SequenceMatcher(None, a, b, autojunk=False).get_opcodes()
for n, (tag, i1, i2, j1, j2) in enumerate(blocks):
if tag == "equal":
same = 0 < n < len(blocks) - 1 and own_b.get(j1) == own_b.get(j2 + 1) == own_b.get(j1 + 1)
if same and own_b.get(j1 + 1) in out: # unchanged lines inside a definition that changes around them
kept = [f" {line.rstrip()}" for line in b[j1:j2] if line.strip()] if j2 - j1 <= 2 else [" ..."]
out[own_b[j1 + 1]] += kept
continue
for sign, lines, own, start, end in (("-", a, own_a, i1, i2), ("+", b, own_b, j1, j2)):
for i in range(start, end):
if lines[i].strip():
out.setdefault(own.get(i + 1, "top level"), []).append(f"{sign} {lines[i].rstrip()}")
return out
def show(changed, before, after):
"""Prints the changed lines without their common indent; a definition that is new or gone as a whole is one line."""
names_before, names_after = set(owners(before).values()), set(owners(after).values())
done = set()
for owner, lines in changed.items():
top = owner.split(".")[0]
if top in done:
continue
whole = top if top != "top level" and (top not in names_before or top not in names_after) else None
if whole or (owner != "top level" and (owner not in names_before or owner not in names_after)):
name = whole or owner
group = [line for o, ls in changed.items() if o == name or o.startswith(name + ".") for line in ls]
print(f" {name}: {'new' if name not in names_before else 'removed'}, {len(group)} lines")
done.add(whole)
continue
print(f" {owner}")
indent = min(len(line[2:]) - len(line[2:].lstrip()) for line in lines if line != " ...")
for line in lines:
print(f" {line}" if line == " ..." else f" {line[:2]}{line[2 + indent:]}")
def load(name, source):
module = types.ModuleType(name)
sys.modules[name] = module
exec(compile(source, f"{name}.py", "exec"), module.__dict__)
return module
def failures(tests, module):
"""The tests that fail on this module, each with the first line of its error."""
failed = []
for test in tests:
try:
test(module)
except Exception as error: # an assertion, or an error raised because the version lacks the feature
failed.append(f"{test.__name__}: {type(error).__name__}: {(str(error).splitlines() or [''])[0]}")
return failed
def report(file, base_tests, follow_ups):
base = open(file, encoding="utf-8").read()
name = file.removesuffix(".py")
old = load(f"{name}_base", base)
print(f"{file}: {len(base_tests) - len(failures(base_tests, old))} of {len(base_tests)} tests pass on the base design")
for f in follow_ups:
after = apply(base, f.edits)
new = load(f"{name}_{f.tag.lower()}", after)
changed = changes(base, after)
added = sum(line.startswith("+") for lines in changed.values() for line in lines)
removed = sum(line.startswith("-") for lines in changed.values() for line in lines)
tops = {owner.split(".")[0] for owner in changed}
untouched = [node.name for node in ast.parse(after).body if isinstance(node, ast.ClassDef) and node.name not in tops]
print(f"\n{f.tag} [{f.kind}] {f.ask}")
print(f" seam: {f.seam}")
print(f" changed: +{added} -{removed} lines; untouched: {', '.join(untouched) or 'nothing'}")
show(changed, base, after)
kept = [t for t in base_tests if t.__name__ not in f.replaces]
old_on_new = failures(kept, new)
new_on_old, new_on_new = failures(f.tests, old), failures(f.tests, new)
print(f" tests: old {len(kept) - len(old_on_new)}/{len(kept)} pass on the new code;"
f" new {len(f.tests) - len(new_on_old)}/{len(f.tests)} on the old code, {len(f.tests) - len(new_on_new)}/{len(f.tests)} on the new")
for test in sorted(f.replaces):
print(f" replaced: {test} pinned the rule this follow-up changes")
for failure in old_on_new + new_on_new:
print(f" still failing: {failure}")
if not old_on_new + new_on_new and new_on_old:
print(f" on the old code: {new_on_old[0]}") 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
- A1, charging spots. The lot already hands out spots by kind, so the new kind of vehicle is one line in
Kind. The twist in the prompt, that an electric car may take a car spot when the charging spots are full, is a rule, and it went into the one method that chooses spots, as a list of spot kinds to try in order. - A2, a second gate. A test cannot start two threads at exactly the wrong moment, so this one creates the moment: gate A’s printer runs gate B’s whole request, which is what a slow printer allows in real life. On the old code both drivers are sent to C1. The change makes “find a spot” and “take it” one step under a lock and prints after the lock is released, because slow output inside a lock makes every other gate wait. The test checks the order of the steps; with real threads, the lock is what makes the two steps one, and a stress test with threads on your own computer checks that part.
- A3, a restart. The lot already kept tickets behind four methods, so a store that writes a JSON file is a new
class of 23 lines, and the lot changes one line: on start, it rebuilds which spot holds which ticket from the saved
tickets. The restarted lot still knows that C1 is taken, gives the next car ticket 3 instead of reusing 2, and
charges the first car for three hours when it leaves at minute 125. The file is written under a temporary name and
then put in place with
os.replace, which the Python documentation describes as atomic on POSIX systems, so a crash in the middle of a write leaves the old file whole.
followups/booking_followups.py
"""Three follow-ups to the room booker, each applied to the base design on its own."""
from seams import FollowUp, report
RATE = {"Lotus": 20000, "Banyan": 50000} # paise per half hour
def at(day, hhmm):
hours, minutes = map(int, hhmm.split(":"))
return day * 24 * 60 + hours * 60 + minutes
def slot(m, day, start, end):
return m.Slot(at(day, start), at(day, end))
def rooms(m):
return m.RoomBook(RATE, m.Tariff(RATE))
def refused(m, call):
try:
call()
except m.Refused as error:
return str(error)
raise AssertionError("the request was accepted")
# ---- the tests the base design already passes ------------------------------------------------------------------------
def check_books_a_free_room(m):
b = rooms(m).book("Lotus", slot(m, 1, "10:00", "11:00"), "asha")
assert (b.id, b.price) == ("B1", 40000)
def check_overlap_is_refused(m):
r = rooms(m)
r.book("Lotus", slot(m, 1, "10:00", "11:00"), "asha")
assert refused(m, lambda: r.book("Lotus", slot(m, 1, "10:30", "11:30"), "ravi")) == "Lotus is taken by B1"
assert r.book("Banyan", slot(m, 1, "10:30", "11:30"), "ravi").id == "B2"
def check_back_to_back_is_allowed(m):
r = rooms(m)
r.book("Lotus", slot(m, 1, "10:00", "11:00"), "asha")
assert r.book("Lotus", slot(m, 1, "11:00", "12:00"), "ravi").id == "B2"
def check_cancel_refunds_and_frees(m):
r = rooms(m)
b = r.book("Lotus", slot(m, 2, "10:00", "11:00"), "asha")
assert r.cancel(b.id, now=at(0, "09:00")) == 40000
assert r.book("Lotus", slot(m, 2, "10:00", "11:00"), "ravi").id == "B2"
def check_started_booking_stays(m):
r = rooms(m)
b = r.book("Lotus", slot(m, 1, "10:00", "11:00"), "asha")
assert refused(m, lambda: r.cancel(b.id, now=at(1, "10:00"))) == "B1 has already started"
BASE_TESTS = [check_books_a_free_room, check_overlap_is_refused, check_back_to_back_is_allowed,
check_cancel_refunds_and_frees, check_started_booking_stays]
# ---- B1: a new rule --------------------------------------------------------------------------------------------------
def check_late_cancellation_refunds_half(m):
r = rooms(m)
b = r.book("Lotus", slot(m, 2, "10:00", "11:00"), "asha")
assert r.cancel(b.id, now=at(1, "10:01")) == 20000, "23 hours 59 minutes ahead is late"
def check_a_day_ahead_refunds_everything(m):
r = rooms(m)
b = r.book("Lotus", slot(m, 2, "10:00", "11:00"), "asha")
assert r.cancel(b.id, now=at(1, "10:00")) == 40000, "exactly 24 hours ahead is in time"
B1 = FollowUp(
"B1", "Cancelling less than 24 hours before the start refunds only half the price.",
kind="rule", seam="Tariff, which already owns what a booking costs",
edits=[
(""" return (slot.end - slot.start) // 30 * self.per_half_hour[room]
""", """ return (slot.end - slot.start) // 30 * self.per_half_hour[room]
def refund(self, booking, now):
\"\"\"All of the price up to 24 hours before the start, half of it after that.\"\"\"
if booking.slot.start - now >= 24 * 60:
return booking.price
return booking.price // 2
"""),
(" return booking.price # the refund\n", " return self._tariff.refund(booking, now)\n"),
],
tests=[check_late_cancellation_refunds_half, check_a_day_ahead_refunds_everything],
)
# ---- B2: a rule that changes an old one ----------------------------------------------------------------------------
def check_cleaning_time_between_bookings(m):
r = rooms(m)
r.book("Lotus", slot(m, 1, "10:00", "11:00"), "asha")
for start, end in (("11:00", "12:00"), ("09:00", "09:50")):
assert refused(m, lambda: r.book("Lotus", slot(m, 1, start, end), "ravi")) == "Lotus is taken by B1"
booked = [r.book("Lotus", slot(m, 1, s, e), "ravi").id for s, e in (("11:15", "12:00"), ("09:00", "09:45"))]
assert booked == ["B2", "B3"]
B2 = FollowUp(
"B2", "Keep 15 minutes free between two bookings of a room, for cleaning.",
kind="rule", seam="Slot.overlaps, the one place that decides whether two slots clash",
edits=[
("class Refused(", "CLEANING = 15 # minutes kept free between two bookings of a room\n\n\nclass Refused("),
(""" def overlaps(self, other):
return self.start < other.end and other.start < self.end
""", """ def overlaps(self, other, gap=0):
return self.start < other.end + gap and other.start < self.end + gap
"""),
("other.slot.overlaps(slot):", "other.slot.overlaps(slot, gap=CLEANING):"),
],
tests=[check_cleaning_time_between_bookings],
replaces=["check_back_to_back_is_allowed"],
)
# ---- B3: a failure: the same request twice -----------------------------------------------------------------------------
def check_a_retry_gets_the_first_answer(m):
r = rooms(m)
first = r.book("Lotus", slot(m, 1, "10:00", "11:00"), "asha", request_id="asha-phone-41")
again = r.book("Lotus", slot(m, 1, "10:00", "11:00"), "asha", request_id="asha-phone-41") # the answer was lost
assert again == first
assert r.book("Banyan", slot(m, 1, "10:00", "11:00"), "asha", request_id="asha-phone-42").id == "B2"
def check_another_request_for_the_slot_is_refused(m):
r = rooms(m)
r.book("Lotus", slot(m, 1, "10:00", "11:00"), "asha", request_id="asha-phone-41")
assert refused(m, lambda: r.book("Lotus", slot(m, 1, "10:00", "11:00"), "ravi", request_id="ravi-7")) == "Lotus is taken by B1"
B3 = FollowUp(
"B3", "Bookings now come from a phone app. When an answer is lost, the app sends the same request again.",
kind="failure", seam="RoomBook.book, which remembers each request id and answers a repeat with the first result",
edits=[
(" self._count = 0\n", " self._count = 0\n self._answers = {} # request id -> the booking it made\n"),
(" def book(self, room, slot, who):\n", """ def book(self, room, slot, who, request_id=None):
if request_id in self._answers:
return self._answers[request_id] # a repeat: the first answer again, nothing booked twice
"""),
(" self._bookings[booking.id] = booking\n", """ self._bookings[booking.id] = booking
if request_id is not None:
self._answers[request_id] = booking
"""),
],
tests=[check_a_retry_gets_the_first_answer, check_another_request_for_the_slot_is_refused],
)
report("booking.py", BASE_TESTS, [B1, B2, B3]) Output
booking.py: 5 of 5 tests pass on the base design
B1 [rule] Cancelling less than 24 hours before the start refunds only half the price.
seam: Tariff, which already owns what a booking costs
changed: +6 -1 lines; untouched: Refused, Slot, Booking
Tariff.refund: new, 5 lines
RoomBook.cancel
- return booking.price # the refund
+ return self._tariff.refund(booking, now)
tests: old 5/5 pass on the new code; new 1/2 on the old code, 2/2 on the new
on the old code: check_late_cancellation_refunds_half: AssertionError: 23 hours 59 minutes ahead is late
B2 [rule] Keep 15 minutes free between two bookings of a room, for cleaning.
seam: Slot.overlaps, the one place that decides whether two slots clash
changed: +4 -3 lines; untouched: Refused, Booking, Tariff
top level
+ CLEANING = 15 # minutes kept free between two bookings of a room
Slot.overlaps
- def overlaps(self, other):
- return self.start < other.end and other.start < self.end
+ def overlaps(self, other, gap=0):
+ return self.start < other.end + gap and other.start < self.end + gap
RoomBook.book
- if other.room == room and other.slot.overlaps(slot):
+ if other.room == room and other.slot.overlaps(slot, gap=CLEANING):
tests: old 4/4 pass on the new code; new 0/1 on the old code, 1/1 on the new
replaced: check_back_to_back_is_allowed pinned the rule this follow-up changes
on the old code: check_cleaning_time_between_bookings: AssertionError: the request was accepted
B3 [failure] Bookings now come from a phone app. When an answer is lost, the app sends the same request again.
seam: RoomBook.book, which remembers each request id and answers a repeat with the first result
changed: +6 -1 lines; untouched: Refused, Slot, Booking, Tariff
RoomBook.__init__
+ self._answers = {} # request id -> the booking it made
RoomBook.book
- def book(self, room, slot, who):
+ def book(self, room, slot, who, request_id=None):
+ if request_id in self._answers:
+ return self._answers[request_id] # a repeat: the first answer again, nothing booked twice
...
+ if request_id is not None:
+ self._answers[request_id] = booking
tests: old 5/5 pass on the new code; new 0/2 on the old code, 2/2 on the new
on the old code: check_a_retry_gets_the_first_answer: TypeError: RoomBook.book() got an unexpected keyword argument 'request_id'
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 booking_followups.py
followups/booking.py
"""Meeting rooms booked by the half hour, as they stand after a timed round. Times are minutes on the building's
clock, counted from 00:00 on day 0; prices are in paise."""
from dataclasses import dataclass
class Refused(Exception):
"""A booking or a cancellation the rules do not allow."""
@dataclass(frozen=True)
class Slot:
start: int
end: int
def overlaps(self, other):
return self.start < other.end and other.start < self.end
@dataclass(frozen=True)
class Booking:
id: str
room: str
slot: Slot
who: str
price: int
class Tariff:
"""What a booking costs: a rate for every half hour, by room."""
def __init__(self, per_half_hour):
self.per_half_hour = per_half_hour
def price(self, room, slot):
return (slot.end - slot.start) // 30 * self.per_half_hour[room]
class RoomBook:
def __init__(self, rooms, tariff):
self._rooms = set(rooms)
self._tariff = tariff
self._bookings = {}
self._count = 0
def book(self, room, slot, who):
if room not in self._rooms:
raise Refused(f"no room {room}")
for other in self._bookings.values():
if other.room == room and other.slot.overlaps(slot):
raise Refused(f"{room} is taken by {other.id}")
self._count += 1
booking = Booking(f"B{self._count}", room, slot, who, self._tariff.price(room, slot))
self._bookings[booking.id] = booking
return booking
def cancel(self, booking_id, now):
booking = self._bookings.get(booking_id)
if booking is None:
raise Refused(f"no booking {booking_id}")
if now >= booking.slot.start:
raise Refused(f"{booking_id} has already started")
del self._bookings[booking_id]
return booking.price # the refund 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
- B1, late cancellation. Money rules already lived in
Tariff, so the refund rule went there, andcancel()asks the tariff instead of returning the price. One of the two new tests already passed on the old code: cancelling exactly 24 hours ahead refunds everything, before and after the change. That is worth saying, not hiding. It guards the rule’s boundary, while the other test, 23 hours and 59 minutes ahead, fails on the old code and so proves the change. - B2, cleaning time. Whether two slots clash is decided in one place,
Slot.overlaps, so the gap is one parameter there and one argument where the room book calls it. One old test, “back-to-back bookings are allowed”, now contradicts the new rule. That is not a regression: the follow-up replaced the rule the test pinned down. Say so, change the test, and test the new boundary: a booking from 11:15 is accepted after one that ends at 11:00, and a booking from 11:00 is not. - B3, a repeated request. Without a request id, an app that resends a booking whose answer was lost is refused
with “Lotus is taken by B1”: the customer sees an error for a booking that worked. With the id,
book()keeps the answer it gave and returns it to a repeat, so the second request books nothing. This is an idempotency key inside one program; the next section takes the idea to several servers.
followups/library_followups.py
"""Three follow-ups to the library, each applied to the base design on its own."""
from seams import FollowUp, report
BOOKS = [("p-1", "Clean Rivers"), ("p-2", "Monsoon Maths"), ("p-3", "Tiny Engines"),
("p-4", "Night Trains"), ("p-5", "Salt and Stone"), ("p-6", "The Quiet Lab")]
PEOPLE = {"asha": "Asha", "ravi": "Ravi", "meera": "Meera"}
def library(m, extra=(), copies=2):
titles = [m.Title(isbn, name, copies) for isbn, name in BOOKS] + list(extra)
return m.Library(titles, [m.Member(key, name) for key, name in PEOPLE.items()])
def refused(m, call):
try:
call()
except m.Refused as error:
return str(error)
raise AssertionError("the loan was allowed")
# ---- the tests the base design already passes ------------------------------------------------------------------------
def check_lends_a_free_copy(m):
lib = library(m)
lib.lend("asha", "p-2")
assert lib.holds("asha") == ["p-2"]
def check_no_free_copy_is_refused(m):
lib = library(m, copies=1)
lib.lend("asha", "p-1")
assert refused(m, lambda: lib.lend("ravi", "p-1")) == "no copy of Clean Rivers is free"
def check_fourth_book_is_refused(m):
lib = library(m)
for isbn in ("p-1", "p-2", "p-3"):
lib.lend("asha", isbn)
assert refused(m, lambda: lib.lend("asha", "p-4")) == "Asha already holds 3 books"
def check_giving_back_frees_the_copy(m):
lib = library(m, copies=1)
lib.lend("asha", "p-1")
lib.give_back("asha", "p-1")
lib.lend("ravi", "p-1")
assert (lib.holds("asha"), lib.holds("ravi")) == ([], ["p-1"])
BASE_TESTS = [check_lends_a_free_copy, check_no_free_copy_is_refused, check_fourth_book_is_refused,
check_giving_back_frees_the_copy]
# ---- C1: a new variant ------------------------------------------------------------------------------------------------
def check_an_ebook_lends_to_everyone(m):
lib = library(m, extra=[m.EBook("e-1", "Kite Science")])
for key in PEOPLE:
lib.lend(key, "e-1")
assert all(lib.holds(key) == ["e-1"] for key in PEOPLE)
def check_an_ebook_counts_towards_the_limit(m):
lib = library(m, extra=[m.EBook("e-1", "Kite Science")])
for isbn in ("p-1", "p-2", "p-3"):
lib.lend("asha", isbn)
assert refused(m, lambda: lib.lend("asha", "e-1")) == "Asha already holds 3 books"
C1 = FollowUp(
"C1", "Add e-books: any number of members may borrow the same e-book at once.",
kind="variant", seam="Title.can_lend, which Library.lend already asks instead of counting copies itself",
edits=[
("@dataclass\nclass Member:", '''class EBook(Title):
"""A licence the library may lend to any number of members at once."""
def __init__(self, isbn, name):
super().__init__(isbn, name, copies=0)
def can_lend(self):
return True
@dataclass
class Member:'''),
],
tests=[check_an_ebook_lends_to_everyone, check_an_ebook_counts_towards_the_limit],
)
# ---- C2: a new rule ---------------------------------------------------------------------------------------------------
def check_a_student_holds_five(m):
lib = m.Library([m.Title(isbn, name, 2) for isbn, name in BOOKS], [m.Member("sam", "Sam", kind="student")])
for isbn, _ in BOOKS[:5]:
lib.lend("sam", isbn)
assert refused(m, lambda: lib.lend("sam", "p-6")) == "Sam already holds 5 books"
C2 = FollowUp(
"C2", "Students may hold five books; everyone else still holds three.",
kind="rule", seam="the limit, a constant that becomes a table by membership type",
edits=[
("MAX_LOANS = 3\n", 'MAX_LOANS = {"adult": 3, "student": 5} # books a member may hold, by membership\n'),
(" loans: list = field(default_factory=list) # ISBNs of the books the member holds\n",
' loans: list = field(default_factory=list) # ISBNs of the books the member holds\n kind: str = "adult"\n'),
(""" if len(member.loans) >= MAX_LOANS:
raise Refused(f"{member.name} already holds {MAX_LOANS} books")
""", """ limit = MAX_LOANS[member.kind]
if len(member.loans) >= limit:
raise Refused(f"{member.name} already holds {limit} books")
"""),
],
tests=[check_a_student_holds_five],
)
# ---- C3: a rule with nowhere to live -----------------------------------------------------------------------------------
def check_overdue_loans_are_listed(m):
lib = library(m)
lib.lend("asha", "p-1", today=1) # due back on day 15
lib.lend("ravi", "p-2", today=10) # due back on day 24
assert lib.overdue(today=15) == []
assert lib.overdue(today=17) == [("Asha", "Clean Rivers", 2)]
lib.give_back("asha", "p-1")
assert lib.overdue(today=30) == [("Ravi", "Monsoon Maths", 6)]
C3 = FollowUp(
"C3", "Loans are due back after 14 days, and the desk wants the list of overdue books each morning.",
kind="rule", seam="none yet: a loan is only an ISBN in a list, so its due day has nowhere to live",
edits=[
("MAX_LOANS = 3\n", "MAX_LOANS = 3\nLOAN_DAYS = 14\n"),
("@dataclass\nclass Member:", '''@dataclass(frozen=True)
class Loan:
isbn: str
due: int # the library's day number
@dataclass
class Member:'''),
("# ISBNs of the books the member holds", "# Loan records"),
(" def lend(self, member_id, isbn):\n", " def lend(self, member_id, isbn, today=0):\n"),
(" member.loans.append(isbn)\n", " member.loans.append(Loan(isbn, today + LOAN_DAYS))\n"),
(" member.loans.remove(isbn)\n", " member.loans.remove(next(loan for loan in member.loans if loan.isbn == isbn))\n"),
(" return sorted(self._members[member_id].loans)\n", ''' return sorted(loan.isbn for loan in self._members[member_id].loans)
def overdue(self, today):
"""(member, title, days late) for every loan past its due day."""
return sorted((m.name, self._titles[loan.isbn].name, today - loan.due)
for m in self._members.values() for loan in m.loans if loan.due < today)
'''),
],
tests=[check_overdue_loans_are_listed],
)
report("library.py", BASE_TESTS, [C1, C2, C3]) Output
library.py: 4 of 4 tests pass on the base design
C1 [variant] Add e-books: any number of members may borrow the same e-book at once.
seam: Title.can_lend, which Library.lend already asks instead of counting copies itself
changed: +6 -0 lines; untouched: Refused, Title, Member, Library
EBook: new, 6 lines
tests: old 4/4 pass on the new code; new 0/2 on the old code, 2/2 on the new
on the old code: check_an_ebook_lends_to_everyone: AttributeError: module 'library_base' has no attribute 'EBook'
C2 [rule] Students may hold five books; everyone else still holds three.
seam: the limit, a constant that becomes a table by membership type
changed: +5 -3 lines; untouched: Refused, Title
top level
- MAX_LOANS = 3
+ MAX_LOANS = {"adult": 3, "student": 5} # books a member may hold, by membership
Member
+ kind: str = "adult"
Library.lend
- if len(member.loans) >= MAX_LOANS:
- raise Refused(f"{member.name} already holds {MAX_LOANS} books")
+ limit = MAX_LOANS[member.kind]
+ if len(member.loans) >= limit:
+ raise Refused(f"{member.name} already holds {limit} books")
tests: old 4/4 pass on the new code; new 0/1 on the old code, 1/1 on the new
on the old code: check_a_student_holds_five: TypeError: Member.__init__() got an unexpected keyword argument 'kind'
C3 [rule] Loans are due back after 14 days, and the desk wants the list of overdue books each morning.
seam: none yet: a loan is only an ISBN in a list, so its due day has nowhere to live
changed: +14 -5 lines; untouched: Refused, Title
top level
+ LOAN_DAYS = 14
Loan: new, 4 lines
Member
- loans: list = field(default_factory=list) # ISBNs of the books the member holds
+ loans: list = field(default_factory=list) # Loan records
Library.lend
- def lend(self, member_id, isbn):
+ def lend(self, member_id, isbn, today=0):
...
- member.loans.append(isbn)
+ member.loans.append(Loan(isbn, today + LOAN_DAYS))
Library.give_back
- member.loans.remove(isbn)
+ member.loans.remove(next(loan for loan in member.loans if loan.isbn == isbn))
Library.holds
- return sorted(self._members[member_id].loans)
+ return sorted(loan.isbn for loan in self._members[member_id].loans)
Library.overdue: new, 4 lines
tests: old 4/4 pass on the new code; new 0/1 on the old code, 1/1 on the new
on the old code: check_overdue_loans_are_listed: TypeError: Library.lend() got an unexpected keyword argument 'today'
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 library_followups.py
followups/library.py
"""A small lending library as it stands after a timed round: titles with copies, members who hold a few books."""
from dataclasses import dataclass, field
MAX_LOANS = 3
class Refused(Exception):
"""A loan the rules do not allow."""
class Title:
def __init__(self, isbn, name, copies):
self.isbn, self.name, self.copies = isbn, name, copies
self.lent = 0
def can_lend(self):
return self.lent < self.copies
def lend(self):
self.lent += 1
def give_back(self):
self.lent -= 1
@dataclass
class Member:
id: str
name: str
loans: list = field(default_factory=list) # ISBNs of the books the member holds
class Library:
def __init__(self, titles, members):
self._titles = {t.isbn: t for t in titles}
self._members = {m.id: m for m in members}
def lend(self, member_id, isbn):
member, title = self._members[member_id], self._titles[isbn]
if len(member.loans) >= MAX_LOANS:
raise Refused(f"{member.name} already holds {MAX_LOANS} books")
if not title.can_lend():
raise Refused(f"no copy of {title.name} is free")
title.lend()
member.loans.append(isbn)
def give_back(self, member_id, isbn):
member = self._members[member_id]
member.loans.remove(isbn)
self._titles[isbn].give_back()
def holds(self, member_id):
return sorted(self._members[member_id].loans) 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
- C1, e-books.
Library.lendasks the title whether it can lend, instead of counting copies itself, so an e-book is a subclass with its owncan_lend, and no existing class changed. The seam was there before the follow-up arrived, because the decision sat with the object that has the data. - C2, students. The limit was a constant, so it becomes a table by membership type,
Membergains a type that defaults to adult, and the check reads the member’s limit. Every old member is an adult by default, so every old test still passes. - C3, due dates. This one changed six places in three classes. A loan was only an ISBN in a list, so its due day
had nowhere to live, and adding one forced edits to
lend,give_backandholds. When a follow-up spreads like this, say why before you type: “A loan is not an object yet. I’ll addLoanfirst, with the old tests green, and then the due day and the overdue list each live in one place.” Naming the missing concept turns a messy diff into a design decision.
The ten results side by side, from the runs above and the three-server run in the next section:
| Follow-up and its kind: lines added and removed, and the existing classes changed | Old tests that pass on the new code; new tests that fail on the old code |
|---|---|
| A1 charging spots, a variant: +6 −3, Kind and ParkingLot | 4 of 4; 2 of 2 |
| A2 a second gate, concurrency: +9 −4, ParkingLot | 4 of 4; 1 of 1 |
| A3 a restart, persistence: +26 −1, one line of ParkingLot | 4 of 4; 1 of 1 |
| A4 three servers, scale: +15 −16, InMemoryTickets and ParkingLot | 4 of 4; 2 of 2 |
| B1 late cancellation, a rule: +6 −1, Tariff and RoomBook | 5 of 5; 1 of 2 |
| B2 cleaning time, a rule: +4 −3, Slot and RoomBook | 4 of 4, and 1 replaced; 1 of 1 |
| B3 a repeated request, failure: +6 −1, RoomBook | 5 of 5; 2 of 2 |
| C1 e-books, a variant: +6 −0, no existing class | 4 of 4; 2 of 2 |
| C2 a limit for students, a rule: +5 −3, Member and Library | 4 of 4; 1 of 1 |
| C3 due dates, a rule: +14 −5, Member and Library | 4 of 4; 1 of 1 |
No follow-up changed more than two existing classes, and six of the ten added six lines or fewer. The three largest were a new store (A3), state moved out of each server (A4) and a missing concept (C3), and each of those has a one-sentence reason you can give before writing it. All 15 new tests pass on the new code, and 14 of them fail on the old code first.
Text Diff Paste parking.py and your own version after a follow-up to see which lines and methods the change really touched.When the follow-up leaves one program
The lock of A2 orders the threads of one process. Put the gates on three servers and each server has its own lock and its own memory, so the question “is this spot free?” gets three answers. This program stands in for three servers with three lot objects that share one ticket store, as three servers share one database:
"""The fourth follow-up to the parking lot: the gates move to three servers. Three lot objects that share one store
stand in for three servers that share one database."""
from parking_followups import BASE_TESTS, CARS, lot
from seams import FollowUp, report
def servers(m, count=3):
shared = m.InMemoryTickets() # stands in for the one database all servers use
return [lot(m, tickets=shared) for _ in range(count)]
def check_three_servers_never_share_a_spot(m):
one, two, three = servers(m)
spots = [one.park("KA01", m.Kind.CAR, 0).spot, two.park("KA02", m.Kind.CAR, 0).spot]
assert spots == CARS, f"two servers gave out {spots}"
try:
three.park("KA03", m.Kind.CAR, 0)
except m.LotFull:
return
raise AssertionError("a third car got one of two spots")
def check_a_car_leaves_through_another_server(m):
one, two, three = servers(m)
ticket = one.park("KA01", m.Kind.CAR, 0)
assert two.leave(ticket.id, 45) == 4000
assert three.free_spots(m.Kind.CAR) == 2
A4 = FollowUp(
"A4", "The three gates now run on three servers that share one database.",
kind="scale", seam="the shared store alone decides who holds a spot; servers keep no map of their own",
edits=[
(""" def all(self):
return list(self._by_id.values())
""", """ def all(self):
return list(self._by_id.values())
def taken(self, spot):
return any(t.spot == spot for t in self._by_id.values())
def claim(self, ticket):
\"\"\"Saves the ticket unless its spot is taken, in one step: what a unique index on the spot does.\"\"\"
if self.taken(ticket.spot):
return False
self.save(ticket)
return True
"""),
(" self._holder = {} # spot -> id of the ticket parked there\n", ""),
(""" spot = self._free_spot(kind)
self._printer(f"ticket for {plate}: go to {spot}")
return self._occupy(spot, plate, kind, at)
""", """ for spot in self._spots.get(kind, []):
ticket = Ticket(self._tickets.new_id(), plate, kind, spot, at)
if self._tickets.claim(ticket): # refused if another server took it first
self._printer(f"ticket for {plate}: go to {spot}")
return ticket
raise LotFull(kind.value)
"""),
(" del self._holder[ticket.spot]\n", ""),
(" return sum(spot not in self._holder for spot in self._spots.get(kind, []))\n",
" return sum(not self._tickets.taken(spot) for spot in self._spots.get(kind, []))\n"),
("""
def _free_spot(self, kind):
for spot in self._spots.get(kind, []):
if spot not in self._holder:
return spot
raise LotFull(kind.value)
def _occupy(self, spot, plate, kind, at):
ticket = Ticket(self._tickets.new_id(), plate, kind, spot, at)
self._holder[spot] = ticket.id
self._tickets.save(ticket)
return ticket
""", ""),
],
tests=[check_three_servers_never_share_a_spot, check_a_car_leaves_through_another_server],
)
report("parking.py", BASE_TESTS, [A4]) Output
parking.py: 4 of 4 tests pass on the base design
A4 [scale] The three gates now run on three servers that share one database.
seam: the shared store alone decides who holds a spot; servers keep no map of their own
changed: +15 -16 lines; untouched: Kind, LotFull, Ticket, HourlyFee
InMemoryTickets.taken: new, 2 lines
InMemoryTickets.claim: new, 6 lines
ParkingLot.__init__
- self._holder = {} # spot -> id of the ticket parked there
ParkingLot.park
- spot = self._free_spot(kind)
- self._printer(f"ticket for {plate}: go to {spot}")
- return self._occupy(spot, plate, kind, at)
+ for spot in self._spots.get(kind, []):
+ ticket = Ticket(self._tickets.new_id(), plate, kind, spot, at)
+ if self._tickets.claim(ticket): # refused if another server took it first
+ self._printer(f"ticket for {plate}: go to {spot}")
+ return ticket
+ raise LotFull(kind.value)
ParkingLot.leave
- del self._holder[ticket.spot]
ParkingLot.free_spots
- return sum(spot not in self._holder for spot in self._spots.get(kind, []))
+ return sum(not self._tickets.taken(spot) for spot in self._spots.get(kind, []))
ParkingLot._free_spot: removed, 5 lines
ParkingLot._occupy: removed, 5 lines
tests: old 4/4 pass on the new code; new 0/2 on the old code, 2/2 on the new
on the old code: check_three_servers_never_share_a_spot: AssertionError: two servers gave out ['C1', 'C1']
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 parking_servers.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
On the old code the first two servers both sent their cars to C1, and a car could not leave through a server that had not parked it. The change moves the rule “one ticket per spot” into the shared store, as one step that refuses a spot already taken: the lot tries spots in order and keeps the first one the store accepts. The servers now hold no state of their own, so a load balancer may send any request to any of them.
In a real system the store is a database, and each LLD piece has an HLD counterpart. Name the counterpart when the question crosses over:
| Inside one program | Across several servers |
|---|---|
| A lock around “find a spot, take it” | An insert that a unique index refuses; PostgreSQL can limit a unique index to open tickets with a partial index |
| A dictionary of request ids (B3) | An idempotency key stored with its result in the same transaction, and kept for a stated time |
| One object that owns a rule | One store, or one partition of it, that owns the rule; other servers ask it |
| A slow call made after the lock | A message on a queue, handled by a worker, so the request does not wait for it |
The idempotency key exists because networks drop answers. HTTP calls a method idempotent when several identical requests have the same intended effect as one, and RFC 9110 says clients should not retry other methods automatically. A new booking is usually sent as a POST, which is not idempotent, so a key is what makes a repeat safe. For the depth behind each row, go on with the HLD lessons on stateless services, timeouts and retries, how distributed systems fail and the seat-booking design, which holds seats across servers and handles payment callbacks that arrive twice.
Keep the LLD answer short when the question turns into HLD: say which invariant must hold across servers, which store enforces it, and what a repeated request does. Then let the interviewer choose how deep to go.
Trade-offs in one breath
Under time pressure, a trade-off fits one sentence with four parts: the choice, the reason taken from the requirements, the price, and the condition that would change your mind.
- C2: “I kept the limit as a table by membership type, because the rule depends only on the type; the price is that a limit for one person needs a new field; if the library starts setting limits per member, I’d store the limit on the member.”
- A2: “One lock for the whole lot, because a few gates rarely park at the same instant; the price is that every park and leave takes its turn, which costs nothing at a few cars a minute; if measurements showed gates waiting, I’d lock each kind of spot separately.”
Three more habits keep follow-ups calm. If the prompt is ambiguous, ask one question first, such as “May an electric car take a car spot when the charging spots are full?”; the answer decides whether it is a variant or a rule. If the change is big, say so before coding, with the reason, as in C3. If the follow-up contradicts an earlier requirement, as B2 did, say which rule replaces which, so that the changed test reads as a decision. In a discussion round, answer in words and write code only when asked; in a machine-coding round, an extension gets five to ten minutes, so write the new test first, make the change, and run every test.
Timer Practise the three-part answer against a 30-second timer: one follow-up from the quiz below at a time, out loud.Check yourself: twelve follow-ups
For each prompt, choose where the change goes. Each explanation says why the other choices cost more.
Twelve follow-up prompts
12 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.
Interview questions
Fresher · What is a seam in a design, and how does it help when a follow-up arrives?
A seam is a place where behaviour can change without editing the code around it: an interface with a second implementation, a strategy passed in, a table of rules, or one method that owns a decision. When a follow-up arrives, the seam is where the change lands, so the rest of the design and its tests stay as they are. You find it by asking which class owns the decision the follow-up changes. A lot that hands out spots by kind has a seam for new kinds of vehicle, and a repository interface is a seam for a new store. When no seam exists, the honest answer names the missing concept, such as a loan with a due day, and adds it with a refactoring that keeps the old tests green before the new rule is written.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
Your hotel booking design stores each check-in as a UTC instant. Follow-up: "Cancellation is free until 18:00 on the day before arrival, in the hotel's own time zone." Where does the rule go, what stays untouched, and which tests prove it?
-
Mid to senior
Your parking lot saves tickets to a file after every change and prints each ticket after taking the spot. Follow-up: "The power fails after the ticket is saved but before it is printed." What happens after the restart, and what would you change?
-
Senior to staff
Your parking-lot design now serves 2,000 lots: 50,000 entries an hour across all of them, and at most 400 an hour at the busiest one. The interviewer asks what changes. What stays in your low-level design, what moves to high-level design, and what do the numbers tell you?
Key takeaways
- Sort the follow-up first: a variant, a rule, concurrency, persistence, or scale and failure; the kind says where to look.
- Answer in three parts: where the change goes, what stays untouched, and how the tests prove it.
- Prove it: old tests pass on the new code, and each new test fails on the old code before it passes.
- A rule lands with the class that owns the related decision, a new store goes behind the repository, and a concurrency fix closes the gap between checking and acting.
- When a change spreads, name the missing concept and refactor first, with the old tests green.
- When the follow-up leaves one program, move the invariant into the shared store and switch to HLD terms.
- State a trade-off as the choice, the reason, the price and the condition that would change it.
References
- Google engineering practices: what to look for in a code review (Google)
- threading: Lock objects (Python Software Foundation)
- os.replace (Python Software Foundation)
- difflib: Helpers for computing deltas (Python Software Foundation)
- ast: Abstract syntax trees (Python Software Foundation)
- RFC 9110: HTTP Semantics, section 9.2.2 Idempotent Methods (IETF)
- PostgreSQL documentation: Unique constraints (The PostgreSQL Global Development Group)
- PostgreSQL documentation: Partial indexes (The PostgreSQL Global Development Group)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress