API design and evolution
An API is a promise to callers you can’t see. Make each method’s outcomes explicit: a value, an empty result, “not found” (None / null), or an exception for misuse and real failures. Pick one rule and apply it everywhere. To evolve an API without breaking callers, only add: new optional parameters at the end (or in an options object), new fields, new methods. Renames, removals, reordering and new required inputs are breaking changes and need a new major version.
The idea in plain words
Section titled “The idea in plain words”A wall socket is an API. You can’t see the wiring, and you don’t need to: the shape of the plug tells you what fits, and the voltage is a promise. If the power company changes the socket shape, every appliance in the country breaks.
Method signatures are the same. A caller relies on:
- the name and parameters (their order, names, types, which are required),
- the return type and what each value means (
[]vsNonevs an exception), - the behaviour: side effects, ordering of results, idempotency.
Good design makes the right call easy and the wrong call hard. Good evolution means callers written against version 1 keep working on version 2. Stripe’s API famously pins each account to the API version it signed up with and translates between versions, so old integrations never break. You don’t need that machinery in an interview, but you need its instincts.
See it
Section titled “See it”Here’s a v1 search function and five callers already in production. Toggle proposed changes and see who breaks, who keeps working, and who silently gets wrong answers. Hover a change for the reason and a safer alternative. The version badge shows the semantic-versioning bump the change set would require.
proposed changes
existing callers2.0.0: MAJOR, breaking
positional args
keyword args
reads a result field
relies on [] for no hits
binary, not recompiled; broken by: add optional param at the end
search(query, limit, offset=0). Source-compatible everywhere. But C# bakes default values into the caller at compile time, so an old binary looks for the 2-parameter method and fails with MissingMethodException. Safer: In C#, add an overload instead: keep Search(q, limit) and add Search(q, limit, offset).When to reach for it
Section titled “When to reach for it”| The problem says… | The design question |
|---|---|
“return None if the key doesn’t exist” |
“Not found” is a normal outcome: a value, not an exception. |
“return False (and change nothing) if…” |
Validation failures as a boolean. Check before mutating. |
| “errors are replies too: never throw” | A string/enum error channel. Every path returns. |
| “raise / throw on invalid input” | Misuse is an exception. Say which type. |
“add an optional ttl parameter” (later level) |
Default it to the old behaviour. Old calls must be unchanged. |
“return a list of \"id:score\" strings, sorted by…” |
The output format and order are part of the contract. |
How it works
Section titled “How it works”Choosing how to report outcomes
Section titled “Choosing how to report outcomes”- Separate three kinds of outcome. Success. An expected miss (key absent, nothing matched, request already processed). Misuse or failure (malformed input, broken invariant, I/O error).
- Expected misses are return values:
None/null/undefinedfor “no such item”, an empty collection for “no matches”,Falsefor “didn’t apply”. Collections should never beNone: empty is a valid answer every caller already handles. - Misuse is an exception, raised at the boundary with a message that says what was wrong. It shouldn’t be caught and ignored in normal flow.
- If a caller often expects failure, offer both:
parse()that throws andtry_parse()that returnsNone/ a result object. One implementation, two signalling styles. - Be consistent across the class. If
deletereturnsbool,cancelshould too.
UNITS = {"B": 1, "KB": 1024, "MB": 1024**2, "GB": 1024**3}
def parse_size(text: str) -> int: """'10KB' -> 10240. Raises ValueError on malformed input: that's caller misuse.""" t = text.strip().upper() unit = next((u for u in sorted(UNITS, key=len, reverse=True) if t.endswith(u)), None) digits = t[: len(t) - len(unit)] if unit else "" if unit is None or not digits.isdigit(): # 1. validate at the boundary, fail loudly raise ValueError(f"not a size: {text!r}") return int(digits) * UNITS[unit]
def try_parse_size(text: str) -> int | None: """Same rules, but 'not a size' is an expected outcome here, so return None.""" try: return parse_size(text) # 2. one implementation; the variants differ only in signalling except ValueError: return None
if __name__ == "__main__": assert parse_size("10KB") == 10240 and parse_size("3mb") == 3 * 1024**2 assert try_parse_size("12XB") is None and try_parse_size("KB") is None try: parse_size("ten KB") # 3. misuse surfaces at the call site, not three calls later raise AssertionError("expected ValueError") except ValueError as e: assert "ten KB" in str(e) # 4. the message says what was wrong print("ok")import assert from "node:assert/strict";
const UNITS: Record<string, number> = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3 };
// A discriminated union makes "it can fail" part of the type: callers must check `ok`.type Result<T> = { ok: true; value: T } | { ok: false; error: string };
function tryParseSize(text: string): Result<number> { const m = /^(\d+)(B|KB|MB|GB)$/.exec(text.trim().toUpperCase()); if (!m) return { ok: false, error: `not a size: ${JSON.stringify(text)}` }; // 1. expected outcome return { ok: true, value: Number(m[1]) * UNITS[m[2]] };}
/** Throwing variant for callers who consider bad input a bug. */function parseSize(text: string): number { const r = tryParseSize(text); // 2. one implementation; the variants differ only in signalling if (!r.ok) throw new RangeError(r.error); // 3. misuse fails loudly at the call site return r.value;}
assert.equal(parseSize("10KB"), 10240);const r = tryParseSize("12XB");assert.equal(r.ok, false);if (!r.ok) assert.match(r.error, /12XB/); // 4. the message says what was wrongassert.throws(() => parseSize("ten KB"), RangeError);console.log("ok");// The .NET idiom for "failure is expected": bool TryX(input, out result), like int.TryParse.static bool TryParseSize(string text, out long bytes){ bytes = 0; var t = text.Trim().ToUpperInvariant(); (string Unit, long Mult)[] units = [("GB", 1L << 30), ("MB", 1L << 20), ("KB", 1L << 10), ("B", 1)]; foreach (var (unit, mult) in units) { if (!t.EndsWith(unit)) continue; if (!long.TryParse(t[..^unit.Length], out var n) || n < 0) return false; // 1. expected outcome bytes = n * mult; return true; } return false;}
// Throwing variant for callers who consider bad input a bug.static long ParseSize(string text) => TryParseSize(text, out var b) ? b // 2. one implementation; variants differ only in signalling : throw new FormatException($"not a size: '{text}'"); // 3. misuse fails loudly, 4. says what
if (ParseSize("10KB") != 10240) throw new Exception("10KB");if (TryParseSize("12XB", out _)) throw new Exception("12XB should fail");try { ParseSize("ten KB"); throw new Exception("expected FormatException"); }catch (FormatException e) when (e.Message.Contains("ten KB")) { }Console.WriteLine("ok");| Python | TypeScript | C# | |
|---|---|---|---|
| “absent” | None with X | None hint |
undefined / null (enable strictNullChecks) |
null with nullable reference types (string?) |
| expected failure | None, or a small result type |
discriminated union { ok: true, value } | { ok: false, error } |
bool TryX(in, out T result) |
| misuse | ValueError, KeyError, TypeError |
throw new RangeError / TypeError |
ArgumentException, InvalidOperationException, FormatException |
Changing an API without breaking callers
Section titled “Changing an API without breaking callers”- Add, don’t change. New optional parameters go at the end with a default equal to the old behaviour. Better still: keyword-only (
*,in Python) or an options object (TS), so position never matters. - New outputs are new fields, not changed meanings of old ones. Old readers ignore fields they don’t know.
- Need a different meaning? Add a new method (
search_v2,searchPaged) and deprecate the old one; don’t repurpose it. - Removing or renaming is a major version. Deprecate first, keep both for a while, then remove.
- Version at the boundary when you can’t avoid breaking: a version in the URL (
/v2/), a header (Stripe-Version: 2024-06-20), or a parameter. Translate old requests to the new internal model in one layer.
from dataclasses import dataclass
@dataclass(frozen=True)class Hit: title: str url: str score: float = 0.0 # 3. added in v2 with a default: old constructors still work
DOCS = [Hit("Cats", "/cats"), Hit("Cat food", "/food"), Hit("Dogs", "/dogs")]
# v1 was: def search(query: str, limit: int) -> list[Hit]def search(query: str, limit: int, *, offset: int = 0, case_sensitive: bool = False) -> list[Hit]: """v2. New parameters are keyword-only (after `*`) and default to v1 behaviour.""" q = query if case_sensitive else query.lower() # 1. defaults reproduce v1 exactly found = [d for d in DOCS if q in (d.title if case_sensitive else d.title.lower())] return found[offset : offset + limit] # 2. still a list; [] when nothing matches
if __name__ == "__main__": # v1 call sites, unchanged, still behave the same: assert [h.title for h in search("cat", 10)] == ["Cats", "Cat food"] assert [h.title for h in search(query="cat", limit=1)] == ["Cats"] assert search("zebra", 5) == [] # v2 features are opt-in, by name only: assert [h.title for h in search("cat", 10, offset=1)] == ["Cat food"] assert search("cat", 10, case_sensitive=True) == [] print("ok")import assert from "node:assert/strict";
interface Hit { title: string; url: string; score?: number } // 3. new optional field in v2
const DOCS: Hit[] = [{ title: "Cats", url: "/cats" }, { title: "Cat food", url: "/food" }, { title: "Dogs", url: "/dogs" }];
// v2 adds options without touching v1's positional parameters.interface SearchOptions { offset?: number; caseSensitive?: boolean }
// v1 was: search(query: string, limit: number): Hit[]function search(query: string, limit: number, { offset = 0, caseSensitive = false }: SearchOptions = {}): Hit[] { const norm = (s: string) => (caseSensitive ? s : s.toLowerCase()); // 1. defaults reproduce v1 const found = DOCS.filter((d) => norm(d.title).includes(norm(query))); return found.slice(offset, offset + limit); // 2. still an array; [] when nothing matches}
// v1 call sites, unchanged, still behave the same:assert.deepEqual(search("cat", 10).map((h) => h.title), ["Cats", "Cat food"]);assert.deepEqual(search("zebra", 5), []);// v2 features are opt-in, by name, in one options object that can keep growing:assert.deepEqual(search("cat", 10, { offset: 1 }).map((h) => h.title), ["Cat food"]);assert.deepEqual(search("cat", 10, { caseSensitive: true }), []);console.log("ok");var lib = new Catalog();// v1 call sites, unchanged, still behave the same:if (!lib.Search("cat", 10).Select(h => h.Title).SequenceEqual(["Cats", "Cat food"])) throw new Exception("v1");if (lib.Search("zebra", 5).Count != 0) throw new Exception("empty");// v2 features are opt-in, by name:if (!lib.Search("cat", 10, new SearchOptions { Offset = 1 }).Select(h => h.Title).SequenceEqual(["Cat food"])) throw new Exception("offset");if (lib.Search("cat", 10, new SearchOptions { CaseSensitive = true }).Count != 0) throw new Exception("case");Console.WriteLine("ok");
record Hit(string Title, string Url, double Score = 0); // 3. new member with a default
record SearchOptions { public int Offset { get; init; } public bool CaseSensitive { get; init; } }
class Catalog{ readonly List<Hit> docs = [new("Cats", "/cats"), new("Cat food", "/food"), new("Dogs", "/dogs")];
// v1 signature kept as its own overload. Adding an optional parameter to it instead // would break already-compiled callers: C# bakes default values in at the call site. public List<Hit> Search(string query, int limit) => Search(query, limit, new SearchOptions());
public List<Hit> Search(string query, int limit, SearchOptions o) // 1. defaults reproduce v1 { var cmp = o.CaseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; return docs.Where(d => d.Title.Contains(query, cmp)).Skip(o.Offset).Take(limit).ToList(); // 2. [] if none }}What it costs
Section titled “What it costs”| Change | Source-compatible? | Binary-compatible (C#)? | Semantic version |
|---|---|---|---|
| Add a method | yes | yes | minor |
| Add an optional parameter at the end | yes | no (use an overload) | minor (source) |
| Add a field to a returned record / object | yes (unless callers unpack positionally) | yes | minor |
Widen an input type (int → int | None) |
yes | depends | minor |
| Rename or reorder parameters | no (keyword / positional callers) | no | major |
| Add a required parameter | no | no | major |
Change [] to None, or a value to an exception |
compiles, fails at runtime | yes, fails at runtime | major |
| Fix a bug that callers relied on | — | — | “it depends”, see Hyrum’s law |
Hyrum’s law: with enough users, every observable behaviour of your API will be depended on by somebody, including the order of results you never promised. Document what’s guaranteed (sorted? stable?) and test exactly that.
Variations you’ll meet
Section titled “Variations you’ll meet”- Command-string APIs. One
execute("SET k v") → strentry point. The contract is the grammar and the reply formats; errors become reply strings. Parse into a structured command first, then dispatch. - Idempotent writes. A client retries; the API must not apply the effect twice. An idempotency key is part of the signature. See idempotency.
- Builder / options objects. When a method grows past three or four optional parameters, take one options object (TS, C#
record) or keyword-only arguments (Python). - Pagination. Offset/limit is simple but shifts when data changes. Cursor tokens are stable. Either way, decide what “next page” means when items are added.
- REST and HTTP. Status codes are the error channel: 400 for bad input, 404 for not found, 409 for conflicts, 422 for validation, 429 for rate limits. Same three-way split as above.
- Deprecation. Python
warnings.warn(..., DeprecationWarning), C#[Obsolete("use X")], JSDoc@deprecated. Warn first, remove later.
Pitfalls
Section titled “Pitfalls”- Returning
Nonefor an empty collection. Every caller now needs aNonecheck. - Exceptions for normal outcomes. “User not found” in a lookup is expected; using exceptions for it is slow and hides the real failures.
- Swallowing errors.
except Exception: return Noneturns bugs into silent wrong answers. - Boolean traps.
search("x", 10, True, False): nobody can read that. Use names or an enum. - Mutating inputs. Sorting the caller’s list in place is a side effect they didn’t ask for.
- Unstable output order. Returning dict iteration order (or C#
Dictionaryorder, which isn’t guaranteed) when the spec says “sorted”. Sort explicitly, with an explicit tie-break. - Inconsistent signatures.
tsfirst in some methods and last in others. Interviewers’ later levels assume the pattern holds.
In the interview
Section titled “In the interview”- Read the signatures before coding and ask what each “unhappy” path returns: “If the account doesn’t exist, is that
None,False, or an error string? Should I ever throw?” - Say the rule you’ll follow: “Absent →
None, nothing matched → empty list, invalid arguments →ValueError.” - When a level adds a parameter, say: “I’ll add it with a default that keeps the old behaviour, so earlier calls and tests are unaffected.” Then rerun the earlier tests.
- If asked to design an API from scratch: start from the caller’s code (“what would I want to write?”), then the types, then the errors.
- Expect follow-ups: versioning a public API, pagination, idempotent retries, and “how would you deprecate this?”
Check yourself
Section titled “Check yourself”Practice on your own
Section titled “Practice on your own”These are classic exercises, not gym questions:
- Design a URL-shortener API:
shorten(url, alias=None),resolve(code). What are all the outcomes of each, and how is each reported? - Take a function with five positional parameters and three booleans and redesign it with keyword-only arguments or an options object, keeping old calls working.
- Write the v1 → v2 migration for a function that returned
Noneon “not found” but now must distinguish “not found” from “deleted”. - Design pagination for a “list events” API where events are inserted while clients page through.
Go deeper
Section titled “Go deeper”- Joshua Bloch, “How to Design a Good API and Why it Matters” (Google Tech Talk and OOPSLA 2006 companion paper): still the best short guide.
- Stripe engineering blog, “APIs as infrastructure: future-proofing Stripe with versioning”: date-based versions and version translation layers.
- Semantic Versioning 2.0.0 and Hyrum’s law.
- Microsoft Learn, Breaking changes guidance for .NET libraries: source vs binary compatibility, optional parameters and overloads.