Your country

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

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

Low-Level Design (Object-Oriented Design)  Module 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.

  • Intermediate
  • 25 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7 and Node.js 24.21.0
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 3 interview questions.

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:

Four nested boxes: one class inside the objects in memory, inside one running program, inside several servers that share data.Several servers, shared datascale, failure: the sharedstore owns the rule; eachrequest carries an idOne running programpersistence: a second storebehind the repositoryObjects in memoryconcurrency: the rule'sowner checks and actsin one stepOne classvariant, rule: a newvalue, a subclass orone changed method

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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:

  1. Where the change goes. Name the class and the method, and what you add or change there.
  2. 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.
  3. 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.

A parking lot: charging spots, a second gate and a restart

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]}")
  • 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.
A room booker: a cancellation fee, cleaning time and a repeated request

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
  • B1, late cancellation. Money rules already lived in Tariff, so the refund rule went there, and cancel() 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.
A library: e-books, a limit for students and due dates

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)
  • C1, e-books. Library.lend asks the title whether it can lend, instead of counting copies itself, so an e-book is a subclass with its own can_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, Member gains 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_back and holds. When a follow-up spreads like this, say why before you type: “A loan is not an object yet. I’ll add Loan first, 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:

Three servers, one store: who decides that a spot is taken Python · followups/parking_servers.py
"""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

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.

  1. Question 1 of 12 A coffee machine keeps its drinks in a table of Recipe values (water, milk, powder, price) and brew() works from any recipe. Follow-up: "Add masala chai." Where does the change go?

    Choose one answer.

    Show the answer to question 1

    Answer: One new row in the recipe table; no class changes

    The model already varies by recipe, and the recipe is data, so a new drink is a new value. brew() and every existing test stay as they are; the new test brews chai and checks the stock it used. A branch or a subclass would add code for something the table already expresses.

  2. Question 2 of 12 A cinema service has hold(show, seats, customer), which marks seats as held for ten minutes. Follow-up: "One customer may hold at most 10 seats at once." Where does the rule belong?

    Choose one answer.

    Show the answer to question 2

    Answer: In hold(), which already owns the decision to hold seats, as one guard tested at 10 and 11 seats

    A rule lands with the decision it limits. hold() is where seats become held, so a check there refuses the 11th seat before anything changes. A seat knows nothing about other seats, the payment step comes too late, and a check in the app alone is bypassed by any other client.

  3. Question 3 of 12 A wallet's transfer() reads the balance, sends an SMS saying the transfer is on its way, then debits. Follow-up: "Two transfers from the same wallet can arrive at the same moment." What is the smallest correct change?

    Choose one answer.

    Show the answer to question 3

    Answer: Check the balance and debit in one step under the wallet's lock, and send the SMS after the lock is released

    The bug is the gap between "check" and "act", and the slow SMS call sits inside it. Making the check and the debit one step closes the gap; moving the SMS out keeps the lock short. A second check narrows the gap without closing it, and a retry repairs damage after it is done.

  4. Question 4 of 12 A task board keeps tasks in an InMemoryTasks repository that the TaskBoard service calls through four methods. Follow-up: "Tasks must survive a restart." Where does the change go?

    Choose one answer.

    Show the answer to question 4

    Answer: A second repository, such as FileTasks, with the same four methods; the service gets it at start

    The repository is the seam that already exists for storage, so a new store is a new class behind it and the rules do not change. Calls added to every service method spread the change; saving only at exit loses everything on a crash; tasks that write files mix storage into the domain.

  5. Question 5 of 12 A seat-booking service uses a threading lock around hold(). It now runs on three servers behind a load balancer, sharing one database. Follow-up: "Can two servers hold the same seat?" What do you answer?

    Choose one answer.

    Show the answer to question 5

    Answer: Yes, each lock lives in one server; the shared database must refuse the second hold, with a unique index on the held seat

    A lock only orders the threads of the process that holds it. Three servers have three locks and three views of memory, so only the store they share can enforce "one hold per seat", in one atomic step such as an insert that a unique index refuses. A cache makes each server's view staler, not safer.

  6. Question 6 of 12 A payment provider calls your confirm(order_id, payment_id) callback, and sometimes calls it twice for the same payment. Follow-up: "The order's points were added twice." What is the fix?

    Choose one answer.

    Show the answer to question 6

    Answer: Record each payment_id when it is applied; a repeated call returns the first result and changes nothing

    The handler must be idempotent: the same payment applied twice has the effect of one. Keeping the payment id with the result, in the same store and the same step, lets a repeat return the first answer. A time window drops late repeats only sometimes, a refund punishes the customer, and a lock still applies both calls in turn.

  7. Question 7 of 12 An expense-sharing app splits every expense equally inside add_expense(). Follow-up: "Let people split an expense by percentages." What do you say first?

    Choose one answer.

    Show the answer to question 7

    Answer: The split rule has no home yet; move the equal split into a Split class with the old tests green, then add a percentage split beside it

    When no seam exists, say so and name the missing concept. A refactoring that changes no behaviour comes first, proved by the old tests, and then the new rule is one new class. A copied method doubles every later change, a required parameter breaks every caller, and a rewrite is a new design rather than a follow-up.

  8. Question 8 of 12 A library has lend(), give_back() and holds(member). Follow-up: "The desk wants the members who hold more than two books." Where does the change go?

    Choose one answer.

    Show the answer to question 8

    Answer: One new read-only method on Library that walks the members it already keeps

    A new question about data the model already holds is the cheapest follow-up: a query method, and a test for it, with nothing else touched. A second copy of the data that lend() and give_back() must keep in step is a place for bugs; build an index like that only when a measured need appears.

  9. Question 9 of 12 A parking lot gives each vehicle kind its own list of spots. Follow-up: "Trucks take two adjacent car spots." Is this a one-line variant?

    Choose one answer.

    Show the answer to question 9

    Answer: No; the allocation rule and the ticket change, because one vehicle now holds two spots

    A variant is one line only when the existing rules already cover it. Here the rule "one vehicle, one spot" is what changes: finding two adjacent free spots and holding both on one ticket. Say that before you code, and the interviewer sees that you read the change, not just its name.

  10. Question 10 of 12 Gate threads park cars under the lot's lock, and the ticket printer is called inside the locked block. Follow-up: "The printer sometimes takes two seconds, and the other gates freeze." What do you change?

    Choose one answer.

    Show the answer to question 10

    Answer: Take the spot under the lock, then print after releasing it

    A lock should cover only the check and the change of shared state. Slow input and output inside it makes every other gate wait. Printing before the spot is taken reopens the race the lock was added to prevent.

  11. Question 11 of 12 Tickets are saved to tickets.json after every change. Follow-up: "After a power cut the file was half written and the program would not start." What is the fix?

    Choose one answer.

    Show the answer to question 11

    Answer: Write to a temporary file, then replace the old file with it in one step (os.replace)

    A rename that replaces the old file is atomic on POSIX systems, so after a crash the file is either the old version or the new one, never half of each. Writing twice can be interrupted twice, and saving at shutdown loses every ticket since the last start.

  12. Question 12 of 12 Your parking lot design works. Follow-up: "Now run it for 500 sites, one database per region." Which belong in a good answer? Choose every one that does.

    Choose every answer that is right.

    Show the answer to question 12

    Answer:

    • Name what must stay atomic, such as one ticket per spot, and which store enforces it
    • The model of one lot stays as it is; the change is where its data lives
    • Split the data by site, so each site's spots live in one place

    Scale questions leave one program, so answer in the terms of high-level design: partition by site, keep each invariant inside the store that owns it, and keep the lot's rules untouched. A singleton is one object in one process, and threads add concurrency without adding a shared owner for the data.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

  3. 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?

    Model answer, Pro. First step and follow-ups

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

Related tools

Report a problem with this lesson

Quick answers and tool search

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