System Design Prepgo pro
Study guide 11 of 16

API design

REST vs gRPC vs GraphQL, resource modelling, pagination (offset vs cursor), idempotency keys, versioning, error handling, authentication, and rate limiting in system design interviews.

Interviewers ask for the API early in the high-level design because it forces the data model and the operations into the open. They are not looking for a complete OpenAPI spec. They want the three or four core calls, the fields that matter, and evidence that you have thought about pagination, idempotency and errors, because those are where real APIs go wrong.

Protocol choice

REST over HTTP/JSON is the default for public and client-facing APIs: universally understood, cacheable by URL, easy to debug. Resources are nouns (/users/123/orders), methods are verbs (GET, POST, PUT, PATCH, DELETE), and status codes carry the outcome.

gRPC (HTTP/2, Protocol Buffers) for service-to-service calls inside the system: binary encoding is smaller and faster to parse, the schema is enforced, streaming is built in, and code generation gives typed clients. Browsers need a proxy (gRPC-Web), so it stays internal.

GraphQL when clients need flexible shapes of nested data and you want to avoid many round trips or a proliferation of bespoke endpoints, typically for a rich frontend over many backend services. Costs: caching is harder (one endpoint), query cost must be limited (depth and complexity limits) or a client can write a query that takes down the database, and N+1 fetching needs a dataloader layer.

WebSockets or SSE for server push. See real-time systems.

In an interview: "REST for the public API, gRPC between services" is the expected answer; add GraphQL only if the problem is a client with many views of the same graph.

Modelling the core calls

For each core operation give the method and path, the key request fields, the key response fields, and one line on auth or idempotency. Example for a chat system:

POST /v1/channels/{channel_id}/messages
  body: { client_msg_id: uuid, text, attachments[] }
  → 202 { message_id, ts }
  note: client_msg_id makes retries idempotent; message_id is time-ordered
GET /v1/channels/{channel_id}/messages?before={message_id}&limit=50
  → 200 { messages[], next_cursor }

Design ids deliberately. Expose opaque ids (not auto-increment integers, which leak volume and are enumerable). Time-ordered ids (Snowflake, ULID, UUIDv7) sort naturally and make cursor pagination trivial. See ids and time.

Pagination

Offset (?page=3&limit=50) is simple and lets users jump to a page, but OFFSET 100000 scans and discards 100 000 rows, and inserts between requests shift items so users see duplicates or skip some. Acceptable for small admin tables.

Cursor (keyset) pagination returns an opaque token encoding the last seen sort key (created_at, id), and the next query is WHERE (created_at, id) < (?, ?) ORDER BY created_at DESC, id DESC LIMIT 50. It uses the index, is constant time regardless of depth, and is stable under inserts. This is the answer for any feed, list, or history endpoint. The cursor should be opaque (base64 of the keys) so clients do not depend on its structure.

Idempotency

Any request that creates or changes something and might be retried needs an idempotency key: the client generates a UUID, sends it in a header (Idempotency-Key) or the body, and the server stores the key with the response for a period (Stripe keeps 24 hours). A retry with the same key returns the stored response instead of creating a second order or charging twice. The store must be checked and written atomically (a unique index, or SET NX in Redis) to handle two concurrent retries. Say this for every POST that spends money or creates a user-visible thing.

PUT and DELETE are idempotent by definition (set to this state; remove); prefer them over POST where the client can name the resource.

Errors

Use status codes the way clients expect: 400 for bad input, 401 unauthenticated, 403 unauthorised, 404 missing, 409 conflict (state machine violation, duplicate), 422 semantic validation, 429 rate limited (with Retry-After), 500 server fault, 503 overloaded (with Retry-After). Return a body with a machine-readable code and a human message: { error: { code: "insufficient_funds", message: "..." } }. Clients retry 429, 503 and network errors with backoff; they never retry 4xx.

Versioning

Put the major version in the path (/v1/) and keep it stable for years. Add fields freely (additive changes are non-breaking); never remove or rename without a new version. Internally, protobuf with field numbers gives the same property. Sunset old versions with headers and a deprecation period, and track which clients still call them.

Authentication and authorisation

Public APIs: OAuth 2 bearer tokens (JWTs signed by an identity service) verified at the API gateway; the gateway forwards the user id in a header to services, which never re-verify. Service-to-service: mTLS via the mesh, or short-lived signed tokens. Authorisation (can this user see this channel) is a business rule in the service, checked on every request, not at the gateway.

Rate limiting and quotas

Per user or per API key, enforced at the gateway with a token bucket in Redis, returning 429 with Retry-After and X-RateLimit-Remaining. Differentiate limits by endpoint cost (a search is more expensive than a read). See rate limiting and resilience.

Other things interviewers notice

  • Batch endpoints (POST /messages:batchGet) to avoid N round trips from mobile clients.
  • Field selection or sparse responses for bandwidth-sensitive clients.
  • Long-running operations return 202 Accepted with an operation id to poll, not a 30-second hanging request.
  • Timeouts and deadlines propagate through internal calls so a slow dependency does not hold a thread forever.
  • Backward-compatible defaults: new fields optional, new enum values tolerated by clients.

Two or three of these mentioned in passing separate a senior answer from a mid-level one.