Skip to content

Designing for the next requirement

intermediateCommon atAnthropicStripeOpenAICoinbaseRead firstClass design and invariantsAPI design and evolutionPractise in the Sandbox →

Multi-level rounds reward code where each new requirement is an addition, not a rewrite. Three habits get you there cheaply: model the nouns of the problem as records keyed by id (not as counts or parallel arrays), keep each rule in one place (one validation path, one sort key, one table of rates), and add optional parameters with defaults so earlier calls keep working. Stop there. Interfaces, plugins and factories for requirements that don’t exist yet cost time now and usually guess wrong.

Practical rounds at companies like Anthropic, Stripe and OpenAI are built in stages. You get 15 to 25 minutes per level, and you can’t see level 3 while writing level 1. Two failure modes are common:

  • Too narrow. Level 1 asks for “how many bikes are at a station”, so you store a number. Level 3 asks for “how long was this bike out”, and a number can’t answer that. You rewrite the class with 10 minutes left.
  • Too wide. You expect anything, so level 1 gets a repository, a strategy interface and an event bus. You run out of time on level 1, and the abstractions turn out to be the wrong shape anyway.

The middle path isn’t guessing the future. It’s keeping the information the problem talks about, and keeping each decision in one place. Information you throw away is expensive to get back. Code structure is cheap to change if it’s small and local.

A useful question at each level: “Which noun does this problem keep talking about, and am I storing it, or just a summary of it?”

The same bike-share class, three levels, three starting designs. Step through the levels and compare what each design has to add, change or throw away. Watch the cost bars: the summary design is cheapest at level 1 and most expensive by level 3; the framework design pays up front for things that never get used.

One class, three levels, three starting designs
interactive
L1: dock, rent, count bikes per stationL2: bikes have a type; rent asks for oneL3: charge per minute, by type

class BikeShare — entities + one indexlevel 1

fields
bikes: id → Bike{station}at: station → set[id]
methods
dock(station, bike_id)rent(station) → bike_id?available(station) → int

effort so far (level 1 + later levels)

just counts
4+0
entities + one index
5+0
framework up front
8+0
Units: each new or edited item costs 1; a replaced representation or rewritten method costs 2. Level 1 includes abstractions nobody uses yet.
Model the noun the problem talks about (a bike) as a record, keyed by id, plus one index for the main query. Slightly more than level 1 needs (ids instead of counts), but the record has room to grow.
addedsmall editreplaced / rewrittenwritten but unused
You notice… Cheap move now Why it pays later
The prompt names a thing with an id (bike, order, file, user) Store a record per id in a map New attributes are new fields, not new structures.
You’re about to store a count or a total Ask whether you’ll need which items Summaries can be derived from records; records can’t be rebuilt from summaries.
A rule appears in two methods (“only active ones”, “sorted by X then Y”) Pull it into one helper or key function The next level changes the rule once.
Policy numbers in if chains (rates, limits) A table: type → rule A new type is a row, not a branch.
A method gains a parameter Default it to the old behaviour Earlier levels’ tests keep passing untouched.
“Every method takes ts” Thread ts through even where unused Time-based levels (expiry, history) slot in.

A worked example: bike share in three levels

Section titled “A worked example: bike share in three levels”
  1. Level 1: dock(station, bike_id), rent(station), available(station). Store bikes by id with their station, plus one index station → set of ids for the query. Slightly more than the minimum, because bikes are the noun.
  2. Level 2: bikes have a type, and rent asks for one. Add a type field to the record and a filter in rent. available gets an optional type parameter defaulting to “any”. The index doesn’t change.
  3. Level 3: charge per minute, by type. Add out_since to the record, set it in rent, use it in dock. Rates go in a table passed to the constructor.

The final class, with comments marking what each level added:

bike_share.py
from dataclasses import dataclass
@dataclass
class Bike:
station: str | None # None while rented
type: str = "regular" # L2: one new field
out_since: int | None = None # L3: one new field
class BikeShare:
def __init__(self, rates: dict[str, int]) -> None:
self.bikes: dict[str, Bike] = {} # L1: the noun of the problem, by id
self.at: dict[str, set[str]] = {} # L1: index for "bikes at station"
self.rates = rates # L3: cents per minute, by type (data, not if-chains)
def dock(self, station: str, bike_id: str, type: str = "regular", ts: int = 0) -> int:
bike = self.bikes.setdefault(bike_id, Bike(None, type))
fee = 0
if bike.out_since is not None: # L3: the record already knows when it left
fee = (ts - bike.out_since) // 60 * self.rates[bike.type]
bike.station, bike.out_since = station, None
self.at.setdefault(station, set()).add(bike_id)
return fee
def rent(self, station: str, type: str = "regular", ts: int = 0) -> str | None:
for bid in sorted(self.at.get(station, ())): # sorted: deterministic choice
if self.bikes[bid].type == type: # L2: a filter, not a new structure
self.at[station].discard(bid)
b = self.bikes[bid]
b.station, b.out_since = None, ts
return bid
return None
def available(self, station: str, type: str | None = None) -> int: # L2: optional filter
ids = self.at.get(station, set())
return sum(1 for b in ids if type is None or self.bikes[b].type == type)
if __name__ == "__main__":
s = BikeShare(rates={"regular": 5, "electric": 15})
s.dock("pier", "b1")
s.dock("pier", "e1", "electric")
assert s.available("pier") == 2 and s.available("pier", "electric") == 1
assert s.rent("pier", "electric", ts=1_000) == "e1" and s.rent("pier", "electric") is None
assert s.dock("park", "e1", ts=1_000 + 10 * 60) == 150 # 10 minutes x 15 cents
assert s.available("park") == 1
print("ok")

