Skip to content
intermediate Phase 10 · API Architecture and Contracts

REST Design and Versioning

Model resources, status codes, and version strategies that age gracefully.

55m
0 problems
Topic Progress 0%

Nouns in the Path, Verbs in the Method: Modeling Orders That Survive Retries

A partner team integrates your orders API and ships code against POST /createOrder, POST /cancelOrder, and GET /getOrders?userId=123. Later you add batch cancellation, and every call site must change, because the URL encoded the action instead of the thing being acted on. REST's constraint is cheap to state and worth defending in design review: the path names the resource and the HTTP method names the operation. You expose /orders, /orders/42, /orders/42/cancellation — the verbs live in GET, POST, PUT, PATCH, and DELETE.

This is more than housekeeping, because everything between your server and the client branches on the method. A GET is safe — the HTTP spec guarantees it has no side effects — so proxies may cache it and any layer may retry it without fear. PUT /orders/42 is idempotent: applying it twice leaves the same state as applying it once, so a client that times out after the server already committed can resend freely. DELETE is idempotent too: a second delete of a gone resource answers 404 and changes nothing. POST is neither, which is exactly why a blind retry can create two orders.

Method Safe Idempotent Typical use Retry after a timeout
GET yes yes read a representation always
HEAD yes yes probe size and headers always
PUT no yes replace with client-chosen id always
PATCH no no partial update only with an idempotency key
POST no no create or trigger an action only with an idempotency key
DELETE no yes remove always

Status codes carry the other half of the contract: 201 plus a Location header when a resource is created, 204 for a delete with no body, 409 when the request conflicts with current state (the order already shipped), 422 for semantically invalid input, 429 when the caller exceeds quota, 500 for your own bug. The failure mode interviewers love to catch is 200 OK with an error payload in the body: clients drive retries, metrics, and alerting off status codes, so a 200-with-error poisons all three — and an HTTP cache may store the error where the resource belongs.

When the operation is inherently non-idempotent, the standard rescue is an idempotency key: the client generates a UUID, sends it as Idempotency-Key, and the server records the first response under that key, replaying it for duplicates instead of executing the side effect twice. Stripe built its public API around this; AWS calls the same idea a client token. It gives POST retry-safety without pretending POST became idempotent, and it belongs in the published contract rather than in tribal knowledge.

Nesting deserves restraint as well: /orders/42/items/9 reads clearly, but every extra segment multiplies routing rules, documentation, and cache keys. Prefer one level of hierarchy plus query parameters (GET /orders/42?expand=items) whenever a child never exists without its parent.

Interviewers probing this topic rarely ask what PUT means. They ask which layer can safely repeat a request and how you know — answer with the safe/idempotent table, name the intermediaries (CDN, gateway, client SDK retry logic), and you have shown the reasoning behind the surface rules. Ground this against Event Sourcing, then write the contract down in Contract-First with OpenAPI.

Where Does the Version Live: URI Segment, Query, Header, or Media Type?

You have forty clients on v1 and one field whose meaning must change. Additive change covers most needs, but when the shape itself has to break, the question is where the version marker goes — and each placement trades visibility against cleanliness.

GET /v2/orders                                  URI path segment
GET /orders?version=2                           query parameter
GET /orders   X-API-Version: 2                  custom request header
GET /orders   Accept: application/vnd.acme.orders-v2+json   media type
Placement Caching and CDN Visibility for support Hand-testable The price you pay
URI path ideal — the URL is the cache key obvious in every log line and link yes, paste into a browser version leaks into SDKs, docs, and bookmarks
Query param good, unless a cache ignores query strings obvious in URLs and logs yes dropped by careless proxies; muddies analytics
Custom header needs Vary or gateway normalization hidden unless you read headers curl flags required clients forget to send it; caches serve the wrong version
Media type needs Vary: Accept sits in headers curl flags required verbose; weak tooling in SDK generators and docs portals

