API Design

Architectural Styles · Lesson 02

REST architecture style

REST isn't a protocol or a file format — it's a set of six constraints on how components of a distributed system should interact. Get those constraints right and you get an architecture that scales to the entire web.

⏱ 12 min Difficulty: core Prereq: Styles Overview (as-01)

By the end you'll be able to

REST is a set of constraints, not a spec

Roy Fielding coined the term REST in his 2000 PhD dissertation. He wasn't inventing something new — he was describing the architectural decisions that made the World Wide Web work at global scale. The key insight: the web's longevity and scale come from six architectural constraints. Obey them and you get the same properties for your API.

Think of REST like the rules of contract bridge. The rules don't specify which cards you hold, only how you're allowed to play them. Any valid hand played under those rules is a "REST API"; hands played under different rules (calling a custom action like /calculateTax) are a different game entirely.

Constraint 1 — Resources and URIs

Every piece of information worth addressing is a resource. A resource is a conceptual thing — a user, an order, a document. Every resource gets a stable URI (Uniform Resource Identifier) — the address that never changes regardless of what's inside it.

The URI names what you want, not what you want done to it:

Constraint 2 — The uniform interface

All resources are manipulated through the same small set of operations. In HTTP-based REST those operations are the HTTP methods. Their semantics are fixed by the HTTP spec — you don't invent new verbs:

MethodMeaningSafe?Idempotent?
GETRead a resourceYesYes
POSTCreate a new resource (or trigger a process)NoNo
PUTReplace a resource entirelyNoYes
PATCHUpdate part of a resourceNoConditional
DELETERemove a resourceNoYes

Safe means the operation has no observable side-effect (you can call it freely). Idempotent means calling it once or a hundred times produces the same result — crucial for retries.

Constraint 3 — Statelessness

Each request must carry all the context the server needs to service it. The server keeps no session state between calls. Your phone knows what song it just played; a REST server does not know what the client called last time.

This makes REST servers easy to scale: any instance can handle any request because none of them hold per-client state. The trade-off is that credentials and context travel in every request (usually via headers), making requests slightly larger.

Constraint 4 — Representations

A resource and its representation are different things. /users/42 is the resource; the JSON object you get back is a representation of it at this moment. The same resource could have many representations — JSON for an app, HTML for a browser, CSV for a data export. HTTP's Accept header lets clients negotiate which one they want (content negotiation).

Resource /users/42 JSON HTML CSV Accept: application/json | text/html | text/csv
One resource, multiple representations. The client signals which it wants via the Accept header; the server sends back a Content-Type confirming what it returned.

Constraint 5 — Cacheability

Responses must label themselves as cacheable or non-cacheable. When a response is cacheable, clients, proxies, and CDNs can store it and reuse it — eliminating round-trips to the origin entirely. The HTTP spec's cache-control vocabulary (Cache-Control, ETag, Last-Modified) is the mechanism. A GET /products/123 response with Cache-Control: max-age=300 can be served from a CDN edge for 5 minutes without touching the origin server at all.

Constraint 6 — HATEOAS (briefly)

Hypermedia As The Engine Of Application State is the most frequently skipped REST constraint. The idea: responses include links that tell the client what it can do next, so clients don't need to hardcode URL templates. A GET /orders/99 response might embed "links": [{"rel":"cancel","href":"/orders/99/cancel"}]. The client navigates the API by following links, like a browser following anchor tags — no prior knowledge of URL structure required.

