Skip to main content
API Design Patterns

Why API Design Matters

APIs are contracts between services — and like legal contracts, they are easy to sign and very hard to change once other parties depend on them. A poorly designed API becomes a long-term liability: every client that integrates with it creates an implicit dependency on its quirks, making breaking changes extraordinarily expensive. Stripe, widely regarded as having one of the best APIs in the industry, attributes much of their developer adoption to thoughtful API design rather than superior payment technology. Good API design leads to:
  • Developer Experience - Easy to use and understand (a developer should be able to guess the endpoint without reading docs)
  • Maintainability - Evolve without breaking clients (backward compatibility is a feature, not a constraint)
  • Performance - Efficient data transfer (returning only what clients need, not everything you have)
  • Security - Protected resources (authentication, authorization, and rate limiting are first-class citizens, not afterthoughts)

REST API Design

REST (Representational State Transfer) treats everything as a resource with a URL, and uses HTTP verbs to perform operations on those resources. The analogy: think of your API as a library catalog system. Each book (resource) has a unique catalog number (URL). You can look up a book (GET), add a new book to the collection (POST), replace a book’s catalog entry entirely (PUT), update just the book’s shelf location (PATCH), or remove a book from the catalog (DELETE). The catalog number does not describe an action — it identifies a thing. This is the fundamental shift from RPC-style APIs (where URLs look like /getUser or /createOrder) to REST (where URLs look like /users/123 or /orders).

Resource-Based URLs

Query Parameters

Response Structure

Response JSON Examples

Pagination Strategies

Pagination is how you prevent a single API call from returning 10 million rows and crashing both your server and the client. The choice between offset-based and cursor-based pagination is a scalability decision disguised as a UI decision. Offset pagination (LIMIT 20 OFFSET 10000) requires the database to scan and skip 10,000 rows before returning the 20 you want — at page 500 of results, the query is doing 99.6% wasted work. Cursor pagination (WHERE id > last_seen_id LIMIT 20) uses an index seek and is equally fast regardless of how deep into the results you are. For any dataset that might exceed a few thousand items, cursor pagination is the correct default.

GraphQL

GraphQL solves a real problem that REST creates at scale: when you have dozens of clients (web, iOS, Android, third-party partners) each needing different subsets of the same data, REST endpoints either over-fetch (returning fields the client does not need, wasting bandwidth) or under-fetch (requiring multiple round trips). GraphQL lets the client specify exactly what it needs in a single request. The trade-off is real though: you move complexity from the client to the server. The server must now handle arbitrary query shapes, which opens the door to performance problems (deeply nested queries can trigger expensive joins) and security concerns (malicious queries that request the entire graph). This is why most teams that adopt GraphQL also implement query depth limiting, query cost analysis, and persisted queries.

GraphQL vs REST

GraphQL Schema Example

GraphQL Resolver Implementation

GraphQL Trade-offs

GraphQL Pros

  • Fetch exactly what you need
  • Single endpoint
  • Strong typing
  • Self-documenting (introspection)
  • Great for complex, nested data
  • Reduces over/under-fetching

GraphQL Cons

  • Caching is harder (no HTTP cache)
  • N+1 query problem
  • Rate limiting complexity
  • File uploads are awkward
  • Learning curve
  • Performance monitoring harder

N+1 Problem & DataLoader

API Versioning

API versioning is how you evolve your API without breaking existing clients. Think of it like road construction: you cannot tear up the old highway until the new bypass is built and all the traffic has migrated. The golden rule: once a field or endpoint is published, removing or changing its behavior is a breaking change. Adding new fields, new endpoints, or new optional parameters is safe. This distinction matters because in practice, most “version bumps” happen because someone needs to rename a field or change a response structure, which could have been avoided by making the API additive from the start.
Scalability Insight: API versioning costs grow linearly with the number of clients. Stripe maintains backward compatibility for years and uses API version headers (Stripe-Version) rather than URL versioning, allowing them to serve dozens of API versions simultaneously from the same codebase using version-specific transformers. At their scale (millions of API integrations), a breaking change — even with 6 months notice — would still break thousands of integrations. The lesson: design for backward compatibility from day one, and treat “additive-only changes” as a hard rule rather than a guideline.

