Summary: Developers get taught how to build APIs — "here's your route, spit out some JSON, done" — but almost never how to design them: what happens when someone depends on this API for years, what breaks when a network request dies, how you evolve without breaking every client. These are the patterns senior engineers learn the hard way and bootcamps ignore.
What makes an API well-designed
Four properties, and every pattern below maps back to at least one:
- Predictable — consistent naming and behavior.
GET /users/{id}andGET /orders/{orderId}mixingidandorderIdmakes clients guess. - Backward-compatible — old clients keep working no matter what changes. Break a client in production once and trust evaporates.
- Resilient — handles partial failures gracefully. Behavior during failure matters as much as behavior when things work.
- Self-documenting — a dev understands the endpoint without opening a PDF. Good naming, obvious patterns, smart error messages.
Pattern 1 — API Versioning
Tutorials say "stick /v1/ in your URL, bump to /v2/ for breaking changes." Reality offers three strategies and a price for choosing wrong:
- URL versioning (
/v1/users) — obvious, easy to route and cache, but clutters URLs and forces whole-API versioning. - Header versioning (
Accept: application/vnd.api+json;version=2) — tidy URLs, more "RESTful," but you can't paste a URL in a browser to test, and debugging is messier. - Query-param versioning (
/users?version=2) — nobody recommends it; weird and hard to maintain.
Stripe's two-tier approach is the one nobody talks about: the URL is /v1/charges (major version), but every API key is pinned to a date-based version like 2023-10-16. Make a change, the date ticks forward; existing keys stay on the old version until you upgrade. Zero breaking changes for existing clients, ever.
The real headache is supporting versions in parallel without insanity. The clean way: an API Gateway routes /v1/* and /v2/* to thin adapters that tweak requests/responses per version while core business logic stays version-agnostic — don't fork the whole codebase. And deprecate cleanly: announce with a timeline (minimum six months for public APIs), add Sunset/Deprecation headers to every old-version response, email affected developers, then actually turn it off.
Pattern 2 — Idempotency
Tutorials say nothing. In production: a user clicks "Pay," the payment goes through, the network times out before the response lands, the user retries, and now they're charged twice. At 100,000 payments/day with 0.1% network hiccups, that's ~100 daily double-charges — a lawsuit, not a bug.
Idempotency means running the same request twice has the same effect as running it once. GET, DELETE, and PUT-with-full-body are naturally idempotent; POST /payments is not. The gold standard is Stripe's Idempotency-Key header: the client generates a unique ID per payment; the server stores ID + response (e.g. in Redis with a 24h TTL); if the same ID arrives again, return the stored response instead of reprocessing.
def handle_payment(request_body: dict, idempotency_key: str):
cache_key = f"idempotency:{idempotency_key}"
cached = r.get(cache_key)
if cached:
return json.loads(cached) # same response, no double charge
result = process_payment(request_body)
r.setex(cache_key, 86400, json.dumps(result)) # store 24h
return result
Any POST that creates something or triggers side effects needs server-side deduplication.
Pattern 3 — Pagination
Tutorials say "use LIMIT and OFFSET." But LIMIT 20 OFFSET 10000 scans 10,020 rows and discards 10,000 — and worse, offset can't handle live data: as items are inserted or deleted between page fetches, users see duplicates or gaps.
Cursor-based pagination fixes it: instead of "rows 10,000–10,020," you say "20 rows after this item." The cursor is an opaque token (often a base64-encoded id/timestamp). Fetch one extra row beyond the limit to compute has_more without an expensive COUNT.
def get_users(cursor=None, limit=20):
query = db.query(User).order_by(User.id)
if cursor:
query = query.filter(User.id > decode_cursor(cursor))
users = query.limit(limit + 1).all()
has_more = len(users) > limit
users = users[:limit]
return {"data": [u.to_dict() for u in users],
"next_cursor": encode_cursor(users[-1].id) if has_more else None,
"has_more": has_more}
Keyset pagination (WHERE id > last_id ORDER BY id LIMIT 20) is the best variant — it rides the primary-key index, so performance stays flat no matter how deep you page. Design the response with next_cursor and has_more from the start.
Pattern 4 — Error Design
Tutorials say "400 for bad input, 500 for server errors." But {"error": "bad request"} tells the client nothing — which field, what to do, is it retryable — so they file a ticket and your team burns hours. Errors are part of your contract.
Use status codes correctly, and mind the splits:
- 400 malformed syntax · 422 valid syntax but business-rule validation failed
- 401 "I don't know you" · 403 "I know you, you're not allowed" (don't leak info by swapping them)
- 404 missing · 409 conflict with current state · 429 rate-limited · 500 crash · 503 downstream down
Adopt RFC 7807 Problem Details (used by Google, Stripe): every error carries type (docs link), title, status, detail, instance (the failing URL), request_id (log-correlation ID — your lifeline at 2am), and a machine-readable error_code. Never expose stack traces in production.
Pattern 5 — Rate Limiting & Throttling
Tutorials treat it as an ops concern. But a bare 429 with no retry guidance makes clients hammer harder or open tickets. Communicate the limit. Return on every response:
X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-ResetRetry-After(only at zero — how many seconds to wait)
So well-behaved clients can back off before hitting the wall. Algorithms: token bucket (allows bursts, enforces average — good default), leaky bucket (fixed rate, no bursts — good for expensive services), sliding window (counts the last N seconds, avoids reset-edge spikes). Set tiered limits by key and endpoint (free vs paid; /search stricter than /users/{id}). For non-critical endpoints, degrade gracefully — return partial or cached data with a Warning header instead of a hard 429.
Pattern 6 — Backward Compatibility & Breaking Changes
Most teams can't even agree on what a breaking change is. Write it down:
- Breaking: removing or renaming a response field; changing a field's type; switching an endpoint's HTTP method; adding a required request field; changing relied-upon status or error codes.
- Non-breaking: adding an optional response field, a new endpoint, optional query params, or optional request fields.
- The tricky one — adding enum values: harmless on the surface, but a client with a strict
switch/if-elseover the enum panics on an unexpected case. Stripe explicitly calls this out.
Roll out field changes with Expand–Contract: Expand (add username while keeping user_name, flag the old one deprecated) → Migrate (give consumers weeks internally, months publicly, and track who still uses the old field) → Contract (drop the old field once everyone's moved). Never skip the migrate phase — it exists to protect clients.
Pattern 7 — Contract-First Design (OpenAPI / AsyncAPI)
Code-first-then-docs leads to drift, blocked frontends, and edge cases caught too late. Contract-first flips it: write the OpenAPI spec up front, get everyone to agree, then frontend (against a mock server) and backend (against the spec) work in parallel. Use $ref to define schemas once and reuse them. From the spec you can auto-generate client SDKs, spin up mock servers, and run contract tests (Dredd, Pact) that verify the implementation hasn't drifted from the contract.
Bonus patterns
- HATEOAS — embed links to related actions in responses (GitHub does this well).
- Partial responses / field masks —
GET /users/123?fields=id,name, crucial for mobile bandwidth. - Bulk operations —
POST /users/batchwith per-item success/failure instead of 1,000 calls. - Long-running operations — respond
202 Accepted+ job ID, client polls for status. - Health-check endpoints —
/health,/ready,/metrics; fast, public, vital for on-call. - Date-based versioning — often clearer than SemVer when client upgrades are out of your control.
The difference between senior and junior API design isn't flashy tech — it's thinking through every edge case: retries, failures, future changes, frugal mobile clients. Look at Stripe, GitHub, and Twilio; the lessons are everywhere if you dig.
Related
- Best Practices for Better RESTful API — the foundational REST standards document (URI format, naming, status codes, filtering, projection) that these advanced patterns build on top of; both converge on cursor pagination, RFC-style error bodies, and clean versioning.
- Async APIs - don't confuse your events, commands and state — AsyncAPI is the event-driven counterpart to contract-first OpenAPI design.
- 10 Things You Should Avoid in Your ASP.NET Core Controllers — framework-level anti-patterns that violate predictability, resilience, and error-contract discipline.
- Designing a URL Shortener That Works Like Infrastructure — idempotency, rate-limited fallbacks, and graceful error behavior at global infrastructure scale.
- What Senior Architects Know About SAGA That Juniors Don't — idempotency and retry discipline carried into multi-service transactions.
- Design-First Collaboration — Pattern 7 (contract-first / OpenAPI) is the API-spec expression of the "agree the design before writing code" discipline.