REST, Done Properly
The Richardson Maturity Model: resources, HTTP verbs, and status codes used correctly - and where HATEOAS/hypermedia genuinely helps versus where it's overkill.
On this page
An API is different from internal code in one terrifying way: you can't refactor it freely, because you can't see or control who depends on it. A mobile app you don't own, a partner's integration, a script written two years ago - all coupled to your promises. That makes API design a senior discipline, and it starts with getting the fundamentals right. Most "REST" APIs aren't especially RESTful; the Richardson Maturity Model is a useful ladder for seeing how far up you actually are.
The maturity ladder
Leonard Richardson's model describes four levels of REST adoption:
- Level 0 - one endpoint, one verb. Everything is
POST /apiwith an action in the body ({"action": "getAccount"}). This is RPC-over-HTTP wearing a REST costume - HTTP is just a tunnel. - Level 1 - resources. You have multiple URIs for different things (
/accounts,/transactions) instead of one god-endpoint, but you stillPOSTeverything. - Level 2 - HTTP verbs and status codes. You use
GET,POST,PUT,PATCH,DELETEfor their real meanings and return proper status codes (200,201,404,409). This is where most good APIs live, and it's a perfectly respectable place to be. - Level 3 - hypermedia (HATEOAS). Responses include links telling the client what it can do next, so the API is self-describing and navigable.
The ladder isn't a mandate to reach Level 3; it's a lens. Aim solidly for Level 2, and adopt Level 3 where it earns its keep.
Level 2: verbs and status codes as the contract
The heart of practical REST is using HTTP's own semantics instead of inventing your own:
GET /accounts/42 β 200 with the account (safe, cacheable, no side effects)
GET /accounts/999 β 404 Not Found
POST /accounts β 201 Created + Location header (create; NOT idempotent)
PUT /accounts/42 β 200 (full replace; idempotent - same call, same result)
PATCH /accounts/42 β 200 (partial update)
DELETE /accounts/42 β 204 No Content (idempotent)Two properties matter enormously and come free if you follow the verbs:
- Safe methods (
GET) have no side effects - so proxies and browsers may cache and prefetch them. Never hide a state change behind aGET. - Idempotent methods (
GET,PUT,DELETE) produce the same result if repeated - so a client can safely retry after a timeout.POSTis not idempotent, which is exactly why creation needs idempotency keys (a later lesson).
Status codes are part of the contract too: 2xx success, 4xx the client's fault (don't retry as-is), 5xx the
server's fault (safe to retry). Returning 200 {"error": "not found"} throws all that away - the client can't
tell success from failure without parsing your prose.
Level 3: HATEOAS - and when it's overkill
At Level 3, a response carries links to related actions, so clients follow links rather than hard-coding URLs:
{ "id": 42, "balance": "100.00", "status": "ACTIVE",
"_links": {
"self": { "href": "/accounts/42" },
"freeze": { "href": "/accounts/42/freeze" }, // available because ACTIVE
"close": { "href": "/accounts/42/close" }
} }The promise is decoupling - the server can change URLs and available actions, and a link-following client adapts. Spring HATEOAS supports it. But be honest: most clients ignore the links and hard-code URLs anyway, and the extra payload and machinery often outweigh the benefit. HATEOAS shines for long-lived, widely-integrated, workflow-heavy APIs; for a straightforward internal service, Level 2 is the pragmatic sweet spot.
Verbs in URLs are the giveaway
The single most common REST mistake is putting the action in the URL: POST /accounts/42/getBalance, POST /createAccount, GET /deleteAccount?id=42. That's Level 0/1 thinking - RPC in disguise. The resource is a noun (/accounts/42); the verb is the HTTP method. If your URLs contain verbs, you're tunneling actions through POST and losing caching, idempotency, and the shared vocabulary that makes HTTP useful.
Level 0 is a single window where you shout every request - 'I'd like to order,' 'now I'd like to pay,' 'now check my bill' - and the staff parse your words each time; HTTP is just the window. Level 2 is a proper restaurant with a shared protocol everyone already knows: you point at a table (a resource URL), and standard gestures mean standard things - sitting means 'serve me' (GET), raising a hand means 'the bill' (a specific verb). Nobody re-explains the rules; the conventions carry the meaning. Level 3 adds a menu on each plate listing what you can do next. Most diners are happy with a great Level-2 restaurant; the printed-next-steps menu is a nice touch that only some venues need.
An API has these endpoints: POST /api/getUser?id=5, POST /api/updateUser, POST /api/deleteUser, and all of them
return 200 with a body like {"success": false, "reason": "not found"} on failure. Identify which Richardson level
this is at and redesign the four concerns to reach Level 2, noting the properties you gain.
At Level 2 of the Richardson Maturity Model, why do the HTTP verbs matter so much?
Key takeaways
- An API is a contract with consumers you can't see or force to upgrade, which is what makes its design and evolution a senior discipline.
- The Richardson Maturity Model is a lens: Level 0 (RPC tunnel), Level 1 (resources), Level 2 (HTTP verbs + status codes), Level 3 (hypermedia/HATEOAS).
- Aim solidly for Level 2: resources as nouns, correct verbs, and honest status codes (2xx/4xx/5xx) as part of the contract.
- Following the verbs gives you safety (cacheable, side-effect-free GET) and idempotency (retry-safe PUT/DELETE) for free; never hide a state change behind GET.
- HATEOAS (Level 3) adds self-describing links and helps long-lived, widely-integrated APIs, but is often overkill - most clients hard-code URLs anyway.