Designing for the next requirement
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.
The idea in plain words
Section titled “The idea in plain words”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?”
See it
Section titled “See 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.
class BikeShare — entities + one indexlevel 1
effort so far (level 1 + later levels)
When to reach for it
Section titled “When to reach for it”| 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. |
How it works
Section titled “How it works”A worked example: bike share in three levels
Section titled “A worked example: bike share in three levels”- Level 1:
dock(station, bike_id),rent(station),available(station). Store bikes by id with their station, plus one indexstation → set of idsfor the query. Slightly more than the minimum, because bikes are the noun. - Level 2: bikes have a type, and
rentasks for one. Add atypefield to the record and a filter inrent.availablegets an optionaltypeparameter defaulting to “any”. The index doesn’t change. - Level 3: charge per minute, by type. Add
out_sinceto the record, set it inrent, use it indock. Rates go in a table passed to the constructor.
The final class, with comments marking what each level added:
from dataclasses import dataclass
@dataclassclass 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")import assert from "node:assert/strict";
interface Bike { station: string | null; // null while rented type: string; // L2: one new field outSince: number | null; // L3: one new field}
class BikeShare { readonly bikes = new Map<string, Bike>(); // L1: the noun of the problem, by id readonly at = new Map<string, Set<string>>(); // L1: index for "bikes at station" constructor(private readonly rates: Record<string, number>) {} // L3: data, not if-chains
dock(station: string, bikeId: string, type = "regular", ts = 0): number { let bike = this.bikes.get(bikeId); if (!bike) this.bikes.set(bikeId, (bike = { station: null, type, outSince: null })); let fee = 0; if (bike.outSince !== null) { // L3: the record already knows when it left fee = Math.floor((ts - bike.outSince) / 60) * this.rates[bike.type]; } bike.station = station; bike.outSince = null; if (!this.at.has(station)) this.at.set(station, new Set()); this.at.get(station)!.add(bikeId); return fee; }
rent(station: string, type = "regular", ts = 0): string | null { for (const id of [...(this.at.get(station) ?? [])].sort()) { // sorted: deterministic choice const b = this.bikes.get(id)!; if (b.type !== type) continue; // L2: a filter, not a new structure this.at.get(station)!.delete(id); b.station = null; b.outSince = ts; return id; } return null; }
available(station: string, type?: string): number { // L2: optional filter return [...(this.at.get(station) ?? [])].filter((id) => type === undefined || this.bikes.get(id)!.type === type).length; }}
const s = new BikeShare({ regular: 5, electric: 15 });s.dock("pier", "b1");s.dock("pier", "e1", "electric");assert.equal(s.available("pier"), 2);assert.equal(s.available("pier", "electric"), 1);assert.equal(s.rent("pier", "electric", 1_000), "e1");assert.equal(s.rent("pier", "electric"), null);assert.equal(s.dock("park", "e1", "electric", 1_000 + 10 * 60), 150); // 10 minutes x 15 centsconsole.log("ok");var s = new BikeShare(new() { ["regular"] = 5, ["electric"] = 15 });s.Dock("pier", "b1");s.Dock("pier", "e1", "electric");if (s.Available("pier") != 2 || s.Available("pier", "electric") != 1) throw new Exception("available");if (s.Rent("pier", "electric", ts: 1_000) != "e1" || s.Rent("pier", "electric") != null) throw new Exception("rent");if (s.Dock("park", "e1", ts: 1_000 + 10 * 60) != 150) throw new Exception("fee: 10 min x 15 cents");Console.WriteLine("ok");
class Bike(string type){ public string? Station; // null while rented public string Type { get; } = type; // L2: one new field public long? OutSince; // L3: one new field}
class BikeShare(Dictionary<string, int> rates) // L3: cents per minute by type: data, not if-chains{ readonly Dictionary<string, Bike> bikes = []; // L1: the noun of the problem, by id readonly Dictionary<string, SortedSet<string>> at = []; // L1: index for "bikes at station"
public long Dock(string station, string bikeId, string type = "regular", long ts = 0) { if (!bikes.TryGetValue(bikeId, out var bike)) bikes[bikeId] = bike = new Bike(type); long fee = bike.OutSince is long since ? (ts - since) / 60 * rates[bike.Type] : 0; // L3 (bike.Station, bike.OutSince) = (station, null); if (!at.TryGetValue(station, out var ids)) at[station] = ids = new(StringComparer.Ordinal); ids.Add(bikeId); return fee; }
public string? Rent(string station, string type = "regular", long ts = 0) { if (!at.TryGetValue(station, out var ids)) return null; var id = ids.FirstOrDefault(i => bikes[i].Type == type); // L2: a filter, not a new structure if (id is null) return null; ids.Remove(id); (bikes[id].Station, bikes[id].OutSince) = (null, ts); return id; }
public int Available(string station, string? type = null) => // L2: optional filter at.TryGetValue(station, out var ids) ? ids.Count(i => type is null || bikes[i].Type == type) : 0;}Policy as data
Section titled “Policy as data”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.
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")import assert from "node:assert/strict";
interface Rule { freeMinutes: number; centsPerMinute: number; dailyCap: number }
// Policy as data: a new bike type (or a promo) is a new row, not a new branch.const RULES = new Map<string, Rule>([ ["regular", { freeMinutes: 30, centsPerMinute: 5, dailyCap: 1_500 }], ["electric", { freeMinutes: 0, centsPerMinute: 15, dailyCap: 4_000 }],]);
function fee(bikeType: string, minutes: number): number { const rule = RULES.get(bikeType); if (!rule) throw new RangeError(`unknown bike type ${bikeType}`); // not a silent zero const billable = Math.max(0, minutes - rule.freeMinutes); return Math.min(billable * rule.centsPerMinute, rule.dailyCap);}
assert.equal(fee("regular", 20), 0);assert.equal(fee("regular", 40), 50);assert.equal(fee("electric", 10), 150);assert.equal(fee("electric", 600), 4_000); // cappedRULES.set("cargo", { freeMinutes: 15, centsPerMinute: 10, dailyCap: 3_000 }); // next levelassert.equal(fee("cargo", 25), 100);console.log("ok");// Policy as data: a new bike type (or a promo) is a new row, not a new branch.var rules = new Dictionary<string, Rule>{ ["regular"] = new(FreeMinutes: 30, CentsPerMinute: 5, DailyCap: 1_500), ["electric"] = new(FreeMinutes: 0, CentsPerMinute: 15, DailyCap: 4_000),};
int Fee(string bikeType, int minutes){ if (!rules.TryGetValue(bikeType, out var rule)) // unknown input is an error, not a silent zero throw new ArgumentException($"unknown bike type {bikeType}"); int billable = Math.Max(0, minutes - rule.FreeMinutes); return Math.Min(billable * rule.CentsPerMinute, rule.DailyCap);}
if (Fee("regular", 20) != 0 || Fee("regular", 40) != 50) throw new Exception("regular");if (Fee("electric", 10) != 150 || Fee("electric", 600) != 4_000) throw new Exception("electric, capped");rules["cargo"] = new(FreeMinutes: 15, CentsPerMinute: 10, DailyCap: 3_000); // next levelif (Fee("cargo", 25) != 100) throw new Exception("cargo");Console.WriteLine("ok");
record Rule(int FreeMinutes, int CentsPerMinute, int DailyCap);What it costs
Section titled “What it costs”| 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.
Variations you’ll meet
Section titled “Variations you’ll meet”- 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.
Pitfalls
Section titled “Pitfalls”- 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.
In the interview
Section titled “In the interview”- 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
Section titled “Check yourself”Practice on your own
Section titled “Practice on your own”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.
Go deeper
Section titled “Go deeper”- 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.