Skip to content

API design and evolution

coreCommon atStripeAnthropicOpenAIGoogleRead firstClass design and invariantsPractise in the Sandbox →

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.

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 ([] vs None vs 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.

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.

Will v2 break the existing callers?
toggle changes
v1: search(query: str, limit: int) → list[Result(title, url)]

proposed changes

existing callers2.0.0: MAJOR, breaking

A workssearch("cats", 10)
positional args
B workssearch(query="cats", limit=10)
keyword args
C worksfor r in search("cats", 10): show(r.title)
reads a result field
D workslen(search("zz", 5)) == 0
relies on [] for no hits
E breakscompiled .NET app built against v1
binary, not recompiled; broken by: add optional param at the end
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).
still worksruns but silently wrongfails to compile or crashes
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.
  1. 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).
  2. Expected misses are return values: None / null / undefined for “no such item”, an empty collection for “no matches”, False for “didn’t apply”. Collections should never be None: empty is a valid answer every caller already handles.
  3. 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.
  4. If a caller often expects failure, offer both: parse() that throws and try_parse() that returns None / a result object. One implementation, two signalling styles.
  5. Be consistent across the class. If delete returns bool, cancel should too.
parse_size.py
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")
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
  1. 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.
  2. New outputs are new fields, not changed meanings of old ones. Old readers ignore fields they don’t know.
  3. Need a different meaning? Add a new method (search_v2, searchPaged) and deprecate the old one; don’t repurpose it.
  4. Removing or renaming is a major version. Deprecate first, keep both for a while, then remove.
  5. 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.
search_v2.py
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")
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.

  • Command-string APIs. One execute("SET k v") → str entry 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.
  • Returning None for an empty collection. Every caller now needs a None check.
  • 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 None turns 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# Dictionary order, which isn’t guaranteed) when the spec says “sorted”. Sort explicitly, with an explicit tie-break.
  • Inconsistent signatures. ts first in some methods and last in others. Interviewers’ later levels assume the pattern holds.
  • 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
pick one
find_orders(customer) has no orders to return for this customer. What should it return?
Check yourself
pick one
Python v1: def charge(amount, currency). v2 inserts a parameter: def charge(amount, account, currency). An old call is charge(500, 'USD'). What happens?
Check yourself
pick one
A public C# library method Send(string to) gets a new optional parameter: Send(string to, int retries = 3). An app compiled against the old DLL is not recompiled. What happens when it calls Send?

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 None on “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.