Cruxly

Cruxly › System design › Running the Design Interview

Designing the API

the contract you write before the boxes: nouns, cursors, idempotency keys, compatible change and errors a client can act on

Open the interactive lesson

The API is the step of the sketch where you commit to what crosses each arrow, and it is where a design either stays honest or hides its weak points. This lesson shows how to design the interface inside the four moves (scope, estimate, sketch, deep dive) in about five minutes, and the handful of details that separate a strong answer from a list of endpoints: pagination, idempotency keys, compatible change, error shape and where authorisation lives.

Further reading: Alex Xu, System Design Interview: An Insider's Guide (Volumes 1 and 2), for worked interview walk-throughs; this lesson is original and does not follow its structure.

The API is the contract: design it before the boxes

Writing the calls down turns a vague architecture into something that can be wrong. In Scoping the Problem you fixed what the system must do, in Estimating the Load you sized it, and in The First Diagram you drew boxes and agreed that every arrow needs a contract (Define the contract at each hop). This lesson is that contract, written out, for the arrow that matters most: the one between your clients and your system.

Where it sits in the interview. Spend about five minutes between the estimate and the picture. Take each functional requirement from scope and give it one call. A chat system has POST /v1/conversations/{id}/messages and GET /v1/conversations/{id}/messages; a URL shortener has POST /v1/links and GET /{code}. The list of calls does three jobs at once:

Do not design the whole API. Write the three to six calls the requirements demand, name the parameters that matter, and say what you are leaving out ("auth, admin and analytics endpoints follow the same conventions"). Time spent on a thirtieth endpoint is time not spent on the deep dive.

Design for the client's failure modes, not the happy path. Google's API design guide (now published as the AIPs) states that the building blocks of an API are individually named resources (nouns) and a small set of standard methods (verbs), and that the API should not simply mirror the database schema. That last point is the interview-relevant one: if your first draft has a /users_table_rows endpoint, you have exposed storage, and every schema migration becomes a breaking change.

What a staff engineer says in the room. "Let me write the contract first, because everything I draw has to carry it." What they check: that every requirement has a call, every list has a bound, every write has a retry story, and no path leaks a table name.

Resources and verbs: modelling the nouns of the system

Model the nouns first and let a small, fixed set of verbs act on them. Google's guide (AIP-121) says an API is individually named resources plus a small number of standard methods, that a collection holds resources of one type, and that APIs should prefer standard methods over custom ones. The standard methods are List, Get, Create, Update and Delete, which map onto GET on a collection, GET on one item, POST, PATCH and DELETE.

IntentCallWhy this shape
Create an orderPOST /v1/ordersThe server assigns the id; the response carries it
Read oneGET /v1/orders/{id}Safe and cacheable
List a customer's ordersGET /v1/customers/{cid}/ordersParent in the path scopes the query and the auth check
Change part of onePATCH /v1/orders/{id}Partial update; only named fields change
CancelPOST /v1/orders/{id}:cancelA state transition that is not a field edit gets a custom method

Hierarchy is a scoping tool, not decoration. A resource path like /customers/{cid}/orders/{oid} tells the service which tenant the data belongs to before it touches storage, and AIP-121 requires the relationships to form an acyclic graph. Keep nesting to what the ownership really is. If an order can move between customers it is not a child of one.

Verbs in paths are a smell, with one exception. POST /createOrder and GET /getOrders put the method in the URL, which throws away what HTTP already says. The exception is a real state transition, like cancel, capture or publish, that is not "set field X". AIP-121 lets you use a custom method for these. In an interview, say "that is a transition, so it is a custom method on the resource, not a PATCH of a status field", because a status PATCH lets a client jump from any state to any state.

Safe, idempotent and neither. RFC 9110 (HTTP semantics, section 9.2.2) defines a method as idempotent if several identical requests have the same intended effect on the server as one, and lists PUT, DELETE and the safe methods as idempotent. POST is not. That is why the choice of verb matters for failure: a client may automatically retry an idempotent request after a connection error, and the RFC says a proxy must not automatically retry a non-idempotent one.