Versioning Strategies

Version Migration Strategy

Rate Limiting

Rate limiting protects your system from being overwhelmed, whether by a misbehaving client, a DDoS attack, or a legitimate traffic spike. Think of it like a bouncer at a club: there is a maximum occupancy, and once you hit it, new arrivals wait in line regardless of who they are. In practice, most systems implement tiered rate limits — free users get 100 requests/minute, paid users get 1,000, and internal services get 10,000. The most common mistake engineers make is implementing rate limiting per server instead of globally (using Redis or a similar shared store), which means a client can simply spray requests across all your servers and bypass per-server limits.

Common Algorithms

Rate Limit Headers

Distributed Rate Limiting

Rate Limiting Implementation

Authentication & Authorization

Auth Patterns

JWT Structure

Idempotency

Why Idempotency Matters

Idempotency Key Pattern

Idempotency Implementation

API Documentation

OpenAPI/Swagger Example

Best Practices Summary

Interview Strategy: When designing an API in an interview, follow this order: (1) Identify resources and their relationships, (2) Define endpoints using REST conventions, (3) Discuss authentication and authorization, (4) Address rate limiting and abuse prevention, (5) Design error responses with machine-readable codes, (6) Plan for versioning from day one. The power move: proactively mention idempotency for mutation endpoints before the interviewer asks. Saying “All POST endpoints will require an Idempotency-Key header to prevent duplicate operations during network retries” signals production experience that most candidates lack.Scalability Considerations: At 1K QPS, a single API server with a database is fine. At 10K QPS, you need connection pooling, read replicas, and response caching (Cache-Control headers for GET endpoints). At 100K QPS, you need a CDN for cacheable responses, cursor-based pagination (offset pagination falls apart when the underlying table has millions of rows), and rate limiting becomes critical to protect downstream services. At 1M+ QPS, you are looking at GraphQL or field selection to reduce payload sizes, edge computing for authentication, and API gateway patterns to fan out to microservices.

Interview Deep-Dive

Strong Answer:Idempotency is the single most important property for any API that mutates state, especially for payments where a duplicate means real money lost.
  • Require an Idempotency-Key header on all POST/PUT/PATCH requests. The client generates a UUID and sends it with the request. The server stores a mapping of (idempotency_key, response) with a TTL of 24-48 hours.
  • On receiving a request: Hash the idempotency key, check if it exists in your store (Redis for speed, backed by Postgres for durability). If it exists and the original request completed, return the stored response verbatim — same status code, same body. If it exists but is still in-flight, return 409 Conflict. If it does not exist, process normally and store the result.
  • The subtle gotcha: The idempotency key must be scoped to the API key/merchant, not globally. Otherwise two different merchants could accidentally collide on UUIDs. Store as (api_key, idempotency_key) -> response.
Back-of-envelope for the idempotency store: If you process 10K payment requests/sec and store idempotency records for 48 hours, that is 10K * 86,400 * 2 = ~1.7 billion records. At ~500 bytes per record, that is ~850 GB. Fits in a Redis cluster with TTL expiration.Follow-up: A client sends the same idempotency key but with a different request body (different amount). What do you do?Return 422 Unprocessable Entity with a clear error: “Idempotency key already used with different request parameters.” You must never silently process a different request under the same key. Compare a hash of the request body against the stored hash. The client needs to generate a new key for a genuinely different request.
Strong Answer:This is the classic tension that GraphQL was designed to solve, but REST-based approaches handle it well without the operational complexity.
  • Option 1: Field selection (sparse fieldsets). Add a fields query parameter: GET /orders/123?fields=id,status,total. Mobile requests minimal fields, dashboards request everything. Trivial to implement.
  • Option 2: Compound documents / includes. Add an include parameter: GET /orders/123?include=items,customer,shipping embeds related resources inline. The dashboard gets one call with nested data, mobile skips includes. This is what Stripe and Shopify do.
  • Option 3: Backend-for-Frontend (BFF). A thin aggregation layer per client type. Adds a service but gives full control per client.
