designing-apis

Use when defining an interface other code will call — a public function or library entry point, an HTTP or RPC endpoint, an IPC or wire message, a plugin contract, an exported module boundary, or a tool, command, or server an agent will call. Also use when a design keeps per-client state on the serving instance, when session affinity is what makes it scale, or when a call to someone else's interface has to turn a setting off. Covers naming and granularity, required versus optional parameters, statelessness and per-request identity, defaults, pagination and ordering, growing an enum, opaque tokens, separating write paths by data provenance, deprecation and sunset, versioning, and judging whether a proposed change is breaking.

Overview

The interface is the only part you cannot refactor later: everything behind it is yours, everything in front belongs to callers you cannot deploy. Design the failure modes, the growth path, and the compatibility promise before the happy path.

When to use

  • Adding anything another team, process, or version will call: exported function, endpoint, message type, plugin hook, config key.
  • Deciding one call versus three; required versus optional; what to return for a collection.
  • Adding a field, parameter, enum member, or error code to something already shipped.
  • Someone asks "is this change breaking?"
  • Not for: choosing a single identifier's wording (naming-things), changing a contract that already has deployed callers (schema-evolution), deciding where the boundary belongs at all (drawing-boundaries).

Shape and granularity

  • Write three call sites — the common one, the rare one, the awkward one — before the implementation. The common case must be reachable with the fewest arguments; the rare case must be reachable at all. If the awkward one requires reaching around the API, the shape is wrong.
  • One call per caller intent, not per stored entity. If every caller makes the same N calls in the same order, that sequence is the API and the N calls are leaked implementation.
  • Granularity follows call cost: in-process ≈ nanoseconds, IPC ≈ microseconds, network ≈ milliseconds. A boundary costing 1ms cannot expose per-field accessors; a boundary costing 10ns should not force a batch.
  • Name the effect, not the mechanism: cancel, not setStatusCancelled. Mechanism names age badly because the mechanism changes and the name cannot.
  • A boolean parameter is a design failure at the first one. render(true) is unreadable at the call site and the second flag creates a combinatorial mess. Use a named option type or enum from flag one.
  • Never let a field's meaning depend on another field's value. "If kind is A, payload is X" is a tagged union; ship it as a discriminated union with a closed shape.
  • One write path per provenance, not per row shape. Where a field encodes how strongly something is believed, writing an outside claim through the operation that means "we observed this" erases the distinction permanently — matching row shapes are what make the shortcut tempting, not what make it safe. tracking-data-provenance covers the claim taxonomy and the promotion rules that turn a laundered value into authority.

Required, optional, and absence

ConceptEncode as
Always meaningfulRequired field
Usually omitted, safe fallback existsOptional with a documented default
Meaningfully absentExplicit unset variant or sentinel, never a null shared with "not provided"
Patch semantics (unset vs cleared vs set)Three encodings, not two — a nullable field cannot express this

Optional-with-default beats nullable for two reasons: null forces every caller to branch, and on any wire format null is indistinguishable from "an older client did not send this field". Put the default in the contract so it is versionable; a default replicated into each caller silently drifts per client.

Absence bites from the calling side too, where the contract is someone else's. Omitting a parameter, sending its lowest named level, and sending an explicit disable are three different requests on most interfaces: omission asks for whatever the provider's default is today, and the lowest level may still do real work. A wrapper or relay that maps its own "off" to omission has inherited that default, and will inherit the next one too. Send the explicit off value, and confirm it with the provider's own accounting of the work done — its usage figures, its logs — never with the absence of an error.

Designing for growth

  • Additive-only is the cheapest promise you can keep. Additive means: a new optional field, a new operation, a new enum member only if tolerance shipped first, a new error code only if callers were told to handle unknown codes by class.
  • Never return a bare closed enum you may need to grow. Every closed enum is a promise you will never add a member. If growth is plausible, ship an unknown/default arm and the client's obligation to tolerate it from day one — schema-evolution covers the deployment order for actually adding the member later without crashing the fleet that hasn't caught up.
  • Opaque tokens — cursors, continuation handles, resume tokens, ETags — must be opaque in fact, not just in the docs: encode and sign or version the payload, and reject a token you did not mint. If callers can parse it, they will depend on it, and their dependency becomes your contract.
  • Reserve removed field names and tag numbers so a future field cannot reuse them and misparse old data — schema-evolution has the full mechanics of why reuse corrupts archived data with no error.
  • An open extra: map<string,string> bag becomes a schema you never versioned and can never validate. Prefer a reserved namespace with an explicit no-compatibility statement, or nothing.

Collections, errors, idempotency

  • Any operation returning a collection needs, in v1: a limit with a documented default and maximum, a total ordering, and an opaque cursor. Retrofitting is breaking even behind a new version, because "returns all of them" is what callers built on.
  • Ordering must be total — tiebreak on a unique key. Ordering by a non-unique column under pagination silently duplicates and skips rows; this is the most common pagination bug and it never appears in small test data.
  • Enumerate the failures at design time; they are part of the type whether or not the language says so. Minimum: a stable machine-readable code, a retryable indicator, and an identifier the caller can quote back. See modeling-errors.
  • For every mutating operation, answer "the caller timed out and does not know the outcome" before shipping. If the answer is "they cannot tell", you designed a double-charge. See designing-for-idempotency.