When rules differ by category, a table beats a chain of if statements: adding a category is adding a row, and the logic is written once.

fee.py
from typing import NamedTuple
class Rule(NamedTuple):
free_minutes: int
cents_per_minute: int
daily_cap: int
# Policy as data: a new bike type (or a promo) is a new row, not a new branch.
RULES: dict[str, Rule] = {
"regular": Rule(free_minutes=30, cents_per_minute=5, daily_cap=1_500),
"electric": Rule(free_minutes=0, cents_per_minute=15, daily_cap=4_000),
}
def fee(bike_type: str, minutes: int) -> int:
rule = RULES.get(bike_type)
if rule is None: # unknown input is an error, not a silent zero
raise ValueError(f"unknown bike type {bike_type!r}")
billable = max(0, minutes - rule.free_minutes)
return min(billable * rule.cents_per_minute, rule.daily_cap)
if __name__ == "__main__":
assert fee("regular", 20) == 0 and fee("regular", 40) == 50
assert fee("electric", 10) == 150 and fee("electric", 600) == 4_000 # capped
RULES["cargo"] = Rule(free_minutes=15, cents_per_minute=10, daily_cap=3_000) # next level
assert fee("cargo", 25) == 100
print("ok")
Approach at level 1 Time at level 1 Cost of a typical later level Risk
Minimum to pass (counts, flags, parallel lists) lowest high when the level needs information you threw away a rewrite under time pressure
Records by id + one index + rules in one place a few extra minutes small: new fields, new filters, new rows low
Speculative framework (interfaces, plugins, factories) highest medium: the guessed abstractions rarely fit running out of time on level 1

The principle has a name: YAGNI (“you aren’t gonna need it”), from Extreme Programming. It is aimed at features and abstractions you don’t need yet. It is not an argument for discarding information the problem clearly revolves around. Keeping the bike record isn’t speculation; the problem is about bikes.

  • Time arrives in a later level. Expiry, scheduling, history. If every method already takes ts, store it on records as you go (created, updated). See TTL and expiry and versioning.
  • Queries arrive in a later level. “Top k by X”, “all with prefix P”. Add an index then, not before. Keep writes going through one method so there’s one place to update it.
  • Undo, backup or history arrives. Much easier if all mutations already go through a few methods. See undo / redo and snapshots.
  • Statuses arrive. Replace booleans with one enum field and a transition table. See state machines.
  • Multiple owners or tenants arrive. Records gain an owner field; queries gain an owner filter; quotas become per-owner counters.
  • Storing only summaries. Counts and totals are great caches and bad sources of truth.
  • Copy-pasted rules. The same filter in three methods; the next level changes it in two.
  • Breaking earlier levels. Changing a return type or making a new parameter required. Earlier tests are your safety net: rerun them at each level.
  • Premature abstraction. An interface with one implementation, a factory that builds one class, a config system for one constant.
  • Clever generality. A generic “rule engine” where a dictionary of three entries would do. Interviewers read code; simple wins.
  • Not asking. “Will bikes ever need to be tracked individually?” is a fair question. The answer is often a useful hint.
  • At level 1, say what you’re choosing and why: “I’ll store bikes by id rather than counts per station. It costs nothing now and keeps the per-bike information if we need it.”
  • Ask about likely directions without asking for the future spec: “Is it reasonable to assume bikes are individually identifiable?”
  • When a level lands, take 30 seconds to say what changes: “New field on the record, a filter in rent, an optional parameter on available. Nothing else moves.” Then do exactly that.
  • If a rewrite is needed, say so and do a small refactor first, with tests green, before adding the feature.
  • Expect the debrief question: “What would you change if you started again?” Have an honest answer.
Check yourself
pick one
Level 1 only asks how many items each warehouse holds. Which representation best balances level-1 speed with later levels?
Check yourself
pick one
A new level adds a parameter to an existing method. What keeps every earlier test passing without edits?
Check yourself
pick one
Discounts differ by customer tier, and the interviewer hints more tiers are coming. What's the simplest extensible design?

These are classic exercises, not gym questions. Write level 1, then reveal level 2 to yourself only after level 1 passes:

  • A library loan desk: L1 lend and return; L2 due dates and overdue lists; L3 fines by item type; L4 reservations queue.
  • A parking garage: L1 park and leave; L2 spot sizes; L3 hourly pricing by size; L4 monthly passes.
  • A vending machine: L1 sell items; L2 coins and change; L3 restock and inventory report; L4 promotions.
  • A to-do app backend: L1 add and complete; L2 tags and filters; L3 due dates and reminders; L4 undo.
  • Martin Fowler, “Yagni”: what it does and does not tell you to skip.
  • Martin Fowler, Refactoring, 2nd ed. (2018): small, safe steps; “preparatory refactoring”.
  • Sandi Metz, “The Wrong Abstraction” (2016): why duplication is cheaper than a bad abstraction.
  • John Ousterhout, A Philosophy of Software Design (2018): deep modules, information hiding, and designing for change.