Full HATEOAS is rarely implemented in practice but the principle (clients shouldn't hardcode internal URL structure) remains a useful design guide.

Worked example: /users/42 with all four main methods

# Read the user
GET /users/42
# → 200 OK
{ "id": 42, "name": "Kwame Asante", "email": "k@example.com" }

# Create a new user (collection resource)
POST /users
{ "name": "Linnea Berg", "email": "l@example.com" }
# → 201 Created   Location: /users/43

# Replace the user entirely
PUT /users/42
{ "name": "Kwame A. Asante", "email": "k@example.com" }
# → 200 OK  (or 204 No Content)

# Remove the user
DELETE /users/42
# → 204 No Content
🎯 Interview angle

Interviewers often ask "what makes an API RESTful?" The trap answer is "it uses HTTP and JSON." The correct answer is: it follows REST's constraints — resources identified by URIs, stateless requests, a uniform interface (HTTP verbs), and cacheable responses. Extra credit: mention that most "REST" APIs in production are actually REST-ish (they skip HATEOAS) and that's fine in practice.

⚠️ Common trap — RPC verbs in REST URLs

The uniform interface means the verb lives in the HTTP method, not the URL. You'll often see APIs that put the action in the path:

POST /users/42/updateEmail ← RPC-style (bad) GET /getOrderStatus?id=99 ← RPC-style (bad) PATCH /users/42 ← REST (good) GET /orders/99 ← REST (good)

Verb URLs lock the client to a specific action name; if you later rename the operation you break every client. REST URLs name things, HTTP methods name operations.

✅ Do this, not that

Do lean on HTTP's existing semantics: return 201 Created with a Location header after a successful POST; return 204 No Content after a DELETE; use ETag on GET responses so clients can cache. Don't return 200 OK with a body of {"error": "not found"} — that defeats the entire uniform interface.

Under the hood: the full resource lifecycle

A resource moves through create → read → conditional-read → update → delete; each step has specific HTTP semantics that clients and intermediaries rely on. Walking through the full lifecycle on the wire reveals how the pieces — status codes, ETag headers, conditional requests, and HATEOAS links — compose into a coherent system.

Step 1 — Create: POST → 201 Created + Location

A 201 Created response tells the client that a new resource now exists. The Location header carries the canonical URL of that new resource, so the client knows exactly where to find it without issuing a follow-up GET. The server also returns an initial ETag — the fingerprint of the resource as it was just created — so the client can start conditional requests immediately.

# Client sends a new user payload
POST /users HTTP/1.1
Host: api.example.com
Authorization: Bearer tok_s3cr3t
Content-Type: application/json
Content-Length: 51

{ "name": "Linnea Berg", "email": "linnea@example.com" }

← HTTP/1.1 201 Created
Location: https://api.example.com/users/43
Content-Type: application/json
ETag: "v1-abc123"

{ "id": 43, "name": "Linnea Berg", "email": "linnea@example.com" }

Step 2 — Read: GET → 200 + ETag

The server issues an ETag — a fingerprint of the current representation — alongside every cacheable GET response. The Cache-Control: max-age=60 header tells intermediaries they can serve this response for up to 60 seconds before re-validating.

GET /users/43 HTTP/1.1
Host: api.example.com
Authorization: Bearer tok_s3cr3t

← HTTP/1.1 200 OK
Content-Type: application/json
ETag: "v1-abc123"
Cache-Control: max-age=60

{ "id": 43, "name": "Linnea Berg", "email": "linnea@example.com" }

Step 3 — Conditional GET: If-None-Match → 304 Not Modified

The client sends back the ETag it received in the previous response via If-None-Match. If the resource hasn't changed, the server returns 304 Not Modified with no body — saving bandwidth and reducing latency. This is HTTP's built-in optimistic cache validation: the server only does the work of serializing a response body when the data has actually changed.

GET /users/43 HTTP/1.1
Host: api.example.com
Authorization: Bearer tok_s3cr3t
If-None-Match: "v1-abc123"   ← client sends its cached ETag

← HTTP/1.1 304 Not Modified
ETag: "v1-abc123"
                               ← no body sent; client uses its cached copy

Bandwidth saving is concrete: if the user object is 800 bytes, a 304 response is roughly 120 bytes of headers only. Multiply across a million GET requests per hour and the difference is significant — fewer bytes transferred, lower origin CPU, and faster perceived responses for clients with warm caches.

Step 4 — Partial update: PATCH → 200

PATCH sends only the changed fields, not the full representation. The If-Match header enables optimistic locking: the server applies the update only if the resource is still at the version the client last saw. If another process has already changed the resource (new ETag), the server returns 412 Precondition Failed rather than silently overwriting the other writer's changes.

PATCH /users/43 HTTP/1.1
Host: api.example.com
Authorization: Bearer tok_s3cr3t
Content-Type: application/json
If-Match: "v1-abc123"   ← only update if still this version

{ "email": "linnea.berg@example.com" }

← HTTP/1.1 200 OK
ETag: "v2-def456"   ← new ETag because the resource changed

{ "id": 43, "name": "Linnea Berg", "email": "linnea.berg@example.com" }

Step 5 — Full replace: PUT → 200/204

PUT replaces the entire resource. The client must send the complete representation — any fields omitted are treated as absent. Use 200 OK with the updated body, or 204 No Content if the server has nothing meaningful to return.

PUT /users/43 HTTP/1.1
Host: api.example.com
Authorization: Bearer tok_s3cr3t
Content-Type: application/json

{ "name": "Linnea Berg", "email": "linnea.berg@example.com", "plan": "pro" }

← HTTP/1.1 200 OK  (or 204 No Content if no body returned)

Step 6 — Delete: DELETE → 204 No Content

204 No Content means "done; there is nothing to say." No body is sent. A subsequent GET on the same URI should return 404 Not Found — the resource is gone.

DELETE /users/43 HTTP/1.1
Host: api.example.com
Authorization: Bearer tok_s3cr3t

← HTTP/1.1 204 No Content
                               ← empty body; 204 means "done, nothing to say"

# Subsequent GET returns 404:
GET /users/43 → 404 Not Found

HATEOAS response — navigable links in the body

A HATEOAS response includes a _links object that tells the client what operations are available from the current state, keyed by semantic relation (rel). The client follows links rather than constructing URLs from hardcoded templates — just as a browser follows anchor tags without knowing site structure in advance.

# GET /orders/99 — response includes navigable links
{
  "id": 99,
  "status": "pending",
  "total": 249.00,
  "_links": {
    "self":     { "href": "/orders/99",         "method": "GET"  },
    "cancel":  { "href": "/orders/99/cancel",  "method": "POST" },
    "pay":     { "href": "/orders/99/payment", "method": "POST" },
    "customer":{ "href": "/users/42",          "method": "GET"  }
  }
}

The client discovers the cancel and pay operations from the response itself — no hardcoded URL construction needed. If the server later renames the cancel endpoint, only the href changes in the response; clients that follow links instead of constructing URLs don't break.

POST 201 Created GET 200 + ETag GET 304 Not Modified PATCH 200 + new ETag DELETE 204 No Content
The five-step lifecycle of a REST resource on the wire. Each transition maps to a specific HTTP method, status code, and set of headers.
⚠️ ETag pitfall — If-None-Match vs If-Match

The two uses of ETag are easy to confuse. If-None-Match is for cache validation on GET: "give me the resource only if it changed since I last fetched it." If-Match is for optimistic locking on PATCH/PUT: "only apply this update if the version I have is still current." Using them backwards causes either stale-read bugs (serving stale cached data) or lost-update bugs (silently overwriting another writer's changes).

How to debug & inspect it

curl -i (the -i flag includes response headers in stdout) is the primary REST debugging tool. It exposes status codes, Location, ETag, and content-negotiation headers that are invisible in the browser Network tab's simplified view.

# Step 1 — Create a user; check for 201 + Location curl -i -X POST https://api.example.com/users \ -H "Authorization: Bearer tok_s3cr3t" \ -H "Content-Type: application/json" \ -d '{"name":"Linnea Berg","email":"linnea@example.com"}' HTTP/1.1 201 Created Location: https://api.example.com/users/43 ETag: "v1-abc123" Content-Type: application/json {"id":43,"name":"Linnea Berg","email":"linnea@example.com"} # Step 2 — Read the new user; note the ETag curl -i https://api.example.com/users/43 \ -H "Authorization: Bearer tok_s3cr3t" HTTP/1.1 200 OK ETag: "v1-abc123" Cache-Control: max-age=60 Content-Type: application/json {"id":43,"name":"Linnea Berg","email":"linnea@example.com"} # Step 3 — Conditional GET; should return 304 if unchanged curl -i https://api.example.com/users/43 \ -H "Authorization: Bearer tok_s3cr3t" \ -H 'If-None-Match: "v1-abc123"' HTTP/1.1 304 Not Modified ETag: "v1-abc123" ← no body; use cached copy # Step 4 — Patch the email; check for new ETag curl -i -X PATCH https://api.example.com/users/43 \ -H "Authorization: Bearer tok_s3cr3t" \ -H "Content-Type: application/json" \ -H 'If-Match: "v1-abc123"' \ -d '{"email":"linnea.berg@example.com"}' HTTP/1.1 200 OK ETag: "v2-def456" Content-Type: application/json {"id":43,"name":"Linnea Berg","email":"linnea.berg@example.com"}

In browser DevTools (Network tab):

  1. Filter by XHR/Fetch. Look at the Status column — 201 means something was created (check the Location response header); 304 means a cached copy was served (bandwidth saved).
  2. Click a response: the Headers tab shows ETag and Cache-Control. If ETag is missing on GETs, conditional caching won't work.
  3. Check the Timing tab for Time To First Byte. A 304 should be faster than a 200 because no body is transmitted.
  4. For POST requests, confirm the response has a Location header pointing to the new resource's URL.

Symptom → cause → fix:

SymptomCauseFix
POST returns 200 instead of 201 Server created the resource but didn't signal it with the correct status code Return 201 Created with a Location header after successful creation
GET returns stale data after a PATCH updated the resource Cached 200 response; ETag not invalidated or Cache-Control max-age still valid Server must update ETag on every mutation; or set Cache-Control: no-cache on mutable resources
DELETE returns 200 with {"success": true} instead of 204 Server returning a body when there is nothing meaningful to say Return 204 No Content with no body; clients that read HTTP correctly don't need a body to confirm deletion
PATCH on /users/43 returns 404 PATCH method not registered in the router; server may handle only GET/POST/PUT Register a PATCH handler; or check if an API gateway is stripping the method
If-Match PATCH returns 412 Precondition Failed unexpectedly Another process updated the resource between the client's GET and PATCH; ETag no longer matches Re-fetch with GET to get the current ETag, apply changes, then retry the PATCH with the new ETag
304 Not Modified but client still re-renders with empty data Client not reading the cached response on 304; treating the empty body as fresh data On 304, use the locally cached body — no new data was sent, and that is intentional

Debug checklist:

  1. Always use curl -i (not just curl) — the -i flag includes response headers in stdout, revealing Location, ETag, and Cache-Control.
  2. After a POST: did you get 201 Created with a Location header? Verify the Location URL is correct by GETting it immediately.
  3. On repeated GETs: is ETag present in the response? Is If-None-Match being sent in subsequent requests?
  4. On a 304: confirm the client is using its cached body, not treating the empty response as an error or an empty payload.
  5. On a 412: re-fetch to get the current ETag, update your local copy with the latest state, then retry the mutation.

🧠 Quick check

1. Which HTTP method is both safe AND idempotent?

GET is safe (no side-effects) and idempotent (same result however many times you call it). POST is neither — each call can create a new resource. PUT is idempotent but not safe.

2. A REST server stores session state between requests for each client. Is this valid REST?

Statelessness is one of Fielding's six constraints. The server must not retain client session state between requests. It's this constraint that makes REST services easy to scale horizontally.

3. POST /users/42/deactivate violates REST's uniform interface because:

REST URLs name resources; HTTP methods express operations. The correct design is PATCH /users/42 with a body like {"status": "inactive"}. Putting the action in the URL breaks the uniform interface and creates a proliferation of custom verb endpoints.

✍️ Exercise: redesign an RPC-style API (try before opening)

A legacy system exposes these endpoints. Redesign them to follow REST constraints:

GET /getUser?userId=5 POST /createOrder POST /deleteOrder?id=88 GET /listProductsByCategory?cat=books

Model answer:

GET /users/5 POST /orders DELETE /orders/88 GET /products?category=books

Rubric: ✓ verbs removed from URLs ✓ nouns are plural ✓ correct HTTP method used for each operation ✓ query string kept for filter params (not for ids of single resources).

Key takeaways

Sources & further reading