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 lessonThe 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:
- It exposes the read/write ratio. The estimate said reads outnumber writes 100 to 1; the calls show which read is the hot one, and that is the one you cache and index.
- It names the entities. Every noun in a path is a table, a partition key or a service boundary you now have to justify.
- It surfaces the hard parts early. A call that returns an unbounded list needs a pagination rule. A call that moves money needs an idempotency rule. A call that takes minutes needs an asynchronous shape. Spotting these before drawing is the senior move.
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.
| Intent | Call | Why this shape |
|---|---|---|
| Create an order | POST /v1/orders | The server assigns the id; the response carries it |
| Read one | GET /v1/orders/{id} | Safe and cacheable |
| List a customer's orders | GET /v1/customers/{cid}/orders | Parent in the path scopes the query and the auth check |
| Change part of one | PATCH /v1/orders/{id} | Partial update; only named fields change |
| Cancel | POST /v1/orders/{id}:cancel | A 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.
- Correctness. Page 2 with
offset=100&limit=100means "skip the first 100 rows now". If one new row is inserted at the front while the user reads page 1, the row that was 100th becomes 101st and appears again at the top of page 2. Delete one row and a row is skipped. For an activity feed that gains rows every second, this is the normal case, not a corner. - Cost. The database has to walk past the skipped rows. Worked arithmetic: a collection of 1,000,000 rows read in pages of 100 is 10,000 pages. With offset, page k walks 100 x k rows, so the whole scan visits 100 x (0 + 1 + ... + 9,999) = 4,999,500,000 rows, about 5 billion. With a cursor each page starts at the cursor and touches 100 rows, 1,000,000 in all. That is a 5,000 to 1 difference for a full export.
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.
- The sort must be total and stable. If you order by
created_atalone 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 Elasticsearchsearch_after, which needs a tiebreaker for the same reason. - 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).
| Rule | Stripe v1 |
|---|---|
| Who makes the key | The client, e.g. a V4 UUID, up to 255 characters, not sensitive data |
| Which calls | All POST requests accept it; GET and DELETE do not need it |
| What the server stores | Status code and body of the first request once execution begins, including 500 errors |
| On a retry | Returns the saved response |
| Different parameters, same key | Error, to catch accidental key reuse |
| Retention | Keys may be pruned once at least 24 hours old; a reused key then makes a new request |
| Not stored | Requests 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.
- The key must be generated before the first attempt and reused for every retry. A client that makes a new UUID per attempt has no protection. A natural business key also works: in a chat, the
client_message_idfrom Define the contract at each hop is exactly this. - Store the key and the result atomically with the write. If the key is recorded but the write is not (or the reverse), a crash between them breaks the guarantee. A unique constraint on
(tenant, key)inserted in the same transaction as the effect is the simplest correct form. - Concurrent duplicates. Two attempts with the same key can arrive together. Take a lock or rely on the unique constraint: one executes, the other waits or gets a "request in progress" error. Stripe does not save a result in the concurrent-conflict case, so the client can retry.
- Compare the request body. The same key with a different body is a bug in the caller; reject it, as Stripe does.
- Retention is a trade-off. Worked arithmetic: 1,000 writes per second kept for 24 hours is 86,400,000 keys. At an assumed 100 bytes per record (key, a request hash, a pointer to the saved response) that is about 8.6 GB, small enough for a replicated store with a TTL. Longer retention helps clients that retry after outages and costs linear storage.
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.
| Approach | Gains | Costs |
|---|---|---|
Major version in the path (/v1) | Visible, easy to route, cache and monitor | Two surfaces to run until the old one is retired |
| Version in a header, pinned per account | Customers upgrade on their schedule; one URL | Server code must serve many behaviours; harder to test |
| No version, additive only (GraphQL's stance: evolve the type system without versions) | One surface | Deprecation, 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 should | Meaning | HTTP examples | gRPC examples |
|---|---|---|---|
| Fix the request | It will fail identically on retry | 400, 404, 422 | INVALID_ARGUMENT, NOT_FOUND, FAILED_PRECONDITION |
| Back off and retry | Transient or overloaded | 429, 503 (both may send Retry-After) | UNAVAILABLE |
| Re-read and retry the whole step | A concurrency conflict | 409, 412 | ABORTED |
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.
- One error shape for the whole API, including the gateway's own errors, so clients write one handler.
- Never encode the outcome only in prose. A client that has to match "insufficient funds" in a string breaks on the next copy edit.
- Do not leak internals. AIP-193 says messages must not assume the reader knows the implementation; stack traces and table names are an information leak.
- Validation errors list every bad field, not the first one, so a form is fixed in one round trip.
- Long work returns an operation. AIP-151 recommends a long-running operation resource when a call may take significant time (about 10 seconds as a rule of thumb), polled by id; Azure's guidelines use
202 Acceptedwith anoperation-locationheader. The status monitor is itself a resource with a stable id, so a client can resume after a crash.
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) | gRPC | GraphQL | |
|---|---|---|---|
| Contract | Convention plus optional OpenAPI | .proto file, generated clients | Typed schema, one endpoint |
| Best boundary | Public and partner APIs, browsers, anything cached by intermediaries | Service to service, polyglot internal calls, streaming | Many different clients (web, mobile) reading shaped data from many backends |
| Shapes of call | Request/response | Unary, server streaming, client streaming, bidirectional | Query, mutation, subscription |
| Caching | HTTP caches work on GET | Not by default | Needs object ids and client or persisted-query caches |
| Failure signal | HTTP status | Status codes 0 to 16, deadlines | Often 200 with an errors array |
| Typical trap | Chatty clients, inconsistent conventions | Hard to call from a browser without a proxy | Expensive 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.
- 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.
- Do you need streaming? Server push to a client usually means WebSocket or server-sent events at the edge, and gRPC streams inside.
- 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.
- 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.
| Job | Question | Where it lives |
|---|---|---|
| Authentication | Who is calling? | Edge: gateway or identity provider verifies a credential and passes a verified identity downstream |
| Authorisation | May this caller do this to this object? | The service that owns the object, close to the data |
| Quota and rate limit | How 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.
- Credentials in headers, never in URLs. URLs are logged by proxies and stored in browser history.
- Scopes or roles on the token for coarse checks (can this client write at all), and an object-level check in the service for fine ones.
- Tenant from the credential, not the request. If
tenant_idis a body field, a caller can set another tenant's. Derive it from the verified identity and use the path only for scoping within it. - Page tokens grant nothing. AIP-158 says tokens must not provide authorisation, so a cursor shared with another user must fail the same object checks.
- Quotas as part of the contract. Say what the limit is per key, return 429 with
Retry-After, and document it, as in Rate Limiting. Per-customer quotas protect other tenants; a global limit sized to a dependency protects the dependency. Writes that charge money may also get a stricter limit than reads.
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
- The interview answer lists one call per scoped requirement, with the read/write ratio marking the hot read, before any box is drawn
- Paths are nouns with a real ownership hierarchy; state transitions are custom actions, not a PATCH of a status field
- Updates are partial (PATCH with a field mask) and writes that can race carry a version or entity tag checked with If-Match
- Every list has a maximum page size, a cursor over a unique total sort, and an opaque token with a stated lifetime
- Every unsafe POST accepts a client-generated idempotency key, stored atomically with the effect and compared against the request body
- Changes are additive only; removals, renames, type changes, new required fields and default changes get a new major version and a deprecation period
- One error shape with a stable machine code, a status that names the retry class, and Retry-After on 429 and 503
- Every gRPC call sets a deadline and GraphQL has cost limits and a monitor that reads the errors array
- Authentication happens at the edge, authorisation per object in the owning service, and tenant comes from the credential
- Per-key quotas are paired with a global limit and load shedding, and are documented in the contract