System Design: API Design Best Practices — REST, Versioning, Pagination, Rate Limiting, and GraphQL

RESTful Resource Modeling

Resources are nouns, not verbs. POST /orders (create), GET /orders/{id} (read), PUT /orders/{id} (replace), PATCH /orders/{id} (partial update), DELETE /orders/{id}. Nest resources only one level deep: GET /orders/{id}/items (items under an order). Avoid: GET /getOrder, POST /createUser. Status codes: 200 OK, 201 Created (POST), 204 No Content (DELETE), 400 Bad Request (validation error), 401 Unauthorized, 403 Forbidden, 404 Not Found, 409 Conflict (duplicate), 422 Unprocessable Entity, 429 Too Many Requests, 500 Internal Server Error. Use plural nouns consistently: /users, /orders, /products.

Versioning

API versioning strategies: (1) URL path: /api/v1/users, /api/v2/users. Simple, explicit, easy to route. Most common. (2) Header: Accept: application/vnd.api+json;version=2. Cleaner URLs but harder to test in browsers. (3) Query parameter: GET /users?version=2. Easy to add but pollutes query strings. Recommendation: use URL versioning for public APIs. Increment major version on breaking changes (removed fields, changed types, new required parameters). Minor changes (added optional fields, new endpoints) are backward-compatible — no version bump. Maintain at least two major versions simultaneously; deprecate the old with a Sunset header and a 6-12 month timeline.

Pagination

Offset-based: GET /orders?offset=100&limit=20. Simple but inconsistent with concurrent inserts/deletes (item can be skipped or duplicated). Cursor-based: GET /orders?cursor=eyJpZCI6MTAwfQ&limit=20. The cursor encodes the position (e.g., base64-encoded last_id). Consistent under concurrent modifications. Used by Twitter, Facebook, Stripe. Keyset pagination: GET /orders?after_id=100&limit=20 — simpler cursor using a natural key. For large datasets, always prefer cursor-based. Response envelope: {data: […], next_cursor: “…”, has_more: true}. Return total count only when cheap (avoid COUNT(*) on large tables — use approximate counts from statistics).

Rate Limiting

Algorithms: (1) Token bucket: bucket holds N tokens, refilled at R tokens/second. Each request consumes one token. Allows bursts up to N. Implemented with Redis: DECR token_count per request, periodic refill job. (2) Fixed window: allow N requests per minute window. Simple but allows 2N requests at window boundaries. (3) Sliding window log: log all request timestamps, count requests in the last 60 seconds. Accurate but memory-intensive. (4) Sliding window counter: hybrid — combine fixed window counts with a weighted proportion of the previous window. Good balance. Return 429 with Retry-After header. Headers: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset. Tier rate limits by API key (free: 100/min, pro: 1000/min, enterprise: custom).

Request and Response Design

Request validation: validate types, required fields, string lengths, enum values at the API gateway or controller layer. Return 400 with structured error: {error: “VALIDATION_FAILED”, details: [{field: “email”, message: “Invalid format”}]}. Never expose stack traces in production responses. Idempotency: support Idempotency-Key header for POST requests that create resources. Store (key, response) and return the cached response on duplicate requests. Response envelope: {data: {…}, meta: {request_id: “…”}}. Always include a request_id for distributed tracing. Use consistent timestamp format: ISO 8601 (2024-01-15T10:30:00Z). Use cents for money (never floats).

GraphQL vs REST

REST: resource-oriented, multiple endpoints, over-fetching (fixed response shape), under-fetching (multiple requests for related data). Simple, cacheable (GET is cacheable by URL), easy to document with OpenAPI. GraphQL: single endpoint, client specifies exact fields needed (no over-fetching), fetch related data in one query. Complex query validation, harder to cache (POST queries), requires query depth limiting to prevent expensive queries. Use GraphQL when: clients have diverse data needs (mobile vs web vs partner), frequent UI changes require different data shapes, avoiding multiple round trips matters. Use REST when: simplicity, cacheability, and tooling maturity matter most. Most public APIs use REST; internal/product APIs often benefit from GraphQL.

Interview Tips

  • Always discuss authentication (OAuth 2.0, API keys, JWT) and authorization (scopes, RBAC) for the API.
  • Idempotency keys for write operations prevent double-charges and duplicate records on retry.
  • Discuss backwards compatibility — adding optional fields is safe; removing or renaming fields is breaking.
  • OpenAPI/Swagger spec as a contract enables auto-generated SDKs and mock servers.

Asked at: Stripe Interview Guide

Asked at: Cloudflare Interview Guide

Asked at: Atlassian Interview Guide

Asked at: Shopify Interview Guide

Scroll to Top