DEV Community

Cover image for REST (Representational State Transfer)
Rhuturaj Takle
Rhuturaj Takle

Posted on

REST (Representational State Transfer)

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

  1. Introduction
  2. REST Is an Architectural Style, Not a Checklist of HTTP Verbs
  3. Resources and URLs: What a URL Should Actually Name
  4. Safety and Idempotency: Precise, Testable Properties
  5. GET: Safe and Idempotent
  6. POST: Neither Safe Nor Idempotent
  7. PUT: Idempotent, Full Replacement
  8. PATCH: Partial Modification, Idempotency Not Guaranteed
  9. DELETE: Idempotent Removal
  10. Status Codes: Communicating Outcome Precisely
  11. Statelessness: The Server Remembers Nothing Between Requests
  12. HATEOAS: The Constraint Almost Everyone Skips
  13. The Richardson Maturity Model
  14. Content Negotiation
  15. Versioning a REST API
  16. Common Pitfalls
  17. Quick Reference Table
  18. 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)
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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).
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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" }
]
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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" }
  }
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
[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); }
Enter fullscreen mode Exit fullscreen mode

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").
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)