PUT versus PATCH. PUT replaces the whole resource, so a client that has not seen a field added by a newer server will erase it. Google's AIP-134 uses PATCH with an update_mask listing the fields to change, treats an empty mask as a no-op, and reserves * for full replacement. Name the failure in the room: "full replace with a stale client silently deletes new fields."

Concurrent writers. Two clients editing one resource is a lost update. RFC 9110 defines If-Match with an entity tag so a write is applied only if the resource is unchanged since the client read it, and the server answers 412 Precondition Failed when not. That is optimistic concurrency, the same fix as the conditional update in Boundaries Are Where Guarantees Die, carried in the API.

What a staff engineer says in the room. "Nouns for things, a status transition gets its own action, updates are partial, and writes carry a version." What they check: that no path contains a verb that PATCH or POST already says, that a stale client cannot erase fields, and that parents in the path match real ownership.

Pagination: cursor versus offset, with a stable sort

Any call that returns a collection needs a bound and a cursor on day one, because adding pagination later breaks every client. Google's AIP-132 requires page_size and page_token on every list request and next_page_token on every list response. Stripe's list endpoints take limit (1 to 100, default 10), starting_after and ending_before, each an existing object id, and return has_more (Stripe API reference, read November 2026). GraphQL's documentation calls cursor pagination the most powerful of the designs and notes that offset pagination gets ambiguous when records are added between page requests.

Why offset fails twice.

What a cursor is. A keyset position: "give me 100 rows after (created_at, id) = (T, 8841)". The query is WHERE (created_at, id) < (T, 8841) ORDER BY created_at DESC, id DESC LIMIT 100, which an index on those two columns answers by seeking, not scanning. The same idea appears in Activity Feed.

Two rules people forget.

  1. The sort must be total and stable. If you order by created_at alone and two rows share a timestamp, the boundary between pages is undefined and rows are skipped or repeated. Add a unique tiebreaker (the id) to both the order and the cursor. The same applies to an Elasticsearch search_after, which needs a tiebreaker for the same reason.
  2. The token is opaque. AIP-158 says page tokens must be opaque, URL-safe strings that clients cannot parse, that Base64 alone is not sufficient obfuscation, that tokens must not grant authorisation, and that a service may expire them (it gives about three days as a rule of thumb). Opaque means you can change the underlying sort or encoding later without a new API version. GraphQL's guide also suggests Base64-encoding cursors as a reminder that their format must not be relied on.

Rules for the rest of the contract. AIP-158 says an empty next_page_token is the only signal of end of collection, a page size above the maximum is coerced down rather than rejected, a negative size is INVALID_ARGUMENT, and changing the size between pages must be honoured. The Microsoft Azure guidelines add that a server may return fewer than the requested maximum and that resources may be skipped or duplicated across pages, which should be documented. An exact total_count is expensive on a large table; AIP-158 allows total_size to be an estimate, so do not promise it.

What cursors cost. You cannot jump to page 500. Say so: "users who need page 500 need a filter or a search, not a deeper page". Cursors are also forward-only unless you add a reverse cursor, which Stripe does with ending_before.

What a staff engineer says in the room. "Offset is a position, cursor is a key; positions move." What they check: a unique tiebreaker in the sort, an index that matches the sort, a maximum page size, an opaque token and a stated token lifetime.

Idempotency keys for unsafe retries

A POST that times out leaves the client not knowing whether the work happened, and an idempotency key is how you let it retry anyway. Boundaries Are Where Guarantees Die explains why retries and duplicates exist; Payment Ledger shows the ledger side. This section is the interface side, and what an interviewer wants is the exact contract.

The contract, per Stripe's API reference (read November 2026).

RuleStripe v1
Who makes the keyThe client, e.g. a V4 UUID, up to 255 characters, not sensitive data
Which callsAll POST requests accept it; GET and DELETE do not need it
What the server storesStatus code and body of the first request once execution begins, including 500 errors
On a retryReturns the saved response
Different parameters, same keyError, to catch accidental key reuse
RetentionKeys may be pruned once at least 24 hours old; a reused key then makes a new request
Not storedRequests that failed validation or conflicted with a concurrent request with the same key, so these can be retried