Stateless core, identity per request

An API that remembers who you are between calls has bound every subsequent call to one instance. Everything downstream inherits that: a load balancer needs affinity, a rolling deploy drops sessions, an instance cannot be replaced under load, and horizontal scaling stops being a configuration change and becomes a migration.

Carry identity on every request instead, and let any instance serve any call. The handshake that establishes a session is usually the thing to remove — it buys a per-call saving that is rarely measurable and costs a property that is expensive to add back later.

State that genuinely must persist across calls goes somewhere that is not the serving instance, and the API says so explicitly: a resource the client addresses, or a token the client holds and returns. The test is whether two consecutive calls can land on different instances with no coordination. If they cannot, that is a scaling limit written into the interface rather than into the deployment, which is the more expensive of the two places to have it.

The exception is genuinely long-running work, which is a poor fit for a request that has to stay open. Give it a resource the client can create, poll, and cancel — long-lived connections then become an optimisation over polling rather than the mechanism the design depends on.

When the caller is a model

A tool an agent calls, a command-line program it drives, a test runner or linter it iterates against, a server that exposes operations to one — each is an API whose caller reads every byte you return, pays for every byte in the working memory it reasons with, and picks among operations by their names and descriptions alone. Everything above still holds. What changes is which mistakes are expensive.

  • The name and description are the contract the caller actually reads. It never sees the code, so the description says what the operation is for, when to use it rather than its neighbour, and what each parameter means in the caller's terms. Grouping names under a shared resource prefix helps a caller tell neighbours apart; which convention works best varies by model, so test it.
  • One operation per task, not per stored entity. The rule from Shape and granularity bites harder here: a caller that must list everything and filter it has spent its context on rows it discards. Offer the search, not the dump.
  • Bound every response by default. A limit, a truncation that says how to get the rest, a summary first with detail written to a file or behind a follow-up call. A test runner that prints ten thousand lines of passing tests has hidden its one failure from the reader that has to fix it.
  • Identifiers the caller can carry. A model copies a long random string between calls less reliably than a name or a short id, and a wrong copy looks like a missing record. Keep cursors opaque but short — a server-side handle rather than a signed blob of state — and give entities a short, stable id or name. Legible is not guessable: every id is still authorized on use.
  • An error message is the caller's retry logic. It acts on the text, so say what to do next — the valid values, the call to make first, the closest match — alongside the stable code modeling-errors requires. A stack trace from inside your tool says where your code broke, not what the caller should send instead. A check it iterates against should say how to fix what it flags, not only that it is wrong.
  • Give it a fast default, because it cannot feel time passing. A sample that runs in seconds, with the full run behind a flag, keeps a caller from spending an hour on a loop no person would have sat through.
  • A check an agent iterates against must be close to exact. It will satisfy the check rather than the intent, so every gap in the check becomes a behavior; confirming-before-claiming-done lists what that looks like from the other side.

Test it the way it will be used: give a model real tasks and read the transcripts. Wrong operation chosen, parameters misfilled, the same call repeated — each is a finding about the name, the description, or the shape of the response, and fixes to those are measured the way any model-facing change is (evaluating-llm-output).

Versioning and the compatibility contract

The menu of versioning strategies and their real costs is the same whether you're picking one now or dealing with a contract that's already shipped — see schema-evolution's versioning table rather than a second copy here. What belongs at design time instead: state three things explicitly before shipping v1 — which surface is covered, how long an obsolete thing keeps working (in absolute time or releases), and how a break is signaled. Deprecation without a runtime signal is not deprecation — nobody reads changelogs; things that emit warnings get fixed. "Experimental" is only real when it is mechanically unpleasant to use (opt-in flag, unstable_ prefix); a documentation note does not stop anyone from depending on it.

Common mistakes

SymptomReal cause
Every new feature adds another boolean parameterOptions never modeled as a type; the first flag set the pattern
Every caller wraps the API in the same helperWrong granularity — their helper is the API you should have shipped
Adding an optional field broke old clientsContract never stated unknown-field tolerance, so parsers were strict
"Page 2 and page 3 both contained item X"Non-total ordering under pagination
A new enum member crashed the fleetMember added before tolerant clients were deployed — see schema-evolution for the deployment order
v2 quietly returns fewer results than v1Unbounded collection in v1; adding a limit is breaking regardless of the version label
Callers keep getting the call order wrongSequencing is a real constraint and belongs inside one call or an explicit state machine
Support asks callers to "check the error text"No stable error code in the contract
A caller broke when you changed a cursor's formatToken documented as opaque but never made opaque
An agent keeps calling the wrong tool, or retries the same failing callThe name and description never said when to use it, and the error said what failed but not what to do instead
An outside recommendation trips a threshold meant for first-party evidenceOne write path served two provenances, so the row records the value and forgets where it came from

Red flags

  • "We'll add pagination when it gets big."
  • "It's internal, we can change it later" — about something whose callers you do not deploy.
  • "Just return null for now."
  • "Callers shouldn't depend on that."
  • "We'll mark it experimental in the docs."
  • Adding a flag parameter instead of asking whether this is really two operations.
  • Reusing a write path because the row shape matches, without checking that the claim's provenance does.

More in Designing systems