The six constraints
REST is an architectural style defined by constraints. Each one buys a specific property, and skipping one costs you that property — which is why “RESTful” APIs that ignore caching and uniformity feel like they are fighting the web.
- Client–server — separate concerns so each side evolves independently.
- Stateless — every request carries its own context, so any instance can serve it.
- Cacheable — responses declare their own cacheability, so intermediaries can help.
- Uniform interface — resources, representations, self-descriptive messages, hypermedia.
- Layered system — proxies, gateways and CDNs can sit in the middle transparently.
- Code on demand (optional) — the server may ship executable code.
Richardson maturity model
| Level | What it looks like | Reality |
|---|---|---|
| 0 — RPC over HTTP | One endpoint, POST everything | Most 'REST' APIs at internal companies |
| 1 — Resources | Distinct URLs per thing | Big improvement already |
| 2 — HTTP verbs + status codes | GET/POST/PUT/PATCH/DELETE, correct codes | The practical target |
| 3 — Hypermedia (HATEOAS) | Responses link to available transitions | Rare outside of specific domains |
Modelling resources and URLs
- Plural nouns for collections:
/orders, not/orderor/getOrders. - Lowercase, hyphenated multi-word segments:
/payment-methods. - Identifiers in the path, modifiers in the query string.
- Maximum one level of nesting; anything deeper becomes a filter.
- Non-CRUD operations become explicit sub-resources:
POST /orders/8812/refund.
✓ GET /v1/orders?customerId=42&status=shipped ✗ GET /v1/customers/42/orders/shipped ✓ POST /v1/orders/8812/cancel ✗ POST /v1/cancelOrder?id=8812 ✓ GET /v1/orders/8812/items ✗ GET /v1/customers/42/orders/8812/items/19/product/reviews
Pagination: offset vs cursor
| Offset / page | Cursor / keyset | |
|---|---|---|
| Query | ?page=3&limit=50 | ?after=eyJpZCI6ODgxMn0&limit=50 |
| SQL | LIMIT 50 OFFSET 100 | WHERE (created_at, id) < (?, ?) LIMIT 50 |
| Deep-page cost | Degrades badly — the DB scans and discards | Constant time, index seek |
| Jump to page N | Yes | No |
| Stable under inserts | No — items shift, you see duplicates or gaps | Yes |
| Total count | Easy (but expensive) | Usually omitted or approximate |
GET /v1/orders?limit=50
{
"data": [ ... ],
"page": {
"nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA4LTIyIiwiaWQiOjg4MTJ9",
"hasMore": true
}
}Encode the cursor opaquely (base64 of the sort key tuple) so you can change the internal shape later. Always cap limit server-side — an unbounded limit is an availability bug.
Filtering, sorting, sparse fields
GET /v1/orders ?status=shipped,delivered # multi-value &createdAt[gte]=2026-01-01 # range operators &sort=-createdAt,total # '-' prefix = descending &fields=id,total,status # sparse fieldset &expand=customer # controlled embedding
- Whitelist filterable and sortable fields — never pass user input into a query builder.
- Every sortable field needs an index, and a tiebreaker on the primary key for stable order.
expandgives you GraphQL's main benefit at a fraction of the complexity.
Versioning
| Approach | Example | Verdict |
|---|---|---|
| URL path | /v1/orders | Most common, trivially visible, cache- and log-friendly |
| Header | Accept: application/vnd.api.v2+json | Purer, but easy to get wrong and invisible in logs |
| Query param | /orders?version=2 | Works; muddles the cache key |
| Date-based | API-Version: 2026-08-22 | Stripe's model — excellent for long-lived public APIs |
Version the API, not each endpoint. Better still, avoid the need: adding fields and optional parameters is backward compatible, so most changes should not require a version at all. Reserve a new major version for removing or renaming things, and publish a deprecation timeline with Sunset headers.
Errors and validation
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://api.example.com/errors/validation",
"title": "Validation failed",
"status": 422,
"requestId": "3f9a-77cd",
"errors": [
{ "field": "items[0].qty", "code": "min", "message": "must be >= 1" }
]
}HATEOAS: worth it?
Hypermedia means the response tells the client what it can do next, so URLs are discovered rather than hardcoded.
{
"id": 8812,
"status": "pending",
"_links": {
"self": { "href": "/v1/orders/8812" },
"cancel": { "href": "/v1/orders/8812/cancel", "method": "POST" },
"pay": { "href": "/v1/orders/8812/payments", "method": "POST" }
}
}The genuine benefit is that available actions become server-driven — the client stops reimplementing “can this order be cancelled?”. That is worth doing even if you never build a fully hypermedia-driven client. Full HATEOAS, where clients navigate purely by links, mostly is not.
Production checklist
- OpenAPI spec generated from code, not maintained by hand.
- Idempotency keys on every create endpoint.
- ETag plus
If-Matchon every mutable resource. - Rate limit headers on every response, not just 429s.
Cache-Control: no-storeon anything authenticated;Varyeverywhere else.- Hard caps on
limit, body size, and query complexity. - A request id propagated into every log line and returned in every error.
- Deprecation policy:
Sunsetheader, changelog, at least one release of overlap.
Interview framing
“Resources are plural nouns with one level of nesting and flat canonical URLs, so responses stay cacheable. Lists use keyset pagination with opaque cursors because offset pagination degrades and skips rows under concurrent inserts. Versioning is in the path, but we treat additive changes as non-breaking so v1 lives for years. Writes carry idempotency keys, mutable resources use ETags with If-Match for optimistic concurrency, and errors are problem+json with a stable code and the request id that also appears in our traces.”