The trade-off resolves differently depending on your constraints. URI versioning is the pragmatic default because caching, routing, logging, and documentation all treat the path as the unit of identity: a gateway routes /v2/* to the new deployment, a CDN keeps /v1/orders and /v2/orders in separate entries, and a support engineer sees the version in a stack trace without knowing which headers the application set.

Header-based versioning keeps one canonical URL and lets a single resource carry several representations, which is theoretically the cleanest answer. But you must emit Vary: X-API-Version (or negotiate through Accept) on every cacheable response; skip that and a shared cache will serve a v1 body to a v2 caller — a corruption bug that looks nothing like a versioning problem while you debug it. Media types are the textbook-correct form of the same idea and inherit the same weakness: most SDK generators, browser tooling, and edge caches handle custom media types badly.

Whichever marker you pick, the deployment rule stays the same: one serving path per version, backed by the same services until the old path retires. Standardize the sunset mechanics instead of announcing them in a blog post — send Deprecation: true and a Sunset header carrying an HTTP-date (RFC 8594) on old-version responses, link the migration guide, and alarm on per-version traffic so you know when usage actually reaches zero. A version you cannot measure is a version you cannot delete.

Two more rules keep v1 alive during the window: never repurpose an existing field for new semantics — add a field instead — and keep the error envelope stable, because clients parse errors on the unhappy path where regressions surface first. In review, ask of any proposed change: would a v1 client written strictly against today's documentation still work?

Real-world precedents are worth knowing because interviewers use them as calibration. Stripe versions by date — /v1 never changes shape and new behavior arrives under date-stamped versions selected per account — AWS versions at the protocol layer rather than in the URL, and most enterprise APIs take the URI route. The common thread: the decision is made once, centrally, and never varied per endpoint. Mixed strategies are the trap. Version in the path for one resource and a header for another, and every SDK, docs page, gateway rule, and cache now has to implement both. If you ship a thick client SDK, tie the marker to the SDK major version so consumers upgrade with one dependency bump instead of editing every call site.

Expand-Contract: Retiring a Field Without a Maintenance Window

A breaking change is any change that forces a client to edit code: removing a field, renaming one, changing a type, narrowing an enum, flipping 404 into 422, or reshaping the error object. Adding a field is the canonical safe change — with one caveat you should state unprompted: an additive change is backwards-compatible for tolerant readers, and some readers are strict. A generated client with unknown-field rejection, a Go decoder calling DisallowUnknownFields, or a validation proxy in the request path may reject the very field you just added. Safety is a property of the client population, not of the server alone.

The pattern that ships breaking changes without a maintenance window is expand-contract, also called parallel change, in four moves:

  1. Expand — emit the new shape alongside the old. The server writes both and reads the old one as a fallback.
  2. Migrate — release clients that consume the new shape; dual-read covers old data still in storage.
  3. Observe — per-version telemetry proves old-field usage is zero, or under the agreed threshold.
  4. Contract — stop writing the old field, then remove it in the next announced version.

Concretely, replacing a flat customer_name with a structured name while one deployment serves both:

# same deployment, same URL, during the deprecation window
customer_name: "Ada Lovelace"   # deprecated 2026-02-01, read fallback only
customer:
  first: "Ada"
  last: "Lovelace"              # written from day one

Read path: if customer is absent, derive it from customer_name. Write path: always populate both. Old and new clients hit the same URL and the same database — no big-bang cutover and no second long-lived codebase, just a window where the response is deliberately redundant.

Parallel versions are not free. Every overlapping major multiplies your test matrix, your documentation, your gateway routes, and the support question about which version a caller is on. Contain that cost with explicit policy: at most two majors served at once, a published 90-day deprecation window, sunset dates recorded in the spec from the day the new version launches, and removal gated by metrics rather than by calendar hope.

Watch the second-order breaks as well. Suddenly requiring an Idempotency-Key on POST breaks callers that never send one, so introduce it as optional first. New Retry-After behavior on 429 is additive; deleting an old pagination cursor format is not. If the error envelope changes in v2, version it with everything else instead of quietly fixing it across versions.

This discipline also applies to data that outlives the request: stored events cannot be rewritten, so their schemas evolve additively in exactly the same style — Event Sourcing covers that side. Once changes are staged this carefully, encoding them in a machine-readable contract is where the payoff lands, and that is Contract-First with OpenAPI.

A practical rollout checklist keeps the window boring: canary the new shape behind a path filter and compare error rates against the old one; render the deprecation banner in the docs portal straight from the spec metadata; add a CI check that fails if the v1 contract changes at all while the window is open, so v1 is frozen rather than merely supported; alert when v1 traffic crosses the agreed threshold instead of waiting on the calendar; and rehearse the removal in staging with old clients simulated before the public sunset date. The freeze check matters most — the usual accident is a well-meaning compatible tweak to v1 during the window that breaks a client nobody retested.

Expect a follow-up about who does this work. The owning team writes the new shape, the client teams migrate on their own schedules, and one person tracks the dashboard of per-version traffic. Versioning is cheap when it is a habit and ruinously expensive when it is a rescue project, which is the honest answer to give when an interviewer asks how many versions you have run and how you decided to kill one.

Quiz

1. Which statement about retrying HTTP requests is accurate?

Question 1 options

2. Your API versions with `X-API-Version: 2` and every response is cacheable, but none of them sets `Vary`. What can a shared cache do?

Question 2 options

3. A client generated from your v1 schema starts rejecting responses right after you ship an additive change. Which change did you make?

Question 3 options

4. During the expand phase of an expand-contract rollout, what must the server do?

Question 4 options

Flashcards

Question

Which HTTP methods are safe? Which are idempotent?

Answer

Safe: GET and HEAD — no side effects, so any intermediary may repeat them. Idempotent: GET, HEAD, PUT, DELETE — repeating yields the same end state as one call (a repeated DELETE simply answers 404). POST and most PATCH calls are neither, which is why they need idempotency keys before anyone retries them.

Question

What is an idempotency key, and when does an API need one?

Answer

A client-supplied unique token (header such as `Idempotency-Key`) under which the server records the first response and replays it for duplicate requests instead of re-executing the side effect. It is required whenever callers must retry a non-idempotent operation — order creation, payment capture — across an unreliable network, and it belongs in the published contract.

Question

Compare URI-path and header versioning in one line each.

Answer

URI (`/v2/orders`): the version is the cache key, visible in logs, links, and gateway routes — but it leaks into every SDK, doc, and bookmark. Header (`X-API-Version: 2`): one canonical URL and a cleaner contract — but only if every cacheable response sets `Vary`, or shared caches will answer one version's request with another version's body.

Question

What makes a REST schema change breaking, and what is the caveat on additive changes?

Answer

Breaking: removing or renaming a field, changing its type or enum range, altering status codes, or reshaping the error envelope — anything that forces client code changes. Adding a field is backwards-compatible only for tolerant readers; strict decoders (some generated SDKs, validation proxies) reject unknown fields, so verify the client population before calling it safe.

Question

List the four phases of an expand-contract rollout.

Answer

1) Expand: emit the new shape alongside the old and read the old as a fallback. 2) Migrate: release clients consuming the new shape while the server dual-reads. 3) Observe: per-version telemetry proves old-field usage hit zero. 4) Contract: stop writing the old field, then remove it in the next published version after the announced sunset date.

Revision Notes

Key Takeaways

  • 1. Paths hold nouns and methods hold verbs: GET/HEAD are safe, PUT/DELETE idempotent, POST neither — that table is the entire reason proxy and SDK retries are harmless for some methods and dangerous for others
  • 2. Never answer 200 with an error payload; client retries, metric dimensions, and HTTP caches all branch on status codes
  • 3. Version placement trades visibility against cleanliness: the URI is cache- and log-friendly, while header versioning mandates `Vary` on every cacheable response or caches will cross-serve versions
  • 4. Additive changes are safe only for tolerant readers — strict decoders fail on your new field, so validate against the real client population
  • 5. Expand-contract ships breaking changes on a single deployment; removal is gated by metrics, a published sunset date, and a cap on simultaneously live majors

Interview Tips

  • • "Which layers can safely retry this request?" — answer with the safe/idempotent classification, name the CDN, gateway, and SDK retries explicitly, then offer the idempotency key as the escape hatch for POST
  • • "Where would you put the API version and why?" — choose one (usually the URI), justify it with cache keys, log visibility, and gateway routing, then show depth by naming the `Vary` requirement that header versioning imposes
  • • "Is adding a field breaking?" — say it is backwards-compatible for tolerant readers and name a strict reader that still fails; the caveat separates senior answers from memorized rules
  • • "How do you retire a field?" — walk expand-contract with dates and numbers: dual-write and dual-read, telemetry gates the removal, `Sunset` header published from launch, at most two live majors

Cheat Sheet

REST Design and Versioning

Methods:

Method Safe Idempotent Retry after timeout
GET / HEAD yes yes always
PUT no yes always
DELETE no yes always
POST no no only with an idempotency key
PATCH no no only with an idempotency key

Status codes that carry the contract: 201 + Location (created), 204 (no body), 400 (malformed), 404 (missing), 409 (state conflict), 422 (semantic invalid), 429 + Retry-After (quota), 500 (server bug). Never 200 with an error body — retries, metrics, and caches all key off status.

Version placement:

Marker Strength Cost
URI /v2/... URL is the cache key; visible in logs; easy gateway routing version leaks into SDKs, docs, bookmarks
Query ?version=2 visible and hand-testable dropped by careless proxies; muddies analytics
Header X-API-Version one canonical URL requires Vary on cacheable responses; invisible in URLs
Media type negotiation done properly weak SDK, cache, and docs tooling

Deprecation signals: Deprecation: true, Sunset: <HTTP-date> (RFC 8594), migration link, per-version traffic metric gating removal.

Expand-contract:

1 expand    write new + old, read old as fallback
2 migrate   ship clients that read the new shape
3 observe   old-field traffic == 0 in telemetry
4 contract  stop writing old; delete at next major

Policy: at most two majors served at once; never repurpose a field's semantics; keep the error envelope stable across versions.

Continue Learning

Before You Start

Related Reading