An API Is a Product, Not a Mirror of Your Database
A familiar failure mode: expose every database table as a CRUD endpoint and you end up with a pile of /getUsers, /createOrder, and /updateUserStatus. It works, but every new client needs bespoke glue, and every schema change has to be announced to every consumer.
The value of REST is not that it looks tidy. It is that an endpoint can be guessed correctly without reading the documentation. Getting there starts with resource modelling, not with URL strings.
When you are debugging response bodies and matching up fields, pasting the returned JSON into a JSON formatter is the fastest first step; for deeply nested payloads, a JSON viewer that lets you collapse and expand beats scrolling through a terminal.
1. Resource Modelling: First Principles of URL Design
Use nouns, not verbs
The URL names the resource; the HTTP method names the action. Putting a verb in the URL throws away the semantic layer HTTP already gives you for free.
| Anti-pattern | Preferred | Why |
|---|---|---|
GET /getUsers |
GET /users |
GET already says read |
POST /createOrder |
POST /orders |
POST already says create |
POST /deleteUser/123 |
DELETE /users/123 |
Intermediaries cannot tell a destructive call is safe otherwise |
GET /getUserOrders?uid=1 |
GET /users/1/orders |
Hierarchy belongs in the path, not a query parameter |
Express hierarchy, but stop at two levels
/users/1/orders/2/items/3 is technically valid, but it locks clients into one fixed traversal path and forces a parent-existence check at every level.
Rules of thumb:
- Use hierarchy when the relationship is exclusive and strongly owned:
/users/1/orders - Flatten past two levels and filter with query parameters:
/orders?userId=1&status=paid - If a resource has a globally unique id, let it be reachable as a top-level resource:
/orders/2
Naming consistency
| Rule | Example | Reason |
|---|---|---|
| Plural nouns for collections | /orders, /users |
Matches collection semantics, avoids mixed singular/plural |
| Lowercase only | /order-items, not /orderItems |
Parts of a URL are case-sensitive; mixing guarantees bugs |
| Hyphens for multi-word | /order-items, not /order_items |
Hyphens need no escaping and read best |
| No trailing slash | /orders, not /orders/ |
Some frameworks treat these as two distinct routes |
What about non-CRUD actions
Some operations never map cleanly onto CRUD — publish, cancel, batch recalculate. Three options, in priority order:
- Model the action as a resource:
POST /orders/1/cancellations. Cancelling becomes creating a cancellation record, which is clean and auditable. - Model it as a state field:
PATCH /orders/1with{ "status": "cancelled" }. - Only as a last resort, a verb suffix:
POST /orders/1:publish. Acceptable, but document clearly that it is not a resource.
2. HTTP Methods: Semantics and Idempotency
| Method | Meaning | Safe | Idempotent | Typical response |
|---|---|---|---|---|
GET |
Read a resource | Yes | Yes | 200 + body |
HEAD |
Headers only | Yes | Yes | 200, no body |
POST |
Create or sub-resource action | No | No | 201 + Location |
PUT |
Full replacement (may create) | No | Yes | 200 / 201 / 204 |
PATCH |
Partial update | No | No by default | 200 / 204 |
DELETE |
Delete | No | Yes | 204 / 200 |
OPTIONS |
Ask which methods are supported | Yes | Yes | 200 + Allow |
For the full semantics and side-effect matrix, see the HTTP methods reference.
Idempotency is not an academic concern
Idempotent means executing the same request N times leaves the server in the same state as executing it once.
It matters because retries are guaranteed to happen: mobile network handovers, gateway timeout replays, at-least-once queue delivery, a user double-tapping a button. Any non-idempotent write endpoint produces duplicates without protection.
The two most common traps:
- Creating orders with POST: two taps, two orders. Fix it by having the client send an
Idempotency-Keyheader (a UUID generator is all you need), storing it server-side as a unique key for 24 hours, and replaying the original result on a hit. - Relative deltas via PATCH:
{ "balanceDelta": -100 }retried twice charges two hundred. Submit the absolute value{ "balance": 900 }instead and idempotency follows automatically.
What POST should return
On success return 201 Created with the new resource URL in Location:
HTTP/1.1 201 Created
Location: /orders/9f3c...
If creation is asynchronous, return 202 Accepted with a status endpoint. Do not return 200 and pretend the work is done.
3. Status Codes: Layer Them, Do Not Return 200 for Everything
Returning 200 for everything and putting the error code in the body is the most common anti-pattern. It blinds monitoring (every request looks successful), confuses gateway caches, and forces clients to parse the body just to learn whether the call worked.
| Class | Meaning | Common codes |
|---|---|---|
| 2xx | Success | 200 OK / 201 Created / 202 Accepted / 204 No Content |
| 3xx | Redirection and caching | 301 / 302 / 304 Not Modified |
| 4xx | Client error, retrying is pointless | 400 / 401 / 403 / 404 / 409 / 422 / 429 |
| 5xx | Server error, retrying is reasonable | 500 / 502 / 503 / 504 |
For the complete list and common misuses, see the HTTP status codes reference.
Boundaries that are easy to get wrong:
- 400 vs 422: when the syntax is valid but the semantics fail (end date before start date), use 422 Unprocessable Entity; malformed syntax is 400.
- 401 vs 403: 401 means we do not know who you are; 403 means we know who you are and the answer is no. An authenticated user without permission gets 403.
- 404 vs 403: returning 404 to hide the existence of a resource is a legitimate security practice.
- 409 Conflict: a state conflict — duplicate submission, version mismatch, insufficient stock. It tells the client that retrying is useless until the state changes.
- 429 Too Many Requests: rate limited. You must include
Retry-After, otherwise clients retry blindly and turn overload into an avalanche.
4. A Uniform Error Format
Use RFC 7807 application/problem+json as the shared envelope:
{
"type": "https://api.example.com/errors/order-already-paid",
"title": "Order already paid",
"status": 409,
"detail": "Order 9f3c has already been paid and cannot be modified.",
"instance": "/orders/9f3c",
"code": "ORDER_ALREADY_PAID",
"errors": [
{ "field": "quantity", "reason": "must be greater than 0" }
]
}
Key points:
typeis the stable contract. Clients branch on it, never ondetailprose.codeis a deliberate extension for localisation and monitoring — the samecodecan be grouped by directly in log queries.errorscarries field-level validation detail. Form endpoints are unusable without it.- Never echo stack traces, SQL, internal hostnames, or raw user input.
When a team needs to freeze the contract, generating a schema from real responses with a JSON Schema generator drifts far less than hand-written docs; TypeScript clients can generate types directly with JSON to TypeScript.
5. Pagination, Filtering, and Sorting
| Dimension | Offset pagination | Cursor pagination |
|---|---|---|
| Syntax | ?offset=100&limit=20 |
?cursor=eyJpZCI6MTAwMH0&limit=20 |
| Deep-page performance | Degrades linearly with offset | Constant |
| Drift from inserts | Duplicates or skips | Immune |
| Random page jumps | Supported | Not supported |
| Total count | Easy | Requires an extra count |
| Best for | Admin back offices, reports | Feeds, timelines, exports |
Filtering and sorting should follow one global convention instead of being reinvented per endpoint:
- Filtering:
?status=paid&created_after=2026-01-01 - Sorting:
?sort=-created_at,amount(a leading minus means descending) - Field selection:
?fields=id,status,total(saves bandwidth, but be explicit about its effect on cache keys) - Totals: do not return
totalon every call; it is often the most expensiveCOUNT(*)in the system. Offer?count=truewhen it is actually needed.
When comparing two revisions of a response, JSON Diff is far more reliable than eyeballing fields one by one.
6. Versioning
| Strategy | Example | Pros | Cons | Best for |
|---|---|---|---|---|
| URL prefix | /v1/orders |
Obvious, shareable, visible in logs | Version leaks into the identifier | Public APIs |
| Request header | Accept: application/vnd.app.v1+json |
Clean URLs, proper content negotiation | Hides the version, complicates cache config | Internal services |
| Query parameter | ?version=1 |
Trivial to implement | Pollutes the query space, easily ignored | Not recommended |
More important: avoid bumping the version at all. These changes are backward compatible and need no new version:
- New optional request fields
- New response fields (clients must ignore unknown fields)
- New endpoints
- New
Linkheaders or new errortypevalues
These are breaking and require a new version or a staged rollout:
- Removing or renaming a field
- Tightening validation rules
- Changing a field type or unit
- Changing error semantics
7. Caching and Conditional Requests
Caching offers the highest performance return for the smallest change, and is the step most often skipped.
| Header | Direction | Purpose |
|---|---|---|
Cache-Control |
Response | Declares the caching policy (max-age, no-store, private) |
ETag |
Response | Fingerprint of the resource version |
If-None-Match |
Request | Sends the last ETag; the server replies 304 if unchanged |
Last-Modified / If-Modified-Since |
Both | Time-based weak validation |
Vary |
Response | Which request headers the cache keys on (for example Accept-Encoding) |
Retry-After |
Response | Tells the client when to retry after 429 or 503 |
Working through a checklist is the cheapest way to catch gaps: HTTP headers reference.
Practical notes: GET is cacheable by default, which is exactly why it must never carry side effects (that is the real problem with delete-by-GET designs). Mark writes explicitly with no-store. Authenticated personalised responses must carry Cache-Control: private, otherwise a shared cache can leak one user's data to another.
8. The Minimum Consensus on Auth
- Force HTTPS and 301-redirect plain HTTP. Certificate and TLS details are covered in the HTTPS certificate guide.
- Do not invent an auth protocol. Use OAuth 2.1 / OIDC or a mature session mechanism. Token design, signing, and common pitfalls are in JWT security best practices.
- Never store passwords in plaintext; use bcrypt, Argon2, or scrypt. The full tradeoff is in the password hashing guide, with the matching bcrypt hash tool.
- Enforce authorisation server-side. Hiding a button in the UI is not access control.
- Rate-limit by identity, not by IP, or users behind the same NAT will block each other.
When debugging a call, feeding a request copied out of the browser into cURL to code produces client snippets for each language far more reliably than transcribing headers by hand.
9. Anti-Pattern Checklist
| Anti-pattern | Consequence | Fix |
|---|---|---|
| Every response is 200 | Monitoring blind, caches misled | Return layered status codes |
| Verbs in URLs | Duplicated semantics, no HTTP caching | Nouns plus HTTP methods |
| POST for every write | Idempotency lost, retries duplicate data | Distinguish POST / PUT / PATCH / DELETE |
| No idempotency key on non-idempotent writes | A double tap creates two orders | Introduce Idempotency-Key |
| Different error shapes per endpoint | Clients cannot handle errors uniformly | RFC 7807 envelope |
| Deeply nested URLs | Coupled traversal, complex validation | Flatten plus query filters |
| Offset for deep paging | Slower the deeper you go, data drifts | Switch to cursors |
| Breaking changes without a version bump | Silently breaks every consumer | Semantic versioning plus staged rollout |
| GET with side effects | Triggered by crawlers and prefetch | Use POST or PUT |
| Error bodies echo internals | Information disclosure | Return only safe, client-readable detail |
Ship Checklist
Before releasing, verify each item:
- All URLs use plural nouns, lowercase, hyphenated, no verbs
- HTTP methods line up with semantics, safety, and idempotency
- Every write endpoint can answer "what happens on a retry"
- Non-idempotent writes support
Idempotency-Key - Status codes are layered 2xx/4xx/5xx; nothing returns 200 for everything
- Both 429 and 503 carry
Retry-After - Errors are uniformly
application/problem+jsonwith a stabletypeand a businesscode - Validation errors include field-level detail
- Error bodies contain no stack traces, SQL, or internal hostnames
- Cursors are used once paging goes past two levels deep
- Filtering, sorting, and field-selection syntax is globally consistent
- Backward-compatible changes did not bump the version; breaking ones did and are staged
- GET is safely cacheable, writes declare
no-store, personalised responses declareprivate - HTTPS everywhere, HTTP is 301-redirected
- Authorisation enforced server-side; rate limiting keyed by identity