:::key Verified November 2026 The 255-character limit, the 24-hour pruning and the saved-500 behaviour are from Stripe's idempotent-requests reference for API v1 on that date; Stripe's v2 behaves differently. Check the current page before quoting a number to an interviewer who uses Stripe daily. :::

Stripe's API v2 changes this: retries do not repeat successful operations and failed or partly failed work is retried to completion where possible. Google's AIP-155 takes a lighter form: an optional request_id field on the request (not on the resource), UUID4 recommended, a duplicate of a successful request should return the earlier response, and the method is idempotent only when the id is provided; the server chooses how long to remember ids. The Azure guidelines use Repeatability-Request-ID plus Repeatability-First-Sent headers and require a tracking window of at least 5 minutes.

The design choices an interviewer probes.

What it does not give you. Idempotency covers the retry of one logical request. Two different requests with different keys are still two operations, so double-clicks that generate two keys still double-submit unless the UI reuses the key per intent. And the key protects only your service: a side effect you trigger in a third system needs its own key, forwarded.

HTTP's own rule. RFC 9110 says a client should not automatically retry a non-idempotent request unless it knows the semantics are idempotent, "regardless of the method". An idempotency key is exactly that knowledge, made explicit.

What a staff engineer says in the room. "The key is chosen by the client before the first try, stored atomically with the effect, and compared against the body." What they check: retention, behaviour for concurrent duplicates, and that every unsafe POST an SDK retries carries one.

Versioning and compatible evolution

The best version strategy is to need a new version rarely, by only making changes old clients cannot notice. Boundaries Are Where Guarantees Die covers why v1 and v2 overlap during every deploy and how encodings tolerate unknown fields. This section is the public-API consequence: you do not control when external clients upgrade, so the contract has to evolve while they stay on it.

The compatible changes. Google's AIP-180 allows, within one major version, adding interfaces, methods, messages, fields, enums and enum values, provided new fields keep the old behaviour by default. It treats these as breaking: adding a required field to an existing request, removing or renaming anything, changing a field's type even when wire-compatible, changing a default, and changing a resource's name. It also advises caution when adding values to an enum used in responses, because a client with a closed switch statement may fail on an unknown value. In an interview, make that point: "adding an enum value is compatible on the wire and breaking in the client's code".

Wire format rules that make it true. The proto3 language guide says adding fields is safe because old code ignores them, field numbers cannot change once in use, and a deleted field's number must be reserved so nobody reuses it with a different meaning. Only certain type swaps are binary-compatible (for example int32, int64 and bool among themselves), and AIP-180 still calls any type change breaking. For JSON APIs the same discipline is a convention you must enforce: ignore unknown fields on read, never repurpose a field.

Where the version lives. AIP-185 puts only the major version in the path (/v1/), forbids minor or patch numbers in it, and says breaking changes are gated behind a new version with a well-communicated deprecation period (it recommends 180 days for beta transitions). Stripe takes the other route (API reference, read November 2026): a version string in the Stripe-Version header, defaulting to the version pinned to the account, and since the 2024-09-30.acacia release a new version monthly with no breaking changes plus a major release twice a year that may break. Webhook events use the version set on the endpoint. The pin means a customer's integration does not change until they choose.

