Roy Fielding's dissertation defines six constraints:
In practice almost no "REST API" does hypermedia, and that is fine — the industry meaning is "resource-oriented HTTP + JSON". What matters is honouring HTTP's semantics (RFC 9110), because every cache, proxy, client library and retry policy already assumes them.
Model the domain as nouns, not verbs: products, orders, reviews.
Plural collection names: /products (collection), /products/123 (item).
The method is the verb: GET /orders, not /getOrders; DELETE /users/42, not POST /users/42/delete.
Nesting shows ownership: /products/123/reviews.
Keep nesting shallow — one level, two at most. /products/123/reviews/9 is fine; /stores/1/departments/4/products/123/reviews/9 makes every client know the whole hierarchy. Once a child has its own globally unique ID, expose it at the top level too (/reviews/9). (Microsoft, Zalando)
One casing everywhere. URLs: lowercase, kebab-case paths (Zalando) — or camelCase per Google AIP; the choice matters less than never mixing. JSON fields: pick snake_case or camelCase once.
IDs are opaque strings to the client. Don't let clients parse or construct them; avoid leaking auto-increment counts (UUIDs or prefixed IDs like ord_7Hk…).
Actions that aren't CRUD. Some operations really are verbs — cancel an order, send an invite. Two accepted patterns:
POST /orders/123/cancellation, or a state change PATCH /orders/123 {"status":"cancelled"};POST /orders/123:cancel.Either is fine; inventing /cancelOrder?id=123 is not.
No file extensions or trailing slashes in URLs (/products.json, /products/); use Accept for format and pick one form for slashes.
| Method | Use | Safe | Idempotent | Typical success |
|---|---|---|---|---|
| GET | Read | yes | yes | 200 |
| HEAD | Headers only | yes | yes | 200 |
| POST | Create in a collection; non-CRUD actions | no | no | 201 + Location, or 200/202 |
| PUT | Replace whole resource (or create at a client-chosen URL) | no | yes | 200 or 204 |
| PATCH | Partial update | no | not guaranteed | 200 or 204 |
| DELETE | Remove | no | yes | 204 (or 200 with body) |
Safe means no state change the client asked for; idempotent means sending it twice has the same effect as once (RFC 9110 §9.2). PUT replaces a whole resource, PATCH changes part of it. What follows from that:
application/merge-patch+json — send the fields to change, null deletes) or JSON Patch (RFC 6902, a list of operations). Merge Patch covers most needs.Idempotency-Key request header (IETF draft-ietf-httpapi-idempotency-key-header; Stripe popularised it): the client mints a key once, the server stores the result per key and replays it on a repeat.The classes are 2xx success, 3xx redirect, 4xx client fault, 5xx server fault. The working set:
| Code | When |
|---|---|
| 200 OK | Success with a body |
| 201 Created | Created; set Location: /products/124 |
| 202 Accepted | Accepted for async processing; return a status resource to poll |
| 204 No Content | Success, no body (DELETE, some PUT/PATCH) |
| 304 Not Modified | Conditional GET, client's copy is current |
| 400 Bad Request | Malformed — unparseable JSON, wrong types |
| 401 Unauthorized | Not authenticated — missing/invalid/expired credentials |
| 403 Forbidden | Authenticated but not allowed |
| 404 Not Found | No such resource — or you won't reveal that it exists |
| 405 Method Not Allowed | Wrong method for this URL; include Allow |
| 409 Conflict | Clashes with current state (duplicate, version conflict) |
| 412 Precondition Failed | If-Match didn't match — someone else edited it |
| 415 Unsupported Media Type | Wrong Content-Type |
| 422 Unprocessable Content | Well-formed but semantically invalid (validation) |
| 429 Too Many Requests | Rate limited; include Retry-After (RFC 6585) |
| 500 Internal Server Error | Unexpected failure — a bug |
| 502 / 503 / 504 | Upstream bad / unavailable (with Retry-After) / timed out |
Points worth knowing:
200 with {"error": …} in the body. Monitoring, caches, retries and client libraries all read the status code.Use Problem Details (RFC 9457), Content-Type: application/problem+json:
{
"type": "https://api.example.com/problems/insufficient-funds",
"title": "Insufficient funds",
"status": 422,
"detail": "Balance is 30.00, the transfer needs 50.00.",
"instance": "/transfers/abc123",
"errors": [{ "pointer": "/amount", "detail": "exceeds balance" }]
}
type is a stable, documented identifier clients can branch on; title and detail are for humans. Extension members (errors above) are allowed.A version prefix (/api/v1/…) lets old clients keep working while v2 develops. But:
/v1, most common, visible, cache-friendly), header (Api-Version: 2025-06-01, as Stripe and Azure do with date versions), or media type (Accept: application/vnd.example.v2+json). Path versioning is the pragmatic default; major versions only.Deprecation header (RFC 9745) and Sunset header (RFC 8594) tell clients when an endpoint goes away; announce, measure remaining usage, then remove.The difference between a demo API and a production one:
ETag on GET; clients send If-Match: "<etag>" on PUT/PATCH/DELETE; the server returns 412 if the resource changed. This prevents the lost update — two people editing the same record, the second silently overwriting the first. (RFC 9110 §13) A version field in the body checked by the server is the same idea.Cache-Control (max-age, no-store for personal data, private), ETag + If-None-Match → 304 for cheap revalidation.Location: /operations/789; the client polls that, or the server calls a webhook when done (Google AIP-151, Microsoft).Retry-After; advertise the budget with RateLimit / RateLimit-Policy headers (IETF draft-ietf-httpapi-ratelimit-headers).The REST-specific minimum:
Authorization: Bearer …, OAuth 2.0 / OIDC JWTs) — never credentials or tokens in the URL, which end up in logs and Referer headers.GET /accounts/124 works because the user is logged in, not because account 124 is theirs.created_by, user_id); derive it from the token.is_admin or balance; whitelist writable fields.OWASP's list of the most common API security risks. The 2023 edition is current and replaced the 2019 one. For each risk: what goes wrong, and the rule against it.
| # | Risk | What goes wrong | Rule |
|---|---|---|---|
| API1 | Broken Object Level Authorization (BOLA) | Change the ID, get someone else's data: GET /accounts/124 |
Check on every request that this user may access this object. Opaque IDs (§2) help, but don't replace the check |
| API2 | Broken Authentication | No brute-force protection on login; tokens accepted without checking signature, exp or aud |
Use a standard (OAuth 2.0 / OIDC); fully validate every token; rate-limit login, reset and OTP endpoints |
| API3 | Broken Object Property Level Authorization | The response leaks fields (passwordHash, internalNote); the request sets fields it shouldn't (isAdmin, mass assignment) |
Separate request and response models per use case; whitelist readable and writable fields |
| API4 | Unrestricted Resource Consumption | Huge pages or uploads, slow queries, paid third-party calls (SMS, e-mail) triggered without limit | Cap page size, body size and timeouts; rate limits and quotas per client (§7) |
| API5 | Broken Function Level Authorization | A normal user calls DELETE /users/42 or /admin/… — only the UI hid the button |
Deny by default; check the role on every endpoint, on the server |
| API6 | Unrestricted Access to Sensitive Business Flows | Bots buy all tickets, mass-create accounts, farm referral bonuses — each single call is legitimate | Find the flows that hurt when automated; add per-user limits, CAPTCHA, or human checks there |
| API7 | Server-Side Request Forgery (SSRF) | The API fetches a client-supplied URL (webhook, image import) and hits internal services or the cloud metadata endpoint 169.254.169.254 |
Allow-list schemes and hosts; block private IP ranges; don't follow redirects blindly |
| API8 | Security Misconfiguration | Stack traces in errors, CORS * with credentials, missing TLS, unneeded HTTP methods enabled |
Hardened, repeatable configuration; errors without internals (§5); explicit CORS allow-list |
| API9 | Improper Inventory Management | A forgotten /v1 or staging host is still live, unpatched, with real data |
Keep an inventory of all hosts and versions — the OpenAPI spec (§10) is the natural one; retire old versions (§6) |
| API10 | Unsafe Consumption of APIs | Data from a partner API is trusted more than user input and goes straight into SQL or the UI | Treat third-party responses as untrusted input: validate, size-limit, set timeouts |
In a Spring backend, the most common ones look like this:
findByIdAndOwnerId(id, userId), and return 404 if nothing matches, so the object's existence isn't revealed (§4).SecurityFilterChain with anyRequest().denyAll() (or .authenticated()), and use @PreAuthorize on admin operations.RestClient or WebClient.Each pattern is a problem first, then the fix, followed by the details that make it work. What matters is recognising the problem, not reciting the pattern. Patterns 1, 4, 7 and 8 have their HTTP mechanics in §2, §3, §7 and §6; here they are seen from the problem side.
| The problem | The pattern |
|---|---|
| Every operation invents its own URL | 9.1 Resource-oriented design |
| The collection is huge | 9.2 Pagination |
| The client only wants some of the rows, or some of the fields | 9.3 Filtering, sorting and field selection |
| The same request may arrive twice | 9.4 Idempotency |
| Different clients need differently shaped data | 9.5 Backend for frontend |
| Too many services for clients to know about | 9.6 API gateway |
| The work outlasts a request | 9.7 Asynchronous APIs |
| The API has to change under existing clients | 9.8 Versioning and evolution |
Problem. Designing around actions — /createProduct, /getProduct, /deleteProduct — gives every operation a new URL, and every new entity a new set of them. Nothing learned about one part of the API transfers to the next.
Pattern. Design around the things: /products is the collection, /products/42 one product, and the HTTP method says what is done to it. Once a client knows how products work, it knows orders and users.
Details.
order_headers, order_lines) can still be one order resource; a resource can also be a process or a state — a cancellation, a payment-intent./products/42/reviews for "reviews of this product", with each review also reachable at /reviews/{id}.Problem. Returning a whole collection works at 10 rows and breaks at 10,000 or 10 million: the response gets enormous, the database does the work for all of it, and the client drowns.
Pattern. Never return everything; return a page. Page numbers (?page=2&limit=20) for small sets, and for huge, fast-changing data a cursor: "the records after this one" instead of "page 500".
Details.
Always cap the page size on the server — a default (e.g. 20) and a maximum (e.g. 100). An uncapped list endpoint is a denial of service waiting for the data to grow.
Offset vs cursor:
| Offset / page | Cursor (keyset) | |
|---|---|---|
| Jump to page N | yes | no |
| Cost on page 10,000 | grows — the DB still scans skipped rows | constant (WHERE (created_at, id) < (:c, :i)) |
| Rows inserted/deleted while paging | duplicates or skips | stable |
| Use for | admin tables, small sets | feeds, large or changing data, public APIs |
A cursor is an opaque token — base64 of the last row's sort key, ideally signed or encrypted — and clients must not parse or build it. Sort by a unique, stable key (append id as a tie-breaker), or rows with the same timestamp are skipped or repeated at page edges.
Return the navigation: in the body ("next_cursor": "…", "has_more": true) or as Link: <…?cursor=xyz>; rel="next" (RFC 8288). Google AIP-158 names them page_size / page_token / next_page_token. An empty next_page_token means the end.
Total counts are expensive on big tables (COUNT(*) scans). Make them optional (?include_total=true) or approximate.
Problem. Pagination answers how much data; the second question is which. A client that wants laptops under $1,000 sorted by price should not download every product and filter on the phone — and one that needs a user's id, name and email should not get the whole profile.
Pattern. Let the client narrow rows with filters and sorting, and narrow each object with field selection. The client should never download data it does not need.
Details.
?category=laptops&in_stock=true. For ranges, either suffixed names (price_lt=1000, created_after=…) or one structured filter parameter (Google AIP-160 defines a small filter language: filter=price < 1000 AND category = "laptops"). Pick one style for the whole API.- for descending: ?sort=-created_at,name (Zalando). Sorting belongs on the server; the client only ever sees a page.?fields=id,name,email — JSON:API spells it fields[users]=…, Google uses a FieldMask (AIP-157). The opposite direction is expansion: ?expand=customer embeds a related resource instead of forcing a second call. Together they answer most of GraphQL's over-/under-fetching critique without leaving REST.Problem. A customer taps Pay; the payment succeeds; the network dies before the response arrives. The client does the natural thing and retries — and the server charges twice. Every client, proxy and mobile network retries, so this is not an edge case.
Pattern. An idempotency key: the client sends a unique key with the request (Idempotency-Key: abc123), the server stores the result next to it, and a retry with the same key gets the stored result instead of a second payment.
Details.
draft-ietf-httpapi-idempotency-key-header) specifies this, and 409 for a key whose first request is still in flight.Problem. Web, mobile and a TV app all use the same backend, but each needs different data: the phone wants one compact payload, the web dashboard wants more, the TV wants something else. Forcing them through one identical API shape pulls client logic into the backend. The clients are not the problem; the shared shape is.
Pattern. Give each client type its own thin API layer. The mobile BFF calls users, orders and recommendations and merges them into one response shaped for the phone.
Details.
Problem. One service becomes two, then five, then a dozen, and every client has to know where each lives, how login works for it and where to send what.
Pattern. One controlled front door: every request passes through the gateway, which checks who you are, applies rate limits and routes to the right service. The warning: don't turn the gateway into the application — business logic stays in the services.
Details.
Problem. Some work doesn't fit in a request — encoding an uploaded video, generating thumbnails, a large export. Doing it synchronously leaves the client waiting, ties up a connection, and fails with the first timeout.
Pattern. The API answers at once — "job created" — and puts the job on a queue; a worker does the heavy lifting; the client asks "how is job 123 doing?" and hears queued, processing, completed. The request no longer means "do the work now" but "create the job".
Details.
POST /videos → 202 Accepted with Location: /operations/123 (and optionally Retry-After). GET /operations/123 returns the status; when done it links to the result or embeds it, or carries an error in the usual error shape (§5).queued → running → succeeded | failed | cancelled — and timestamps. Allow DELETE or POST …:cancel if cancelling makes sense.Problem. The API works, thousands of apps depend on exactly what it returns, and six months later it has to change. Some changes are safe — a new nickname field is ignored by old apps — and some break every one of them instantly: removing or changing a field clients use.
Pattern. Evolve additively. When a breaking change is truly unavoidable, run two versions side by side: old apps stay on v1, new ones use v2. But don't cut a version for every change. Two live versions are a tool; ten are a mess. Design today's API for tomorrow's clients.
Details. The mechanics (what counts as breaking, where the version goes, Deprecation/Sunset headers) are in §6. Beyond them:
oasdiff breaking, §10).Don't memorise eight tricks — ask what problems this API will have six months from now. Huge data set → pagination. Clients need different data → BFF. Requests might repeat → idempotency. Too many services → gateway. Long-running work → async. The API must change → evolution.
Contract-first means defining what requests and responses look like before implementing them. Concretely, the API description — almost always an OpenAPI document (YAML/JSON; 3.1 is current, aligned with JSON Schema 2020-12, and 3.2 followed in 2025) — is written, reviewed and agreed first, and becomes the single source of truth from which docs, mocks, client SDKs, server stubs and tests are derived.
The alternative, code-first, annotates handlers and generates the spec from code. It is faster to start but tends to produce an API shaped by the framework rather than by the consumer, and the spec is only as good as the annotations someone remembered to update.
$ref reuse, examples, servers, security, plus best practices, upgrade guides and complete example descriptions.type: [string, "null"] instead of nullable).Both teach writing the description; the design decisions are sections 1–9 above.
Error, PageInfo, Money) via $ref.A minimal fragment:
openapi: 3.1.0
info: { title: Shop API, version: 1.0.0 }
paths:
/products/{id}:
get:
operationId: getProduct
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
"200":
description: The product
content:
application/json:
schema: { $ref: "#/components/schemas/Product" }
"404":
description: No such product
content:
application/problem+json:
schema: { $ref: "#/components/schemas/Problem" }
components:
schemas:
Product:
type: object
required: [id, name, price_minor]
properties:
id: { type: string }
name: { type: string }
price_minor: { type: integer, format: int64 }
Problem:
type: object
properties:
type: { type: string, format: uri }
title: { type: string }
status: { type: integer }
detail: { type: string }
| Stage | Tools |
|---|---|
| Authoring | Any editor with an OpenAPI extension (VS Code: Redocly, 42Crunch); Swagger Editor; Stoplight Studio; TypeSpec (Microsoft — a compact language that compiles to OpenAPI, good for large APIs); Apicurio Studio |
| Linting / style guide | Spectral (Stoplight; custom rulesets, ships OWASP and many company rulesets); Redocly CLI (redocly lint, also bundling); Vacuum (fast Spectral-compatible) |
| Mocking | Prism (Stoplight; mock server from the spec, also a validating proxy); Microcks; Mockoon |
| Server code generation | oapi-codegen (Go: types + server interface for net/http, chi, Echo, Gin); ogen (Go); OpenAPI Generator (dozens of languages); FastAPI goes the other way (code-first) |
| Client generation | OpenAPI Generator; openapi-typescript + openapi-fetch (TypeScript types, tiny runtime); Orval, Kiota (Microsoft), Speakeasy and Stainless (commercial SDK generators) |
| Runtime validation | kin-openapi's validation middleware (Go); express-openapi-validator (Node); committee (Ruby); openapi-core (Python) |
| Contract testing | Schemathesis (property-based tests and fuzzing from the spec); Dredd (example-based, largely unmaintained); Pact (consumer-driven contracts — the consumer's expectations, not the spec, are the contract; complements OpenAPI) |
| Breaking-change detection | oasdiff (oasdiff breaking old.yaml new.yaml, CI-friendly); openapi-diff; Optic |
| Documentation | Redoc; Swagger UI; Scalar; Stoplight Elements |
| Gateways reading the spec | AWS API Gateway, Azure API Management, Kong — import OpenAPI for routing and validation |
A common lean setup: OpenAPI 3.1 in the repo → Spectral in CI → Prism for frontend mocks → oapi-codegen (Go server) and openapi-typescript (client) → oasdiff on every merge request → Redoc or Scalar for docs.
oneOf, nullable fields or 3.1 features poorly. Check the generator against your schema patterns before committing to it.| Do | Don't |
|---|---|
Plural nouns: /products/123 |
Verbs: /getProduct?id=123, /products/123/delete |
| Let the method be the verb | Tunnel everything through POST (POST /api with {"action": …}) |
| Shallow nesting, top-level access by ID | Five-level URL hierarchies |
| One casing, one error shape, one pagination style | A different convention per team or endpoint |
Precise status codes; 201 + Location on create |
200 OK with {"success": false} |
Problem Details error bodies with stable type |
Stack traces, SQL errors, free-text only |
Cap limit, cursor-paginate large sets |
Unbounded lists; deep OFFSET on big tables |
| Server-side filter/sort on whitelisted fields | Client fetching everything to sort locally; arbitrary SQL-ish filters |
Idempotency-Key on POSTs that create things |
Assuming the network never retries |
ETag/If-Match or a version field on updates |
Last write silently wins |
Additive evolution, tolerant readers, /v1 for majors |
Breaking changes in place; a new version for every additive field |
| Integer minor units or decimal strings for money | Floats for money |
| ISO 8601 / RFC 3339 timestamps in UTC | Locale-formatted dates, ambiguous epoch units |
| Contract written first and linted (§10) | Docs written after the fact and drifting from the code |
| Per-object authorization on every request | "Logged in" treated as "allowed" |
Minutes 35–51 ("RESTful API Design") of Hayk Simonyan, How to Design APIs Like a Senior Engineer (https://www.youtube.com/watch?v=7iHl71nt49o), the starting point for chapters 1–8. Chapter 9 is based on Visualcoders' 8 API Design Patterns Every Senior Developer Should Know (https://www.youtube.com/watch?v=jCs7ld1ROcw).
Sources referred to above:
| Short name | What it is |
|---|---|
| Fielding | Roy Fielding's 2000 dissertation, ch. 5 — where REST is defined |
| RFC 9110 | HTTP Semantics (2022) — methods, status codes, conditional requests |
| RFC 9457 | Problem Details for HTTP APIs (2023, replaces RFC 7807) |
| RFC 8288 | Web Linking — the Link header |
| RFC 6585 | Adds 429 Too Many Requests |
| Google AIP | Google's API Improvement Proposals, https://google.aip.dev |
| Microsoft | Microsoft REST API Guidelines, https://github.com/microsoft/api-guidelines |
| Zalando | Zalando RESTful API Guidelines, https://opensource.zalando.com/restful-api-guidelines/ |
| OpenAPI | OpenAPI Specification, https://spec.openapis.org/oas/latest.html |
| OWASP API Top 10 | OWASP API Security Top 10, 2023 edition, https://owasp.org/API-Security/editions/2023/en/0x11-t10/ |