tool / 78

REST Patterns

What to do, what to avoid, and why. The patterns most APIs get wrong, with before/after examples.

All local
23/23
URLs3
Resource naming
avoid
GET  /getUsers
POST /createUser
POST /deleteUser?id=42
GET  /user_list?status=active
prefer
GET    /users
POST   /users
DELETE /users/42
GET    /users?status=active
Use plural nouns and let the HTTP method be the verb. Lowercase, hyphenated. Keep it boring and predictable.
Hierarchy depth
avoid
GET /users/42/orders/13/items/7/options/red
prefer
GET /users/42/orders/13
GET /order-items/7?option=red
Stop at one level of nesting. Beyond that, switch to top-level resources or query params. Long paths break callers and obscure intent.
Singletons vs collections
avoid
GET /user/42        // singular for an instance
GET /users/list     // redundant collection suffix
prefer
GET /users        // collection
GET /users/42     // instance
GET /users/me     // current user is a known singleton
Plural for collections, singular nicknames only for known singletons (`/me`, `/current`).
Methods3
Use the right method
avoid
POST /users/42/delete
GET  /users/42/update?email=new@x.com
prefer
DELETE /users/42
PATCH  /users/42  { "email": "new@x.com" }
GET reads, POST creates, PUT replaces, PATCH partially updates, DELETE removes. Don't tunnel everything through POST or GET.
PUT vs PATCH
avoid
PUT /users/42 { "email": "new@x.com" }
// only updates email — but PUT means REPLACE so the rest of the resource is now undefined
prefer
PATCH /users/42 { "email": "new@x.com" }
// or
PUT /users/42 { ...wholeUserObject, "email": "new@x.com" }
PUT replaces the entire resource. PATCH updates a subset. Mixing them up creates data loss bugs.
Idempotent POST via Idempotency-Key
avoid
POST /charges { ... }
// network blip → client retries → user charged twice
prefer
POST /charges
Idempotency-Key: 4f9e1c-uuid

// server records the key. Replay with the same key returns the original result.
POST isn't idempotent by default. For payment-like operations, add an Idempotency-Key header (Stripe popularized this).
Status4
Use the right status code
avoid
200 OK
{ "error": "user not found" }
prefer
404 Not Found
{ "error": "user not found" }
Don't return 200 with an error in the body. HTTP status codes exist; use them. Caches, monitors and clients all rely on them.
201 vs 200 for create
avoid
POST /users → 200 OK
{ "id": 42 }
prefer
POST /users → 201 Created
Location: /users/42
{ "id": 42, "email": "..." }
Use 201 with a Location header on successful creates. Tells the client exactly where to find the new resource.
401 vs 403
avoid
401 Unauthorized for "you're logged in but not allowed"
prefer
401 — not authenticated (or expired)
403 — authenticated but no permission
401 says 'who are you'. 403 says 'I know you, you can't have it'. Don't conflate them.
422 for validation
avoid
400 Bad Request
{ "error": "email is invalid" }
prefer
422 Unprocessable Entity
{
  "errors": [
    { "field": "email", "message": "must be a valid email" }
  ]
}
400 means the request itself was malformed. Use 422 for well-formed requests that fail business validation. Return field-level errors.
Payloads3
Wrap collections
avoid
// /users
[
  { "id": 1, "name": "Ada" },
  { "id": 2, "name": "Grace" }
]
prefer
{
  "data": [
    { "id": 1, "name": "Ada" },
    { "id": 2, "name": "Grace" }
  ],
  "meta": { "page": 1, "total": 247 }
}
Bare arrays leave no room to add metadata later (pagination, totals, links). Wrap from day one.
Consistent error shape
avoid
// one endpoint
{ "error": "bad" }
// another
{ "msg": "failed", "code": 1234 }
// a third
"Internal server error"
prefer
// every endpoint
{
  "error": {
    "code": "user_not_found",
    "message": "No user with id 42",
    "details": { "id": 42 }
  }
}
Pick one error shape and use it everywhere. RFC 7807 (Problem Details) is a sensible standard.
Don't leak DB models
avoid
// returning the raw ORM entity
{ "id": 42, "email": "...", "passwordHash": "$2b$12$...", "internalNote": "vip" }
prefer
// explicit DTO
{ "id": 42, "email": "...", "displayName": "Ada" }
Returning the database row by accident leaks fields the API never meant to expose. Use a DTO.
Pagination2
Cursor over offset
avoid
GET /users?page=4500&size=20
// slow on big tables, breaks when items shift
prefer
GET /users?after=cursor_xyz&limit=20
{ "data": [...], "next_cursor": "abc..." }
Offset pagination is slow at scale and inconsistent under writes. Cursor pagination is consistent, fast and well-suited to infinite scroll.
Always set a default and max limit
avoid
GET /users?limit=99999999
prefer
// server caps limit at 100
default 20, max 100
If you don't enforce a max, someone will request a million records and OOM your service.
Versioning2
Version in the URL
avoid
GET /users               // v1
GET /users-v2            // v2 (string in path is hard to evolve)
prefer
GET /v1/users
GET /v2/users
Major version in the URL path is the simplest model — easy to test, route and document. Header-based versioning is technically purer but more painful in practice.
Don't break v1
avoid
// silently changing v1 response shape
prefer
// freeze v1, deprecate it, ship v2 alongside
API versions are contracts. Don't change them. Add a new version, run both, deprecate the old one with a sunset header.
Patterns6
Filtering and sorting
avoid
GET /users/active
GET /users/sorted-by-created
prefer
GET /users?status=active&sort=-created_at
Use query params for filtering and sorting, not new URL paths. Minus prefix for descending. Keep it consistent.
Sparse fieldsets
avoid
// always returning every field
prefer
GET /users?fields=id,name,email
Let clients ask for only the fields they need. Saves bandwidth and lets you add new fields without breaking smaller clients.
Bulk operations
avoid
// firing 1000 PATCH requests
PATCH /users/1
PATCH /users/2
...
prefer
PATCH /users
[
  { "id": 1, "active": true },
  { "id": 2, "active": false }
]
Add a bulk variant for any operation users will do at scale. Saves round trips and lets you batch atomically.
Async / long-running
avoid
POST /reports/generate
// blocks for 90 seconds
prefer
POST /reports          → 202 Accepted, Location: /reports/jobs/abc
GET  /reports/jobs/abc → { "status": "running" }
GET  /reports/jobs/abc → { "status": "done", "url": "..." }
For anything slower than ~5 seconds, return 202 with a job URL the client can poll. Don't hold the connection open.
Rate limiting headers
avoid
429 Too Many Requests
{ "error": "rate limited" }
// no info
prefer
429 Too Many Requests
Retry-After: 30
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 1700000000
When you rate-limit, tell the client when they can try again. Retry-After is mandatory; the X-RateLimit-* headers are convention.
HATEOAS sparingly
avoid
// hand-rolling URL strings on the client
const url = `/users/${id}/orders`;
prefer
// API includes related links
{
  "id": 42,
  "_links": {
    "self": "/users/42",
    "orders": "/users/42/orders"
  }
}
HATEOAS is cool in theory, painful in practice. Use it lightly: include links to related resources so clients don't hand-build URLs, but don't go full HAL/JSON-API unless the team has bought in.