ApproachGainsCosts
Major version in the path (/v1)Visible, easy to route, cache and monitorTwo surfaces to run until the old one is retired
Version in a header, pinned per accountCustomers upgrade on their schedule; one URLServer code must serve many behaviours; harder to test
No version, additive only (GraphQL's stance: evolve the type system without versions)One surfaceDeprecation, not removal; schema grows forever

Retiring a version is a project, not a deploy. Measure traffic by version and by client, tell the owners, run both, and only then remove. A design question in the room: "who are the clients and can we make them upgrade?" For a mobile app the answer is no, so the API must support versions as old as the oldest install.

What a staff engineer says in the room. "Additive only, and every breaking change gets a new major version with a deprecation window." What they check: unknown-field tolerance in every client, reserved field numbers, an enum policy, and traffic per version before shutting one off.

Errors, status codes and rate-limit headers clients can act on

An error response is an instruction to the caller: stop, fix the request, wait, or try again. Design it so a program can tell which, without parsing English.

The decision the client makes. Three classes matter, and each status code should map to exactly one of them.

Client shouldMeaningHTTP examplesgRPC examples
Fix the requestIt will fail identically on retry400, 404, 422INVALID_ARGUMENT, NOT_FOUND, FAILED_PRECONDITION
Back off and retryTransient or overloaded429, 503 (both may send Retry-After)UNAVAILABLE
Re-read and retry the whole stepA concurrency conflict409, 412ABORTED

RFC 9110 defines 409 as a conflict with the current state that the user might be able to resolve and resubmit, 412 as a failed precondition such as If-Match, 422 as syntactically valid content the server cannot process, and 503 as temporary overload or maintenance that may carry Retry-After. RFC 6585 defines 429 for too many requests in a given time, with details in the body and optionally Retry-After. The rate-limiting side, including the header set and thundering retries, is in Rate Limiting; the point here is that the error contract must include it.

Retry rules need to be written down. Google's AIP-194 says clients should automatically retry only UNAVAILABLE, and only for requests that are safe to repeat; it lists CANCELLED, DEADLINE_EXCEEDED, INVALID_ARGUMENT and DATA_LOSS as never to retry, and says transactional requests should be retried as a whole at the application level. The gRPC status-code guide gives the same split: UNAVAILABLE retries the failing call, ABORTED retries a higher-level read-modify-write sequence, FAILED_PRECONDITION waits until state is fixed. It also warns that DEADLINE_EXCEEDED on a state-changing call can happen even though the operation succeeded, which is why idempotency keys (section 4) come before retry policy.

A body a program can read. RFC 9457 defines problem details for HTTP APIs: a JSON object with type (a URI that is the primary identifier of the problem), title, status, detail and instance. The status is advisory and must match the real HTTP status. Google's AIP-193 requires a status with a canonical code, a developer-facing message and an ErrorInfo with a reason (UPPER_SNAKE_CASE, at most 63 characters, unique in its domain) and a domain. Either way, the stable identifier is the machine contract; the message is for humans and can change.

Rules that save you later.

What a staff engineer says in the room. "Status says the class, a stable code says the cause, the body says what to do, and 429 and 503 say when." What they check: that no 5xx hides a client mistake, that every retryable error has a documented policy, and that the error shape is identical on every route including the gateway's.

REST, gRPC and GraphQL: choosing per boundary

Pick the style per boundary, because the three solve different problems and a system often uses two. API Gateway covers protocol translation at the edge; this is the choice you make before that.

REST (HTTP resources)gRPCGraphQL
ContractConvention plus optional OpenAPI.proto file, generated clientsTyped schema, one endpoint
Best boundaryPublic and partner APIs, browsers, anything cached by intermediariesService to service, polyglot internal calls, streamingMany different clients (web, mobile) reading shaped data from many backends
Shapes of callRequest/responseUnary, server streaming, client streaming, bidirectionalQuery, mutation, subscription
CachingHTTP caches work on GETNot by defaultNeeds object ids and client or persisted-query caches
Failure signalHTTP statusStatus codes 0 to 16, deadlinesOften 200 with an errors array
Typical trapChatty clients, inconsistent conventionsHard to call from a browser without a proxyExpensive queries, N+1 resolvers

gRPC facts. The gRPC core concepts page lists four method kinds (unary, server-streaming, client-streaming, bidirectional-streaming) and says protocol buffers are the default IDL. Its deadlines guide says gRPC sets no deadline by default, so a client can wait effectively forever, advises always setting a realistic deadline, and notes that propagating a timeout rather than an absolute time shields you from clock skew. That propagation is what stops a slow dependency from holding every caller's thread: the same cascading failure as in Boundaries Are Where Guarantees Die. Cancellation does not roll back work already done.

GraphQL facts. GraphQL serves from a single endpoint; servers are expected to accept POST for queries and mutations, and may accept GET for queries only (GraphQL over HTTP documentation). Partial success returns a 2xx with both data and errors, because HTTP has no partial-success status, so an uptime monitor watching status codes sees green while fields fail. The performance guide names the N+1 problem (one query for a list, N more for each item's children) and the usual fix, batching with a DataLoader, and lists demand controls: paginating lists, limiting depth and breadth, query complexity analysis, and persisted queries (trusted documents) so the server runs only known operations. The rate limits in Rate Limiting therefore need to be per cost, not per request: one GraphQL request can ask for a million rows.

How to decide in the room.

  1. Who are the callers? Unknown third parties: REST. Your own services: gRPC. Your own many frontends with differing data needs: GraphQL, probably over REST or gRPC services.
  2. Do you need streaming? Server push to a client usually means WebSocket or server-sent events at the edge, and gRPC streams inside.
  3. Who pays for a bad query? REST routes have a fixed cost you can reason about. GraphQL hands the client the ability to write the query, so you need cost limits.
  4. Can intermediaries cache it? If a CDN should serve it, make it a GET with a stable URL.

A common answer: public REST with idempotency keys and cursors; gRPC between services with deadlines; a GraphQL layer only if there are many clients with different needs. Say what you gain and what you pay, not that one is modern.

What a staff engineer says in the room. "REST at the public edge, gRPC inside, GraphQL where client variety justifies a cost-limited graph." What they check: a deadline on every call, a cost limit on every query language, and a status monitor that understands 200-with-errors.

Authentication, authorisation and quotas at the edge

Authenticate at the edge, but authorise where the data lives, and put the identity into the request, not into the URL. API Gateway covers what belongs on the gateway. The API-design point is the split of three jobs that get blurred in interviews.

JobQuestionWhere it lives
AuthenticationWho is calling?Edge: gateway or identity provider verifies a credential and passes a verified identity downstream
AuthorisationMay this caller do this to this object?The service that owns the object, close to the data
Quota and rate limitHow much may this caller do?Edge for the cheap, coarse limits; service for expensive operations

Why authorisation cannot sit only at the edge. The GraphQL authorization guide states the same principle: authorisation belongs in the business logic layer, not in the API layer, because duplicating it per entry point lets users see different data depending on which API they use. That is the failure of every API that checks "is the token valid?" at the gateway and then trusts GET /v1/orders/{id}: any logged-in user who guesses another id reads another customer's order. Naming this attack (a broken object-level check) and the fix (every handler checks the caller owns or may see the object; IDs are not secrets) is a strong interview line.

What goes in the contract.

Internal calls. After the edge, services still need to know who the end user is. Pass a verified identity (a signed token or propagated claims) and have each service enforce its own rules. Trusting "it came from inside" is the assumption that turns one compromised service into a full breach.

Worked arithmetic. A public API with 1,000,000 customers and a per-key limit of 100 requests per second allows up to 100,000,000 requests per second if every customer maxes out, far above any capacity. Per-key limits therefore protect against one abuser, not against aggregate overload, so a separate global limit and load shedding must exist.

Closing the loop on the interview. End the API step by reading the contract back in one breath: the resources, the cursor on every list, the key on every unsafe POST, the additive-only rule, the error shape, where auth sits. That short recap tells the interviewer the rest of the design will be consistent with it.

What a staff engineer says in the room. "The gateway proves who you are; the service proves what you may touch." What they check: a test that one tenant cannot read another's object id, tenant derived from the credential, limits per key and global, and the same checks on internal paths.

How to say it in the interview

"Before I draw boxes I write five or six calls. Resources are nouns, so POST /v1/orders, GET /v1/orders/{id}, and a list that is cursor-paginated with a stable sort, because offset pages shift when rows are inserted. Every unsafe POST takes an idempotency key the client generates, so a retry after a timeout cannot charge twice. Errors carry a machine-readable code, and a 429 carries Retry-After. I add fields but never remove or retype them, so old clients keep working. Authentication happens at the edge, but authorisation for each object is checked in the service that owns it."

Checklist

Practice this interactively
Share this lessonWhatsAppLinkedInX

Related lessons