REST (Representational State Transfer)
A deep-dive walkthrough of REST as an architectural style — covering Roy Fielding's original constraints (not just "use HTTP verbs"), safety and idempotency as precise, testable properties of each HTTP method, the genuine PUT-vs-PATCH distinction, proper status code usage, resource-oriented URL design, the Richardson Maturity Model as a way to measure how "RESTful" an API actually is, HATEOAS — the most cited, least implemented constraint — and where pragmatic, real-world APIs deliberately diverge from strict REST and why that's often a reasonable trade-off.
Table of Contents
- Introduction
- REST Is an Architectural Style, Not a Checklist of HTTP Verbs
- Resources and URLs: What a URL Should Actually Name
- Safety and Idempotency: Precise, Testable Properties
- GET: Safe and Idempotent
- POST: Neither Safe Nor Idempotent
- PUT: Idempotent, Full Replacement
- PATCH: Partial Modification, Idempotency Not Guaranteed
- DELETE: Idempotent Removal
- Status Codes: Communicating Outcome Precisely
- Statelessness: The Server Remembers Nothing Between Requests
- HATEOAS: The Constraint Almost Everyone Skips
- The Richardson Maturity Model
- Content Negotiation
- Versioning a REST API
- Common Pitfalls
- Quick Reference Table
- Conclusion
Introduction
REST — Representational State Transfer — is an architectural style Roy Fielding defined in his 2000 doctoral dissertation, describing a set of constraints that, taken together, produce systems with specific, desirable properties: scalability, a uniform interface, and independent evolvability of client and server. In practice, "REST" has become shorthand in most of the industry for "an HTTP API using GET/POST/PUT/DELETE with JSON," which captures only a fraction of what Fielding actually described — and understanding the fuller picture (safety, idempotency as precise properties, HATEOAS, statelessness) is what separates an API that's genuinely well-designed by REST's own logic from one that merely uses HTTP verbs as convenient action names. This guide goes deep on both halves: the pragmatic, everyday HTTP-verb-and-status-code discipline most real-world "RESTful" APIs actually practice, and the fuller architectural constraints that discipline is loosely derived from.
GET /orders/42 → safe, idempotent → READ, no side effects, repeatable with the SAME result
POST /orders → NEITHER → CREATE, each call can produce a NEW resource
PUT /orders/42 → idempotent → REPLACE the whole resource; repeating has the SAME end state
PATCH /orders/42 → NOT guaranteed → PARTIALLY modify; repeating can have DIFFERENT effects
DELETE /orders/42 → idempotent → REMOVE; repeating leaves the SAME end state (gone)
1. REST Is an Architectural Style, Not a Checklist of HTTP Verbs
What Fielding's dissertation actually describes: a set of architectural constraints
Client-Server: separation of concerns between the UI and data storage.
Statelessness (Section 10): no client context stored on the server BETWEEN requests.
Cacheability: responses must define themselves as cacheable or not.
Uniform Interface (including HATEOAS, Section 11): a consistent way of
identifying and interacting with resources, and of DISCOVERING what's
possible next.
Layered System: a client can't necessarily tell whether it's talking
directly to the origin server or an intermediary.
Code-on-Demand (optional): servers can extend client functionality by
transferring executable code.
This is the actual, complete set — worth knowing that "use nouns in URLs and the right HTTP verb" (the part of REST most APIs actually implement) is really just one practical consequence of the Uniform Interface constraint, and that several other constraints (statelessness, HATEOAS specifically) are just as central to Fielding's original definition but are far less commonly, and far less rigorously, implemented in real-world "REST APIs."
Why this gap between "REST" and "RESTful in Fielding's full sense" matters to know about, even if you don't implement every constraint
Most real-world "REST APIs" are better described, more precisely, as
"HTTP APIs following REST-inspired conventions" — this isn't a
criticism; it's an accurate, useful distinction, because knowing WHICH
constraints you're following and WHICH you're deliberately skipping
(and WHY) is what lets you make that trade-off consciously, rather than
assuming you're "doing REST" when you're actually doing something
looser and, for many real applications, entirely reasonable.
This framing — knowing the full picture specifically so you can make an informed, deliberate choice about which parts to adopt — is the spirit this whole guide is written in, and Section 12's Richardson Maturity Model gives you a concrete way to locate exactly where on that spectrum a given API actually sits.
2. Resources and URLs: What a URL Should Actually Name
A URL identifies a RESOURCE (a noun) — not an action (a verb)
✅ GET /orders/42 — "the order with ID 42"
✅ POST /orders — "create something within the orders collection"
❌ GET /getOrder?id=42 — the VERB is redundant; GET already means "read," and the URL should
be a resource, not an RPC-style function call
❌ POST /orders/42/cancelOrder — mixing a resource path with an ACTION name baked into the URL
This is the most visible, most widely-adopted piece of REST's Uniform Interface constraint: the URL's job is to identify what you're operating on; the HTTP method's job is to say what kind of operation you're performing on it — conflating the two (verbs baked into URLs) undermines the very division of labor that makes the interface "uniform" across every resource in the API.
Collections and individual resources, with a consistent pluralization convention
/orders — the COLLECTION of all orders
/orders/42 — one SPECIFIC order within that collection
/orders/42/items — the SUB-COLLECTION of line items belonging to order 42
/orders/42/items/7 — one specific line item within THAT sub-collection
This nested, hierarchical structure — collection, then a specific member, optionally followed by a sub-collection of that member's own related resources — is the standard, idiomatic URL shape most REST APIs converge on, and it's worth following consistently across an entire API rather than mixing conventions (singular here, plural there) endpoint by endpoint.
When an action genuinely doesn't map cleanly onto CRUD, and how to handle it without abandoning resource-orientation
"Cancel an order" isn't cleanly CRUD — but modeling it as creating a
NEW, specific resource keeps the interface uniform:
✅ POST /orders/42/cancellation — CREATING a "cancellation" resource FOR order 42
(the cancellation itself becomes a resource you could
later GET to see when/why it happened)
This is a genuinely useful pattern worth knowing for the common "not every operation is naturally Create/Read/Update/Delete" problem — rather than reaching for an RPC-style verb-in-the-URL (/orders/42/cancel), reframing the action as creating a new resource that represents the action (a cancellation, a payment, an approval) keeps the API's interface uniform and resource-oriented, and has the added benefit that the created resource itself becomes something you can subsequently GET to see its details.
3. Safety and Idempotency: Precise, Testable Properties
These are precise, technical terms — not vague synonyms for "well-behaved"
SAFE: the request produces NO SIDE EFFECTS on the server — the server's
state is IDENTICAL before and after, regardless of how many times, or
whether at all, the request is made. (Purely informational logging/
metrics as a byproduct doesn't count against safety — the RESOURCE
STATE itself must be unchanged.)
IDEMPOTENT: making the SAME request multiple times produces the SAME
end state as making it exactly once — the request CAN have side
effects (unlike "safe"), but repeating it doesn't compound or change
that effect further.
Every safe request is automatically idempotent (if nothing changes at all, repeating it obviously can't produce a different result) — but not every idempotent request is safe (an idempotent request can genuinely change server state, just in a way that repeating doesn't change further). This distinction is the actual, technical foundation the rest of this guide's per-method sections build on.
Why these properties matter practically, not just academically
A client (or an intermediary — a proxy, a load balancer) that doesn't
receive a response due to a network failure needs to know: is it SAFE
to just retry this exact request? For a SAFE or IDEMPOTENT method, yes
— retrying can't make things worse. For a method that's NEITHER,
retrying blindly risks a genuine, harmful duplicate effect (this
series' Payment Processing guide's Section 4 idempotency discussion
covers exactly this risk for payment-specific operations).
This is the concrete, practical payoff of getting safety/idempotency semantics right: HTTP infrastructure (browsers, proxies, retry logic in HTTP client libraries) makes real, automatic decisions based on these properties — a browser will silently retry a failed GET without asking, but won't do the same for a POST, precisely because the underlying protocol's design assumes you've correctly declared which of your methods are safe to retry blindly.
4. GET: Safe and Idempotent
Read-only, by definition and by convention
[HttpGet("orders/{id}")]
public async Task<ActionResult<Order>> GetOrder(int id)
{
var order = await _repository.GetByIdAsync(id);
return order is null ? NotFound() : Ok(order);
}
A GET request should never cause any observable change to server state — this is the contract every piece of HTTP-aware infrastructure assumes, and violating it (a GET endpoint that, say, increments a view counter as a meaningful side effect, or worse, deletes something) is a genuine, real violation that can produce surprising behavior when a browser prefetches a link, or a crawler follows it, or a proxy caches and later re-serves it.
Why GET requests shouldn't have a request body, by convention
While the HTTP spec doesn't strictly FORBID a GET request body, it's
widely unsupported or stripped by intermediaries (caches, proxies,
some server frameworks) — query parameters are the conventional,
reliable way to pass filtering/parameters for a GET, precisely BECAUSE
the whole point of GET is to be a simple, cacheable, safe identifier
of a resource or resource set, not a request carrying meaningful payload data.
5. POST: Neither Safe Nor Idempotent
The standard method for creating a new resource within a collection
[HttpPost("orders")]
public async Task<ActionResult<Order>> CreateOrder(CreateOrderRequest request)
{
var order = await _orderService.CreateAsync(request);
return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order); // 201, with a Location header
}
POST is genuinely neither safe (it has side effects — a new resource exists that didn't before) nor idempotent (calling it again typically creates another new resource, not the same one) — this is exactly why this series' Payment Processing and Order Management guides make such a point of idempotency keys specifically for POST-based creation endpoints: the protocol itself offers no inherent protection against a retried POST producing a duplicate.
201 Created and the Location header: the conventional, complete response to a successful POST
201 Created (Section 9) is the semantically correct status — not 200 OK,
which doesn't specifically communicate "a new resource now exists."
Location header: points to the URL of the NEWLY CREATED resource —
letting the client immediately GET the resource it just created,
without needing to already know the URL scheme.
CreatedAtAction (in the ASP.NET Core example above) is specifically designed to produce both of these correctly — this is a genuinely common, easy-to-overlook detail: returning a bare 200 OK from a creation endpoint, without a Location header, discards information the response is specifically supposed to carry.
6. PUT: Idempotent, Full Replacement
PUT replaces the ENTIRE resource at the given URL — not just some fields
[HttpPut("orders/{id}")]
public async Task<IActionResult> ReplaceOrder(int id, Order fullOrderRepresentation)
{
// fullOrderRepresentation should represent the COMPLETE, intended state of the order —
// any field NOT included is typically treated as being reset to its default/absent state
await _repository.ReplaceAsync(id, fullOrderRepresentation);
return NoContent();
}
This is the precise semantic PUT carries, and it's worth being exact about: a PUT request's body represents the complete, intended state of the resource at that URL — sending a partial representation and expecting only those specific fields to be updated is technically a misuse of PUT's defined semantics (that's PATCH's job, Section 7), even though many real-world APIs do treat PUT more loosely in practice.
Why PUT is genuinely idempotent, mechanically
Sending the SAME complete representation via PUT, twice in a row,
produces the SAME end state both times — the second PUT doesn't ADD
anything or compound any effect; the resource simply ends up looking
EXACTLY the same as it did after the first PUT. This is precisely
what makes PUT safe to retry blindly on a network failure, unlike POST.
PUT can also legitimately CREATE a resource, if the client specifies the ID
[HttpPut("orders/{id}")]
public async Task<IActionResult> UpsertOrder(int id, Order fullOrderRepresentation)
{
var existed = await _repository.ExistsAsync(id);
await _repository.UpsertAsync(id, fullOrderRepresentation);
return existed ? NoContent() : CreatedAtAction(nameof(GetOrder), new { id }, fullOrderRepresentation);
}
Worth knowing this is a legitimate, spec-compliant use of PUT, distinct from POST's creation role — when the client determines the resource's identifier (rather than the server generating one), PUT to that specific, client-known URL is the semantically correct way to create it, still remaining fully idempotent (sending the same PUT again just re-confirms the same end state, whether the resource already existed or was just created).
7. PATCH: Partial Modification, Idempotency Not Guaranteed
PATCH modifies specific fields, without requiring the full resource representation
[HttpPatch("orders/{id}")]
public async Task<IActionResult> UpdateOrderStatus(int id, JsonPatchDocument<Order> patchDoc)
{
var order = await _repository.GetByIdAsync(id);
patchDoc.ApplyTo(order); // applies ONLY the specified changes
await _repository.UpdateAsync(order);
return NoContent();
}
PATCH is precisely the method PUT's "must send the complete representation" constraint made necessary — for updating just one or two fields on a large resource, requiring the client to re-send the entire object (as strict PUT semantics demand) is often needlessly wasteful, and PATCH exists specifically to express a partial modification instead.
Why PATCH is NOT guaranteed to be idempotent, and a concrete example of why
// A PATCH representing "increment the quantity by 1" is NOT idempotent —
// applying it TWICE produces a DIFFERENT end state (quantity +2) than applying it ONCE (+1)
{ "op": "increment", "path": "/quantity", "value": 1 }
// A PATCH representing "set the quantity TO 5" IS idempotent, since it's
// effectively the SAME kind of full-value-assignment PUT does, just for ONE field
{ "op": "replace", "path": "/quantity", "value": 5 }
This is a genuinely important, precise distinction worth understanding rather than assuming PATCH is automatically idempotent just because it "sounds like" a smaller version of PUT — whether a specific PATCH request is idempotent depends entirely on what the patch operation actually says: a "set this field to this absolute value" patch is idempotent; an "adjust this field relative to its current value" patch is not, and the HTTP spec itself explicitly does not guarantee PATCH idempotency the way it does for PUT.
JSON Patch (RFC 6902): the standard, structured format for expressing a PATCH body
[
{ "op": "replace", "path": "/status", "value": "Shipped" },
{ "op": "add", "path": "/tags/-", "value": "priority" }
]
Rather than inventing a bespoke, ad hoc partial-update format per API, JSON Patch (which ASP.NET Core's JsonPatchDocument<T> directly supports, per the code example above) is a standardized way to express a sequence of specific operations (add, remove, replace, move, copy, test) against a JSON document — worth knowing it exists as the "proper," standards-based way to implement PATCH, as opposed to the simpler, less formally correct but very common alternative of just sending a partial JSON object and merging it field-by-field.
8. DELETE: Idempotent Removal
Removing a resource, with idempotency defined in terms of the resource's ABSENCE, not the response itself
[HttpDelete("orders/{id}")]
public async Task<IActionResult> DeleteOrder(int id)
{
await _repository.DeleteAsync(id); // deleting something ALREADY gone is typically a no-op, not an error
return NoContent();
}
DELETE is idempotent in a specific, worth-clarifying sense: the end state — this resource no longer exists — is the same whether you call DELETE once or five times. The response to the second, third, etc. call might reasonably differ (some APIs return 404 on a repeat delete since the resource genuinely isn't there anymore; others return 204 regardless, treating "already gone" as an equally successful outcome) — but the underlying resource state itself doesn't change further after the first successful deletion, which is what idempotency, precisely defined (Section 3), actually requires.
9. Status Codes: Communicating Outcome Precisely
The five classes, and what each broadly signals
1xx Informational: rarely used directly by application code.
2xx Success: the request was received, understood, and accepted.
3xx Redirection: further action is needed to complete the request.
4xx Client Error: the request itself was flawed (bad syntax, unauthorized, not found).
5xx Server Error: the server failed to fulfill a genuinely valid request.
The status codes worth knowing precisely, not just approximately
200 OK — generic success, with a response body
201 Created — a NEW resource was created (pair with a Location header, Section 5)
204 No Content — success, but genuinely NOTHING to return (a common PUT/DELETE response)
400 Bad Request — the request itself is malformed or fails validation
401 Unauthorized — NOT AUTHENTICATED (a genuinely confusing name — this series' Authentication
guide's Section 12 covers WHY this differs from 403)
403 Forbidden — AUTHENTICATED, but not ALLOWED (this series' Authorization guide's whole subject)
404 Not Found — no resource exists at this URL
409 Conflict — the request conflicts with the resource's CURRENT state
(a classic example: two concurrent updates racing, per this series'
High-Volume Transaction Processing guide's optimistic concurrency discussion)
422 Unprocessable Entity — syntactically valid, but semantically invalid (e.g., a business rule violation)
429 Too Many Requests — per this series' Rate Limiter guide, exactly the response that guide's Section 8 covers
500 Internal Server Error — an unexpected failure on the server's side
The 401 vs. 403 distinction is worth calling out specifically, since it's genuinely, commonly confused: 401 means "I don't know who you are, or your credentials weren't valid" (an authentication failure, per this series' Authentication guide); 403 means "I know exactly who you are, and you're not allowed to do this" (an authorization failure, per this series' Authorization guide) — precisely mapping onto the authentication-vs-authorization distinction both of those guides establish.
Why picking the precise, correct status code matters beyond pedantry
HTTP-aware infrastructure (caches, retry logic, monitoring/alerting
systems) makes real decisions based on the STATUS CODE CLASS —
returning 200 with an error message embedded in the response BODY,
rather than an actual 4xx/5xx status, defeats this infrastructure
entirely: a cache might cache an ERROR as if it were a valid success,
a monitoring system won't flag it as the failure it actually was.
This is a real, practical consequence worth internalizing — "always return 200 and put the real status in the response body" is a genuinely common anti-pattern that discards the very information HTTP's status code mechanism exists to convey unambiguously to every layer of infrastructure sitting between client and server, not just to the application code that happens to read the response.
10. Statelessness: The Server Remembers Nothing Between Requests
Every request must contain everything needed to understand and process it, independent of any prior request
❌ Server-side "session state" that a subsequent request implicitly
relies on (e.g., "the last order the client was looking at" stored
in server memory, referenced by an earlier request but not resent).
✅ Every request carries its OWN complete context — an auth token
(this series' Authentication guide), the specific resource ID in the
URL, any needed parameters — nothing is assumed to be "remembered"
from a previous interaction.
This is Fielding's statelessness constraint, and it's precisely why this series' ASP.NET Core Dependency Injection guide's Scoped lifetime (one instance per request) and this series' ASP.NET Core Authentication guide's token-based schemes (each request independently carrying its own credential) fit REST's model so naturally — a stateless server has no per-client memory to manage between requests, which is exactly what makes it trivial to scale horizontally: any server instance can handle any request, since no instance holds state a specific client's next request depends on.
Why this doesn't mean "no state anywhere" — it means no CLIENT SESSION state on the server
The underlying RESOURCE state (an order's status, a user's profile) is
absolutely still stored and persisted — statelessness specifically
refers to the server NOT remembering anything about a particular
CLIENT'S INTERACTION HISTORY between one request and the next.
Worth clarifying this distinction precisely, since "stateless" is easy to over-generalize — a REST API's underlying data is obviously stateful (that's the whole point of having a database); what's specifically prohibited is the server holding onto conversational context tied to a particular client across separate requests, the way a traditional server-rendered web app's in-memory session state historically did.
11. HATEOAS: The Constraint Almost Everyone Skips
Hypermedia As The Engine Of Application State — responses should tell the client what it can do NEXT
{
"id": 42,
"status": "Pending",
"total": 99.99,
"_links": {
"self": { "href": "/orders/42" },
"cancel": { "href": "/orders/42/cancellation", "method": "POST" },
"items": { "href": "/orders/42/items" }
}
}
This is the constraint Fielding himself has, on record, called the one most commonly missing from APIs that call themselves REST — the idea is that a response shouldn't just carry data, it should carry links describing the legitimate next actions available from this current state, meaning a client can navigate an entire API starting from just one entry point, discovering available operations dynamically, rather than needing hardcoded, out-of-band knowledge of every possible URL and transition baked into the client itself.
Why this matters, in principle: true decoupling of client and server evolution
Without HATEOAS, a client has HARDCODED knowledge of every URL it might
ever need to construct ("to cancel an order, POST to
/orders/{id}/cancellation") — if the server later changes that URL
scheme, every client needs updating too. WITH HATEOAS, the client
follows a LINK the server itself provided in a prior response — if the
server changes the URL, the client's behavior doesn't need to change
AT ALL, since it never hardcoded the URL in the first place.
This is the genuine, principled payoff HATEOAS is meant to provide — a real, meaningful decoupling between client and server implementation details, letting the server's URL structure evolve freely as long as the relationships (what "cancel" means, semantically) stay consistent.
Why HATEOAS is so rarely, fully implemented in practice — the honest, practical trade-offs
- Genuine implementation complexity: every response needs to compute and
include the CORRECT set of currently-valid links, which depends on the
resource's current state (you can't "cancel" an already-shipped order,
so that link shouldn't even appear).
- Client-side complexity: a client genuinely following links dynamically,
rather than hardcoding URLs, is meaningfully more complex to write than
one that just knows the URL scheme upfront — and most real-world API
CONSUMERS (mobile apps, frontend SPAs) are developed in close
coordination with the API anyway, reducing the practical need for this
level of decoupling.
- Tooling and ecosystem: most API client generators, SDKs, and developer
expectations are built around fixed, documented URL schemes (OpenAPI/
Swagger specs list exact paths) — HATEOAS's dynamic-discovery model
sits somewhat outside that dominant tooling ecosystem.
This is worth stating honestly rather than treating the near-universal absence of HATEOAS as an industry-wide mistake — for many real applications, where client and server are developed together and versioned together, the decoupling HATEOAS provides genuinely isn't worth its real implementation cost, and skipping it is a reasonable, deliberate engineering trade-off, not ignorance of the constraint. Section 12's maturity model gives you a precise way to describe exactly where that trade-off lands your own API.
12. The Richardson Maturity Model
A framework (by Leonard Richardson) for measuring how far an API actually goes toward Fielding's full REST model
Level 0: The Swamp of POX — a single URL, everything is a POST
(essentially RPC-over-HTTP, using HTTP merely as a transport).
Level 1: Resources — multiple URLs exist, one per resource, but still
mostly using ONE HTTP method (often just POST) for everything.
Level 2: HTTP Verbs — the GET/POST/PUT/PATCH/DELETE semantics from THIS
guide's Sections 4-8 are used correctly, along with proper status
codes (Section 9). This is where the VAST MAJORITY of real-world
"REST APIs" actually sit.
Level 3: Hypermedia Controls — HATEOAS (Section 11) is genuinely
implemented; responses include links describing available next actions.
This is a genuinely useful, precise vocabulary worth adopting — rather than a binary "is this REST or not," the Richardson Maturity Model lets you describe exactly how far an API goes, and most APIs that industry convention calls "RESTful" are honestly, accurately described as Level 2 — correct resource orientation and HTTP semantics, without the fuller hypermedia-driven discovery Fielding's original model describes.
Why Level 2 is a genuinely reasonable, common destination, not a failure to reach Level 3
Per Section 11's honest cost/benefit discussion: Level 2 captures the
overwhelming majority of REST's PRACTICAL benefits (a clean, resource-
oriented, cacheable, HTTP-semantics-respecting interface) at a
fraction of Level 3's implementation and consumption complexity —
for most APIs, this is a genuinely sound, deliberate stopping point.
13. Content Negotiation
Letting the client specify what representation format it wants, via the Accept header
GET /orders/42 HTTP/1.1
Accept: application/json
[HttpGet("orders/{id}")]
[Produces("application/json", "application/xml")] // this endpoint CAN produce either, based on Accept
public async Task<ActionResult<Order>> GetOrder(int id) { /* ... */ return Ok(order); }
This is the mechanism behind REST's "representation" in "REpresentational State Transfer" — a resource (an order) is a conceptual thing; its representation (a specific JSON document, an XML document, an HTML page) is what actually gets sent over the wire, and content negotiation is the standard HTTP mechanism letting the client and server agree on which representation format to use for a given exchange, without needing separate URLs per format.
Content-Type for the request body, Accept for the desired response — a distinction worth keeping precise
Content-Type header: describes the format of the REQUEST BODY the
client is SENDING (e.g., "I'm sending you JSON").
Accept header: describes the format(s) the client would like the
RESPONSE BODY to be in (e.g., "please respond with JSON, or XML if
JSON isn't available").
14. Versioning a REST API
The genuine tension: an API's contract needs to evolve, but breaking existing clients is costly
Per Fielding's original vision (Section 11's HATEOAS discussion), a
TRULY hypermedia-driven API could evolve its URL structure freely
without breaking clients — in practice, at Richardson Level 2
(Section 12), clients DO hardcode URLs and response shapes, which
means a genuine breaking change needs an explicit versioning strategy.
The common strategies, each with real trade-offs
URL path versioning: /v1/orders/42 — simple, highly visible, but
"pollutes" the URL with something
that isn't really part of the RESOURCE's identity
Query string versioning: /orders/42?version=1 — similarly simple; some
consider it a cleaner separation
of "what" from "which version of the contract"
Header versioning: Accept: application/vnd.myapi.v1+json — keeps the
URL itself clean and stable,
treating the version as PART of
content negotiation (Section 13) —
more "correct" per REST's own
resource-identity philosophy, but
less DISCOVERABLE/visible to a
developer just reading a URL
Worth presenting as a genuine, ongoing trade-off rather than a single settled best practice — URL path versioning is by far the most common in real-world practice specifically because of its visibility and simplicity, even though header-based versioning arguably aligns more precisely with REST's own principle that a URL identifies a resource, not a specific version of its contract.
15. Common Pitfalls
| Pitfall | Why it hurts | Better approach |
|---|---|---|
Verbs baked into URLs (/getOrder, /cancelOrder) |
Undermines the uniform interface — the URL should name a resource; the HTTP method should express the action | Model actions as resources when they don't map cleanly onto CRUD (Section 2's cancellation example) |
| Treating GET as safe to have side effects | Breaks assumptions baked into browsers, proxies, and crawlers, which may retry, prefetch, or cache GET requests freely | Never mutate state in a GET handler; keep it genuinely safe per Section 3-4's precise definition |
| Assuming PATCH is automatically idempotent because it "feels smaller" than PUT | A relative/incremental patch operation is genuinely NOT idempotent, unlike an absolute-value one | Design PATCH operations (or use JSON Patch's replace semantics) to be idempotent where practical; don't assume it by default (Section 7) |
Always returning 200 OK with error details embedded in the response body |
Discards the status-code signal that caches, retry logic, and monitoring systems rely on | Use the precise, correct status code (Section 9) for every outcome, reserving the body for additional detail |
| Confusing 401 and 403 | Sends the wrong signal about whether the problem is "who you are" or "what you're allowed to do" | Use 401 for authentication failures, 403 for authorization failures, matching this series' Authentication/Authorization guides' own distinction |
| Storing client-specific session state in server memory between requests | Breaks statelessness, making horizontal scaling and load balancing far harder — a client's next request may land on a different server instance | Include everything a request needs (auth, context) in the request itself, never relying on server-remembered prior interaction (Section 10) |
| Treating "not implementing HATEOAS" as a failure rather than a deliberate trade-off | Leads to either guilt-driven, poorly-motivated over-engineering, or an inaccurate sense that the API "isn't really REST" | Recognize Level 2 (Section 12) as a genuinely reasonable, common destination; implement HATEOAS specifically when its decoupling benefit is actually worth the real cost |
| No versioning strategy decided before the API has real, external consumers | Any future breaking change becomes far more costly to roll out once clients are already depending on the current contract | Decide and document a versioning strategy (Section 14) early, even if the API's first version never actually needs it |
Quick Reference Table
| HTTP Method | Safe? | Idempotent? | Purpose |
|---|---|---|---|
| GET | Yes | Yes | Read a resource or collection |
| POST | No | No | Create a new resource (or a non-CRUD action modeled as one) |
| PUT | No | Yes | Replace a resource's complete representation |
| PATCH | No | Not guaranteed | Partially modify a resource |
| DELETE | No | Yes | Remove a resource |
| Concept | Purpose |
|---|---|
| Resource-oriented URLs | Nouns identify what you're operating on; the HTTP method identifies the operation |
| Status code classes (2xx/4xx/5xx) | Communicates outcome precisely to every layer of HTTP-aware infrastructure, not just application code |
| Statelessness | No client-session memory on the server between requests — enables horizontal scaling |
| HATEOAS | Responses carry links describing valid next actions, decoupling client and server evolution |
| Richardson Maturity Model | A precise vocabulary (Levels 0-3) for how far an API actually goes toward full REST |
| Content negotiation |
Accept/Content-Type headers let client and server agree on representation format |
Conclusion
REST, in Fielding's original, full sense, is a considerably richer architectural style than "use the right HTTP verb" — but the pragmatic subset most real-world APIs actually implement (resource-oriented URLs, precise safety/idempotency semantics per method, correct status codes, statelessness) captures the large majority of REST's genuine, practical benefit, and the Richardson Maturity Model gives you an honest, precise way to describe exactly how far a given API goes beyond that subset, rather than treating "REST" as a binary label that's either fully earned or entirely forfeited. Understanding safety and idempotency as precise, testable properties — not vague synonyms for "well-designed" — is what actually matters for building an API that HTTP's surrounding infrastructure (caches, retry logic, proxies) can interact with correctly and safely, which is the concrete, practical payoff underneath REST's more abstract architectural goals.
HATEOAS deserves the attention this guide gives it specifically because it's simultaneously REST's most central, defining constraint by Fielding's own account, and the one most consistently, deliberately skipped in real-world practice — understanding why it's skipped (genuine implementation and consumption complexity, versus a real but often not-worth-it decoupling benefit) is more valuable than either blindly implementing it everywhere or dismissing it as irrelevant theory; knowing the trade-off precisely is what lets you decide, deliberately, where your own API's design should actually sit.
Found this useful? Feel free to star the repo, open an issue with corrections, or share the retried-a-non-idempotent-POST-and-created-a-duplicate-order incident that made the safety/idempotency distinction click far better than any HTTP spec citation ever could.
Top comments (0)