My recommendation: Start with field selection + includes on REST. It covers 90% of cases. Move to GraphQL only if you have many distinct client types with wildly different data needs.Back-of-envelope impact: If your average order response is 5KB but mobile only needs 500 bytes, field selection reduces bandwidth by 90%. At 100K mobile requests/sec, that saves 450 MB/sec = 3.6 Gbps. At 0.09/GBforcloudegress,roughly0.09/GB for cloud egress, roughly 3,500/day in bandwidth savings.Follow-up: A client requests deeply nested includes like GET /orders?include=items.product.reviews. How do you prevent this from destroying your database?Set a maximum include depth (typically 2 levels) and a maximum included resource count per response (say, 100 items). For the database, use DataLoader-style batching: collect all product IDs across all items and do a single WHERE id IN (...) query instead of N+1 queries. Monitor query count per API request — if any endpoint exceeds 10 queries, it needs optimization. Rate limit heavy include patterns separately from lightweight requests.
Strong Answer:API versioning for enterprise customers is a contract negotiation, not a technical exercise.
  • Step 1: Introduce v2 alongside v1. Both run simultaneously. I prefer URL-path versioning (/v2/users) over header versioning for enterprise APIs because it is more visible in logs, documentation, and support tickets.
  • Step 2: Set a sunset timeline. Announce v1 deprecation 12 months out. Add Sunset and Deprecation headers to all v1 responses. Add Link header pointing to migration docs.
  • Step 3: Build a compatibility shim. Internally, v1 and v2 hit the same business logic. The v1 controller transforms the response to match the old contract. This avoids maintaining two codepaths.
  • Step 4: Monitor adoption. Track v1 vs v2 request percentage per customer. Proactively reach out at the 6-month mark.
The mistake most teams make: They try to avoid versioning by returning both old and new field names simultaneously. This works short-term but creates permanent API bloat — after 5 such changes, your response has 10 deprecated fields nobody can remove.Follow-up: 3 of your 500 enterprise customers generate 80% of revenue and refuse to migrate off v1. What do you do?Business reality overrides technical elegance. Keep v1 alive with the compatibility shim. Negotiate migration tied to contract renewal — “v1 support included through your current contract, v2 in the renewal.” The shim costs almost nothing to maintain if it is just a response transformer. That is sustainable for years.
Strong Answer:Baseline traffic: 50M DAU, average 60 API calls/day per user = 3 billion requests/day. Average QPS: 3B / 86,400 = ~35,000 QPS. Peak (3x): ~100,000 QPS.Rate limit tiers:
  • Per-user: 100 requests/minute (catches runaway scripts, generous for normal use).
  • Per-IP: 500 requests/minute (higher because multiple users share IPs behind NATs).
  • Per-endpoint: Timeline read: 30/min. Post creation: 10/min. Search: 20/min. Media upload: 5/min.
  • Global: No hard limit, but adaptive load shedding when total QPS exceeds 80% capacity.
Algorithm choice: Token bucket for per-user limits (allows brief bursts matching real scrolling behavior). Sliding window for per-IP limits. Redis with MULTI/EXEC for atomic operations — one instance handles 100K+ checks/sec.The 10x viral spike: Timeline reads spike from 100K to 1M QPS.
  1. CDN absorbs most read traffic (cache-hit ratio jumps from 70% to 95% because everyone reads the same viral content).
  2. Per-user rate limits stay unchanged — more users, not more requests per user.
  3. Global load shedding kicks in: deprioritize non-essential endpoints, shed background sync traffic.
  4. Auto-scale timeline service horizontally.
Follow-up: How do you handle rate limiting across 20 API servers at 1M QPS without bottlenecking on centralized Redis?Two-tier approach. Tier 1: each server maintains a local in-memory approximate counter updated from Redis every 1 second. Handles 95% of checks with zero network overhead. Tier 2: synchronous Redis check only for users near their limit. The local counter is up to 1 second stale, so a user might get 101 requests through instead of 100 — acceptable for rate limiting. At 1M QPS across 20 servers, each handles 50K locally with only ~2,500 Redis checks/sec for borderline cases.