Complete REST
API Design
REST isn't a technology you install — it's a set of agreements that let millions of clients and servers talk without coordinating. This chapter takes the standard apart, rebuilds it from the scalability crisis that created it, and turns every fuzzy question (plural or singular? PUT or PATCH? which status code?) into a rule you never have to guess about again.
What REST Actually Is
In 1990 Tim Berners-Lee started a project to share knowledge across the world — the World Wide Web. Within about a year he invented essentially everything we still use: the URI (uniform resource identifier), the HTTP protocol, HTML, the first web server, the first browser, and the first WYSIWYG HTML editor built into that browser. Remarkable for one person in twelve months.
Then the problem arrived: scale. The web grew exponentially, far past anything Berners-Lee had planned for. The original design simply could not absorb the user base it was acquiring every day. Around 1993, Roy Fielding — co-founder of the Apache HTTP server project — got concerned about exactly this. To make the web scalable he proposed a set of architectural constraints. He and Berners-Lee then co-wrote the specification for HTTP/1.1, the first standardized version of the protocol. Finally, in 2000, Fielding named and described the whole architectural style in his PhD dissertation: REST — REpresentational State Transfer.
Fielding's dissertation is the original source document for REST. Search "Roy Fielding REST dissertation" — it's a genuinely worthwhile read for any backend engineer, because it explains why these patterns exist, not just what they are.
The name decomposes into three ideas, and each one is a real concept you use every day:
Representational
A resource (a piece of data — a user, a book, a cart) can be represented in different formats depending on who's asking. The same user record might be sent as JSON to an API client (server-to-server), or as HTML to a browser, or as XML to some legacy consumer. The underlying resource is one thing; its representations are many.
State
State is the current condition of a resource — its present attributes. Think of an Amazon shopping cart: its state is the set of items in it, their quantities, and the total price right now. That state lives on the server and is what gets moved around.
Transfer
Because we have a client and a server, the whole point is moving these representations between them. The transfer happens over the common standard — HTTP — using its methods (GET, POST, PUT, PATCH, DELETE, …). When your browser asks for a page with GET, it's transferring a representation from server to client.
REST = resources that have multiple representations, whose state can be transferred between client and server over HTTP, all within a set of constraints that keep the system scalable. You don't need to memorize the theory — but knowing the three words tells you what every API call is really doing: moving a representation of some resource's current state.
The Six Constraints
To be truly "RESTful" — and to get the scalability Fielding was after — a system follows six constraints. Five are required; the sixth is optional. These aren't bureaucracy; each one buys a concrete scaling property.
| Constraint | What it says | What it buys |
|---|---|---|
| 1 · Client–Server | Strict separation of concerns: client owns UI/UX, server owns data & business logic. | Each side evolves independently. |
| 2 · Uniform Interface | One standardized way for all components to talk. (Four sub-constraints below.) | Consistency across every service. |
| 3 · Layered System | Hierarchical layers; a layer only sees the one immediately below it. | Drop in load balancers / proxies without touching core logic. |
| 4 · Cache | Responses must label themselves cacheable or not. | Less server load, faster responses. |
| 5 · Stateless | Server keeps no memory of past requests; each request carries everything it needs. | Any server can handle any request → horizontal scaling. |
| 6 · Code on Demand (optional) | Server may ship executable code (e.g. JavaScript) to extend the client. | Flexible client behavior when needed. |
The two that matter most in practice
Stateless is the heavy hitter. The server does not remember your previous request — every request must contain all the information needed to understand and process it. This is what makes scaling possible: put a load balancer in front of three identical servers, and because no server holds your session in memory, any of them can handle any request via round-robin. Statelessness is the reason you can add servers and they "just work."
Layered System is what statelessness enables. Because the client only ever talks to the layer immediately in front of it, you can slip in CDNs, reverse proxies, load balancers, and API gateways between client and origin — and the client never knows or cares. This is the entire foundation of how a small app grows into one serving millions.
Because no server stores your session, the load balancer can send your requests to server 1, 2, or 3 interchangeably — each one carries everything it needs inside the request. Add a fourth server and it works instantly, no coordination required. That's horizontal scaling, and statelessness is what makes it free.
The four sub-constraints of Uniform Interface
- Resource identification — every resource is addressable by a URI.
- Manipulation through representations — you act on a resource by sending/receiving its representation (the JSON you PUT is how you change it).
- Self-descriptive messages — each message carries enough metadata (headers, content-type, method) to be understood on its own.
- HATEOAS — Hypermedia As The Engine of Application State: responses can include links telling the client what it can do next. (The most aspirational sub-constraint; few APIs implement it fully.)
Why REST Confuses Us Now
Here's the honest backstory the transcript leans on: people still get tripped up by REST — plural or singular path? PUT or PATCH? which status code for a custom action? — and the reason is historical. When these standards were forming, the web ran on MPAs (multi-page applications): every interaction was a full-page request to the server, which rendered HTML and sent it back.
Today we mostly build SPAs (single-page applications): the first request downloads a big bundle of JavaScript, and from then on the browser does its own routing on the client side, fetching data (JSON) from APIs rather than whole pages. The client got heavy; the server became a pure data API. The old standards never anticipated this shift, which is why some of their rules feel ambiguous against modern usage.
Not to invent new rules — the standards already exist — but to extract clear, practical guidelines from them and commit to one consistent style. Once you've internalized the conventions, you stop re-litigating "is this RESTful?" on every endpoint and get to spend your energy on business logic instead.
Anatomy of a Route
Start with a normal website URL and its parts:
https, the encrypted transport.api. subdomain.v1, API versioning via path./ means hierarchy.The industry-standard shape of an API route (a convention, not a hard rule) layers these together: https:// + an api. subdomain + a version like /v1 + the resource path.
Rule 1 — always plural nouns
The resource in the path is always plural, even when fetching a single item. List all books → /books. Fetch one book → /books/123, not /book/123. The resource type ("books") is a collection; the ID just narrows into it. This is the single most common beginner mistake.
Rule 2 — no spaces or underscores; use slugs
URLs travel across many server/client/OS environments, so keep them clean. Never put spaces or underscores in a path. To put a human-readable name in a URL, build a slug:
Harry Potterharry potterharry-potter → /books/harry-potterRule 3 — the slash means hierarchy
A forward slash expresses a hierarchical relationship between resources. /organizations/123/projects reads as "the projects that belong specifically to organization 123." First level: the collection. Next level: a specific member. Next: that member's sub-collection. Design your paths so the nesting tells the story.
Methods & Idempotency
Idempotency is the concept that unlocks "which method should I use?" An operation is idempotent if performing it many times has the same effect on the server as performing it once. The key subtlety: idempotency is about the side effect you cause on the server, not about whether the response bytes are identical.
| Method | Purpose | Idempotent? | Has body? |
|---|---|---|---|
GET | Read / fetch a representation | yes + safe | No |
POST | Create a resource / custom action | no | Yes |
PUT | Replace a resource entirely | yes | Yes (full) |
PATCH | Update part of a resource | usually | Yes (partial) |
DELETE | Remove a resource | yes | No |
HEAD (fetch only headers) and OPTIONS (used in the CORS pre-flight to ask "is this origin allowed?") exist too, but these five carry the real data work.
GET — idempotent & safe
Fetches data; changes nothing on the server. Call it once or a thousand times — the server state is identical. "But what if someone else creates a book between my calls and the response changes?" That doesn't break idempotency: idempotency asks what side effect your call causes, and a GET causes none. Because it's safe, it's freely retryable and cacheable.
On the wire, a GET is just a request line, headers, and a blank line — no body. All input rides in the URL (path + query string), which is why you never put secrets in a GET: the whole URL lands in browser history, server logs, and CDN logs.
| Code | On a GET, this means |
|---|---|
200 OK | Found it; the body is the representation. |
304 Not Modified | Your cached copy is still fresh (you sent If-None-Match); no body returned — saves the payload. |
301 / 302 | Moved; follow the Location header. |
400 Bad Request | Your query string is malformed. |
401 / 403 | Not authenticated / authenticated but not allowed. |
404 Not Found | No such single resource. |
429 Too Many Requests | Rate-limited; check Retry-After. |
PUT & PATCH — idempotent updates
Both update. The difference is scope:
- PATCH — partial update. Send only the fields you want to change (e.g. just
status). Best fit for SPA-era JSON apps, where you almost always update a few fields, not the whole record. - PUT — full replacement. You must send the entire representation; the server replaces what it has with your payload. Omit a field and you've wiped it.
Why idempotent? Say a user's name is A and you PATCH it to B. First call: A → B. Second identical call: B → B. Thousandth call: still B. The end state never changes after the first call, so repeating the same update payload is idempotent.
Developers often use them interchangeably, and internally that's mostly fine. But for a public API, stick to the semantics: PATCH for partial, PUT for full replacement. Consumers assume you follow the standard — if you use PUT where you mean PATCH, you hand them a confusing, wrong assumption. In practice most modern (SPA-driven) APIs lean on PATCH, because you rarely want to replace an entire entity.
On the wire — the body is the whole difference
PUT replaces. If the stored user has name, email, and city, but you PUT only {"city":"Bangalore"}, you've just blanked the name and email. Fields you forget to send are gone. If you only have some of the record in hand, use PATCH — never PUT. This is the #1 way people accidentally destroy data through an API.
Concurrent edits — the lost-update problem
Two clients GET the same record. A changes the email and saves. B (who never saw A's change) saves the city using B's stale copy — and B's write silently overwrites A's email. The fix is optimistic concurrency with ETag + If-Match: the server hands out a version fingerprint, and only applies your write if that version is still current.
func UpdateOrganization(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
org := store.Get(id)
if org == nil {
writeJSON(w, 404, errBody("organization not found"))
return
}
// optimistic concurrency: reject stale writes
if m := r.Header.Get("If-Match"); m != "" && m != org.ETag {
writeJSON(w, 412, errBody("resource changed; re-fetch and retry"))
return
}
var patch map[string]any
json.NewDecoder(r.Body).Decode(&patch) // only the fields to change
updated := store.Patch(id, patch) // merge, don't replace
w.Header().Set("ETag", updated.ETag)
writeJSON(w, 200, updated) // 200 + updated entity
}
| Code | On a PUT / PATCH, this means |
|---|---|
200 OK | Updated; body holds the new state. |
201 Created | PUT only — created the resource at a client-chosen URL (upsert). PATCH never creates. |
204 No Content | Updated; nothing to return. |
400 Bad Request | Body malformed. |
404 Not Found | Resource doesn't exist (PATCH); PUT without upsert. |
409 Conflict | Change collides with current state (e.g. unique-field clash). |
412 Precondition Failed | Stale If-Match — somebody edited it first. |
415 Unsupported Media Type | Wrong Content-Type (esp. PATCH's merge-patch vs json-patch). |
422 Unprocessable Entity | Well-formed JSON, but fails a business rule (e.g. age: -5). |
DELETE — idempotent
Delete user 1. First call: the user is removed (a real side effect). Second call: the server checks, finds no such user, and returns 404 — but nothing changed on the server. No new side effect occurred. You only changed state on the first call; every call after that is a no-op that simply reports "not found." Hence DELETE is idempotent.
POST — the only non-idempotent one
POST creates. Send a "create book" payload once → one book with ID 1. Send the exact same payload again → a second book with ID 2 (IDs are generated server/DB-side, and names usually needn't be unique). Ten identical POSTs → ten distinct books. The side effect changes every time, so POST is non-idempotent.
On the wire, POST carries a body (declared by Content-Type), and a successful create returns 201 with a Location header pointing at the new resource:
Say you POST /payments {amount: 5000} and the network drops after the server charged the card but before the response reached you. You don't know if it worked, so you retry — and now you've charged ₹10,000. The standard fix is an idempotency key: the client sends a unique ID, the server records it with the result, and any retry carrying the same key returns the original result instead of charging again. This is how Stripe and every serious payment API make POST safe to retry.
func CreatePayment(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("Idempotency-Key") // client-generated UUID
// already processed this exact request? return the SAME result, don't re-charge
if prior, ok := idemStore.Get(key); ok {
writeJSON(w, prior.Status, prior.Body)
return
}
var in struct{ Amount int `json:"amount"` }
json.NewDecoder(r.Body).Decode(&in)
payment := charge(in.Amount) // the real, non-idempotent side effect
idemStore.Save(key, 201, payment) // remember it, keyed by the idempotency key
writeJSON(w, 201, payment)
}
| Code | On a POST, this means |
|---|---|
200 OK | Action ran; no new resource created (e.g. "send email", custom actions). |
201 Created | A new resource was created; Location points to it. |
202 Accepted | Queued for async processing — "I'll do it later" (reports, heavy jobs). |
400 Bad Request | Body malformed or missing required fields. |
409 Conflict | Collides with existing state (e.g. "username already taken"). |
413 Payload Too Large | Body exceeds the server's size limit. |
415 Unsupported Media Type | Server doesn't accept the Content-Type you sent. |
422 Unprocessable Entity | Valid JSON, but fails validation at the business-rule level. |
429 Too Many Requests | Rate-limited. |
Custom Actions — beyond CRUD
Sometimes an action doesn't fit Create/Read/Update/Delete. "Clone a project," "archive an organization," "send an email" — these trigger a web of background work beyond a simple database write. The REST spec makes POST the open-ended method for exactly these cases.
The rule & the route shape
When an action doesn't map to a standard method, make it a POST and append the action as a verb at the end of a specific resource path:
This keeps the hierarchy honest: all organizations → one specific organization → the action to run on it.
On the surface, archiving looks like setting status = "archived" — so why not PATCH? Because archiving an organization may trigger far more than a field write: deleting all its projects and their tasks, emailing the owner, revoking access, queuing cleanup jobs. The status flip is a side effect of the action, not the action itself. When the real operation is bigger than the data change, it's a custom action → POST.
The "send email" example
Consider POST /emails with body {"target": "someone@example.com"}. Is sending an email a fetch? No. A create? Not really. An update or delete? No. It's a custom action — so it lives under POST. And note: a custom action POST often returns 200 OK (the action ran), not 201 Created, because nothing new was necessarily created. Never assume "POST ⇒ 201."
List APIs: Page · Sort · Filter
A list endpoint can't just dump every row. Three features make it robust, and all three ride on query parameters.
Pagination — why & how
Returning a thousand records at once is expensive on both ends: JSON serialization/deserialization is heavy work, the network bottlenecks, and the user perceives a multi-second delay — even though on screen they only see the first 10–20 items before scrolling. So the server returns the data in chunks (pages).
A paginated response includes four fields:
data— the array of items for this page.total— the absolute count of items in the DB (so the UI can say "showing 10 of 50").page— which page this response represents.totalPages— the maximum number of pages (the frontend uses this to know when to stop fetching on infinite scroll: whenpage === totalPages, stop).
The client controls it with two params: limit (how many per page) and page (which chunk). And — a sane-defaults moment — if the client sends neither, the server should default page to 1 and limit to something like 10 or 20, never crash.
Sorting
Two params: sortBy (which field) and sortOrder (ascending / descending). Crucial default: even with no sort params, the server must sort by something — otherwise the DB returns rows in arbitrary order and the same call yields different orderings each time. The natural default is sortBy=createdAt, sortOrder=descending (newest first).
Filtering
Pass resource fields as query params to narrow the list: ?status=active, or combine them: ?status=archived&name=org. The server applies each as a filter on the result set.
// GET /v1/organizations?status=active&sortBy=name&sortOrder=ascending&page=1&limit=10
func ListOrganizations(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
// --- sane defaults: never crash if the client omits params ---
page := atoiDefault(q.Get("page"), 1)
limit := atoiDefault(q.Get("limit"), 10)
sortBy := defaultStr(q.Get("sortBy"), "createdAt")
sortOrder := defaultStr(q.Get("sortOrder"), "descending")
filters := map[string]string{}
if s := q.Get("status"); s != "" {
filters["status"] = s // ?status=active
}
rows, total := store.Query(filters, sortBy, sortOrder, page, limit)
totalPages := (total + limit - 1) / limit // ceil division
writeJSON(w, 200, map[string]any{
"data": rows,
"total": total,
"page": page,
"totalPages": totalPages,
})
}
Status Codes — done right
| Code | Meaning | Use it when |
|---|---|---|
200 OK | Success | Fetch, update (PATCH/PUT), or a custom action that ran. |
201 Created | Created | A POST successfully created a new entity. Return the new entity in the body. |
204 No Content | Success, empty body | A successful DELETE — nothing to send back. |
404 Not Found | Resource missing | Client asked for one specific ID that doesn't exist. |
The 404 rule — the one people get wrong
Return 404 only when a client requests a specific single resource that doesn't exist (e.g. GET /users/999). If a client hits a list API and nothing matches (e.g. "all users named Zack," or a filter that matches nothing), do not return 404. Return 200 OK with an empty array []. A list that found nothing still succeeded — it just found nothing.
The five families — read the first digit
Every status code's first digit tells you almost everything. This is the fastest debugging instinct you can build.
| Family | Class | Whose problem |
|---|---|---|
1xx | Informational | Rare; you'll basically never handle these directly. |
2xx | Success | It worked. |
3xx | Redirection | Go look elsewhere / use your cache. |
4xx | Client error | You sent something wrong — fix the request. |
5xx | Server error | The server broke — not your fault; retry / alert ops. |
When a call fails: 4xx → fix your request (check body, auth, URL). 5xx → not your fault; retry with backoff or page the server team. When it succeeds: 20x → done, 30x → follow the redirect. That single split resolves most "what do I do with this response?" questions instantly.
The 4xx codes people confuse
401 vs 403
401 Unauthorized means "I don't know who you are" — no credentials, or they're invalid/expired. The fix is to log in or refresh the token. 403 Forbidden means "I know exactly who you are, and you're not allowed" — valid credentials, insufficient permission. Logging in again won't help. (The names are historically swapped — 401 is really about authentication, 403 about authorization.)
409 Conflict
The request collides with the current state of the resource: creating something that already exists, a unique-field clash, or deleting something other records still depend on ("can't delete a user with active orders").
422 vs 400
400 Bad Request = the request is malformed at the syntax level (broken JSON, a string where a number was required). 422 Unprocessable Entity = the syntax is perfect, but it fails a semantic/business rule (a negative age, an email that isn't an email, an end-date before the start-date). Use 422 when you parsed the body fine but the values don't make sense.
429 Too Many Requests
Rate-limited. Respect the Retry-After header (seconds to wait) instead of hammering, or you'll just stay throttled.
The codes you'll meet 90% of the time
| Code | Plain meaning |
|---|---|
200 | worked |
201 | created (POST success) |
204 | worked, no body (DELETE success) |
301 / 302 | redirect |
304 | your cache is still good |
400 | your request is broken (syntax) |
401 | log in |
403 | logged in, but not allowed |
404 | that one thing doesn't exist |
409 | conflict with current state |
422 | data fails validation (semantics) |
429 | slow down |
500 | they crashed |
503 | they're overloaded / in maintenance |
Worked Example — a PM platform
Following the transcript's build: a project-management product (think Jira / Linear). From the wireframes we extract the nouns → resources: organizations, projects, tasks (also users, tags). Those become tables, then endpoints. Here's the full endpoint set for one resource — the same pattern repeats for every other resource.
| Action | Method + Route | Success |
|---|---|---|
| List organizations | GET /v1/organizations | 200 · paginated |
| Create organization | POST /v1/organizations | 201 · new entity |
| Get one | GET /v1/organizations/:id | 200 / 404 |
| Update (partial) | PATCH /v1/organizations/:id | 200 · updated entity |
| Delete | DELETE /v1/organizations/:id | 204 · empty |
| Archive (custom) | POST /v1/organizations/:id/archive | 200 · archived entity |
Notice the symmetry: list and create share the collection URL (/organizations) and differ only by method — the server routes GET to the list handler and POST to the create handler. Likewise get-one / update / delete share /organizations/:id and differ only by method. The custom action appends a verb. projects would mirror this exactly, with clone as its custom action.
func RegisterOrgRoutes(mux *http.ServeMux) {
// list + create share the collection URL, split by method
mux.HandleFunc("GET /v1/organizations", ListOrganizations)
mux.HandleFunc("POST /v1/organizations", CreateOrganization)
// get-one / update / delete share /:id, split by method
mux.HandleFunc("GET /v1/organizations/{id}", GetOrganization)
mux.HandleFunc("PATCH /v1/organizations/{id}", UpdateOrganization)
mux.HandleFunc("DELETE /v1/organizations/{id}", DeleteOrganization)
// custom action: verb at the end of a specific resource
mux.HandleFunc("POST /v1/organizations/{id}/archive", ArchiveOrganization)
}
func CreateOrganization(w http.ResponseWriter, r *http.Request) {
var in struct {
Name string `json:"name"`
Status string `json:"status"`
Description string `json:"description"`
}
json.NewDecoder(r.Body).Decode(&in)
if in.Status == "" {
in.Status = "active" // sane default — don't force the client to send the obvious
}
org := store.Insert(in.Name, in.Status, in.Description) // id, createdAt set server-side
writeJSON(w, 201, org) // 201 Created + the new entity
}
func DeleteOrganization(w http.ResponseWriter, r *http.Request) {
store.Delete(r.PathValue("id"))
w.WriteHeader(204) // No Content
}
func ArchiveOrganization(w http.ResponseWriter, r *http.Request) {
org := store.Archive(r.PathValue("id")) // flips status + cascades: projects, tasks, emails...
writeJSON(w, 200, org) // custom action → 200, NOT 201
}
Golden Rules
Extract nouns from the UI first
Before writing a line of business logic, look at the wireframes (Figma) or talk to the product people. The nouns users interact with — projects, users, tasks, tags — are your resources. Looking at how the end-user touches data tells you how the lowest layer (the DB) relates to the top layer (the screen), and that's the right place to start designing the interface.
Design the interface before coding
A REST API is designed, not programmed-first. Lay out routes, payloads, and responses in a tool like Insomnia or Postman before touching Go, Python, or any framework. The whole point is a delightful, intuitive, unambiguous interface — so the consumer never has to read your source code or guess your behavior by trial and error.
Provide sane defaults
Never crash because the client omitted something obvious. No page → default 1. No limit → default 10. No sortBy → createdAt descending. No status on create → active. Require only the information you genuinely cannot infer.
Be ruthlessly consistent
- JSON in
camelCase, always — both payloads and responses. - No abbreviations — if a field is
description, never call itdescin another endpoint. You have context the consumer doesn't; an abbreviation they can't decode costs them a failed call and a docs hunt. - Same shapes across resources — if
organizationsare plural and paginated with{data,total,page,totalPages}, thenprojectsandtasksare too. Consumers integrate one endpoint, then assume the rest follow the same style. Reward that assumption.
Following a shared standard removes guesswork, assumptions, and human error from integration. If a consumer can assume your API is ~80% standard-compliant, their integration time drops sharply — fewer bugs, fewer "how does this endpoint behave?" calls. Consistency of style is one of the clearest marks of a good backend engineer.
Ship interactive docs
Generate an interactive playground with Swagger / OpenAPI from the start. It doubles as documentation for frontend engineers integrating you, and as a testing ground for you. How consistently you maintain your OpenAPI spec genuinely sets you apart.
Pull resources (plural nouns) from your UI, give each the CRUD set on a clean hierarchical URL, pick the method by intent (GET read · POST create/action · PUT replace · PATCH partial · DELETE remove), let idempotency decide retryability, return the honest status code (201 created · 204 deleted · 404 only for a missing single resource), make lists page/sort/filter with sane defaults, and stay ruthlessly consistent — so you can stop arguing about REST and get back to your business logic.
Versioning — not breaking your consumers
The moment another team or another company integrates your API, you can no longer change it freely — a field you rename or a response you reshape breaks their code in production. Versioning is how you evolve without breaking what's already deployed. Three common strategies:
| Strategy | Looks like | Trade-off |
|---|---|---|
| URL path (most common) | /v1/books → /v2/books | Dead obvious, trivial to debug, easy to route. Slightly "impure" REST (the version isn't part of the resource's identity). The pragmatic default. |
| Header | Accept: application/vnd.example.v2+json | Keeps URLs clean and "pure," but invisible in a browser and harder to test/debug. Used by GitHub for years. |
| Query param | /books?version=2 | Simple but clutters every URL and muddies caching. Least recommended. |
When to bump the version
Only on a breaking change — something that would break an existing consumer:
- Removing or renaming a field; changing its type or meaning.
- Removing an endpoint, or changing its method/route.
- Making a previously-optional field required, or changing default behavior.
Additive changes are NOT breaking — and don't need a new version. Adding a new optional field, a new endpoint, or a new optional query param leaves old clients working untouched (they just ignore what they don't know about). Bumping the version for additive changes is needless churn.
When you must retire a version, don't yank it. Announce a timeline, run v1 and v2 in parallel during a migration window, and send a Deprecation / Sunset response header on the old version so integrators get a programmatic heads-up. Breaking people silently is how you lose their trust.
Error Responses — the other half of the contract
People design the happy path carefully and then return raw stack traces or bare strings on failure. But the error shape is part of your API contract too — the consumer's error-handling code depends on it being consistent and machine-readable. A status code alone isn't enough; the body should explain what went wrong in a predictable structure.
A consistent error envelope
Pick one error shape and use it on every error, across every endpoint. A solid, minimal envelope:
Why each piece earns its place:
code— a stable string the client canswitchon. Never make them parse the human message; messages get reworded, codes don't.message— for humans/logs. Safe to show or surface.details— per-field errors so a form can highlight exactly which inputs failed (pairs naturally with422).requestId— the single most useful field in production: the consumer quotes it in a bug report and you find the exact request in your logs in seconds.
Don't return stack traces, SQL errors, file paths, or framework exception dumps to clients — they're useless to the consumer and a gift to attackers (they reveal your stack, schema, and structure). Log the gory details server-side against the requestId; return the clean envelope to the client. A 500 body should say "something went wrong, here's your request id" — nothing more.
type FieldError struct {
Field string `json:"field"`
Issue string `json:"issue"`
}
func writeError(w http.ResponseWriter, status int, code, msg string, details ...FieldError) {
body := map[string]any{"error": map[string]any{
"code": code,
"message": msg,
"details": details,
"requestId": requestIDFromCtx(),
}}
writeJSON(w, status, body) // SAME shape for every error in the whole API
}
// usage
writeError(w, 422, "validation_failed", "Some fields are invalid.",
FieldError{"email", "must be a valid email"},
FieldError{"age", "must be >= 0"},
)
If you'd rather adopt a spec than invent an envelope, RFC 9457 (Problem Details for HTTP APIs) defines a standard application/problem+json body with type, title, status, detail, and instance fields. Either approach is fine — the only wrong move is being inconsistent about error shape across your endpoints.
Return the honest status code, wrap every failure in one consistent JSON envelope with a machine-readable code + human message + per-field details + a requestId, and never leak internals — so a consumer can handle your errors as reliably as your successes.
Constraints vs Conventions
Zoom back out. Plural-or-singular and PUT-or-PATCH aren't the only things that trip people up about REST — so does the count itself. Ask one engineer for "the principles of REST" and you'll hear six; ask another and you'll get a checklist of eight or ten. Both are correct, because they answer two different questions. This closing section is a reference card for telling them apart.
Two lists, two questions
The six are Roy Fielding's formal architectural constraints — they define what makes a system RESTful at all. That's the academic answer, and it's the whole of §02. The eight are practical conventions — how you design a clean, predictable API once you've accepted those constraints. That's what most of this manual actually teaches, spread across its chapters. Constraints are the theory; conventions are the craft.
The six constraints — the definition of REST
- Client–Server — split the UI from data & logic so each side evolves on its own.
- Uniform Interface — one standard way to address and manipulate every resource.
- Layered System — let proxies, caches, and gateways sit between client and origin, invisibly.
- Cacheable — every response declares whether it may be cached.
- Stateless — each request carries everything it needs; the server remembers nothing between calls.
- Code on Demand (optional) — the server may ship executable code to extend the client.
All six — plus the four sub-constraints of the uniform interface, HATEOAS among them — are unpacked in §02.
The eight conventions — how to build one well
| Convention | In one line | Covered in |
|---|---|---|
| Resources as plural nouns | Model things, not actions; the method is the verb. /orders/123, never /getOrder. | §04 Routes |
| Methods by intent | GET read · POST create/action · PUT replace · PATCH partial · DELETE remove. | §05 Methods |
| Honest status codes | Let the code carry the outcome — don't bury an error inside a 200. | §08 Status |
| Statelessness * | Every request self-contained → any server can answer it → horizontal scaling. | §02 Constraints |
| Ruthless consistency | One casing, one date format, one pagination shape — everywhere. | §10 Golden Rules |
| Versioning | Bump only on breaking changes; additive changes need no new version. | §11 Versioning |
| Page · sort · filter | Never dump a whole table; paginate lists and give sane defaults. | §07 List APIs |
| Structured errors | One consistent, machine-readable envelope on every failure. | §12 Errors |
* Statelessness is the one item that lands on both lists — see below.
They aren't rivals — the conventions are how you honor the constraints day to day. The clearest overlap is statelessness, which is both Fielding's constraint #5 and a rule you follow on every endpoint. The others echo too: cacheability surfaces as your Cache-Control / ETag headers, and the uniform interface is exactly what "plural-noun URLs + methods by intent + consistent shapes" delivers in practice. HATEOAS — the most aspirational sub-constraint — is the one piece of the theory most real-world APIs quietly skip.
If the question is academic — an interview, "define REST," a systems exam — they want the six constraints. If it's "how do I design a clean API?" — a code review, a new service — they want the eight conventions. Same standard, two altitudes: the six say what REST is; the eight say how to do it well.
The Eight Principles
§13 lined these up against Fielding's six at a glance; this is the same eight written out in full — the plain-English checklist. Each is a working convention that turns the theory into an API other developers actually enjoy consuming, and each links back to the chapter that unpacks it in depth.
1 · Model resources as nouns, not actions
URLs should represent things, not operations. Use /users/123/orders rather than /getUserOrders?id=123. The HTTP method supplies the verb, so the noun and the method together describe the action. Collections are plural — /orders, not /order. (Full treatment in §04.)
2 · Use HTTP methods for their intended semantics
GET reads (and never changes state), POST creates, PUT replaces a resource wholesale, PATCH updates part of it, and DELETE removes it. Respecting this lets clients, caches, and proxies reason about your API correctly — a GET is safe to retry, a PUT is idempotent (calling it twice has the same effect as once), but a POST generally isn't. (See §05.)
3 · Return meaningful status codes
Lean on the standard ones instead of always returning 200 with an error buried in the body: 200/201/204 for success, 400 for bad input, 401/403 for auth problems, 404 when something's missing, 409 for conflicts, 422 for validation failures, 500 for server faults. This lets clients handle outcomes programmatically. (See §08.)
4 · Stay stateless
Each request should carry everything the server needs to process it — auth token, parameters — with no reliance on server-side session memory from previous calls. This is what lets you scale horizontally: any server can handle any request. It's also one of Fielding's core constraints (§02).
5 · Be consistent
Pick conventions and apply them everywhere: naming style (snake_case vs camelCase in JSON), date formats (ISO 8601), how errors are shaped, how pagination works. Predictability is worth more than cleverness — a developer who learns one endpoint should be able to guess the others. (See §10.)
6 · Version your API
Breaking changes are inevitable, so plan for them from the start — commonly via the URL path (/v1/users) or a header. This lets you evolve without breaking existing clients. (§11 goes deeper on when to bump the version.)
7 · Handle collections gracefully
Large lists need pagination (?page=2&limit=50 or cursor-based), plus filtering (?status=active), sorting (?sort=-created_at), and often field selection. Returning ten thousand records in one response serves no one. (§07 has the full pattern.)
8 · Design clear, structured errors
A good error response tells the client what went wrong and ideally how to fix it — a machine-readable code, a human-readable message, and often which field failed. Consistency here matters as much as consistency in success responses. (§12 shows a concrete envelope.)
An API is a contract meant to be used by other developers — so predictability, standards-compliance, and clear communication beat novelty every time. Nail these eight and a consumer can integrate one endpoint, then correctly guess the rest.
Worked Code — a Task API in Go
Everything in this chapter, compiled into one runnable file: a complete REST API for a single resource — tasks — built on nothing but Go's standard library and the method-aware router introduced in Go 1.22. Before the source itself, here is how it actually behaves at runtime — how it boots, how a request is dispatched, and every branch a request can take.
main() builds the store, seeds twelve tasks, wires the routes, sets read/write timeouts, and hands off to ListenAndServe. That call blocks: the process now sits and waits, ready to serve one request at a time as they arrive.
Because the router is registered with "METHOD /path" patterns, a wrong method on a real path (say POST /v1/tasks/5) matches no route and falls through to the built-in 404. The tag on each row is that route's success code; the error branches live in the next diagram and the table below.
Four gates, each with an early 400 exit; anything that passes is filtered, sorted, and paginated. Note the two things that are not errors: a missing param falls back to a sane default, and a page past the end returns an empty [] with a 200 — an empty list is not “not found” (§08).
Every response the API can return
Reading the three diagrams together, here is the complete set of outcomes — every status code this server can produce, and the condition that triggers each.
| Request | When | Response |
|---|---|---|
GET /v1/tasks | Valid query, or a page past the end | 200 — {data, pagination}; empty page → data: [] |
GET /v1/tasks | done, sortBy, sortOrder, page or limit malformed | 400 — message naming the bad param |
GET /v1/tasks/{id} | Numeric id that exists | 200 — the task |
GET /v1/tasks/{id} | id is not a number | 400 — invalid id |
GET /v1/tasks/{id} | Numeric id that does not exist | 404 — task not found |
POST /v1/tasks | Valid JSON with a non-empty title | 201 — the created task |
POST /v1/tasks | Body is not valid JSON | 400 — invalid JSON body |
POST /v1/tasks | title missing or empty | 400 — title is required |
PUT /v1/tasks/{id} | Valid id + valid body | 200 — the updated task |
PUT /v1/tasks/{id} | Bad id, bad JSON, or empty title | 400 — the matching message |
PUT /v1/tasks/{id} | id does not exist | 404 — task not found |
DELETE /v1/tasks/{id} | Numeric id that exists | 204 — no body |
DELETE /v1/tasks/{id} | Bad id | 400 — invalid id |
DELETE /v1/tasks/{id} | id does not exist | 404 — task not found |
GET /healthz | Always | 200 — {status: "ok"} |
| anything else | No route matches the method + path | 404 — default mux |
The complete program
// A REST API in Go (standard library) for managing "tasks".
//
// This version applies three REST-design rules from the course notes:
//
// §3 URL versioning -> every route lives under /v1
// §6 List query parameters -> pagination + sorting + filtering
// §8 Consistent naming -> JSON fields use camelCase, with sane defaults
//
// Requires Go 1.22+ for the method-aware router.
//
// Run: go run ./RestAPI (from the module root, where go.mod is)
// Then: curl "http://localhost:8080/v1/tasks?limit=5&sortBy=title&sortOrder=asc&done=false"
///- stateless HTTP server that exposes one resource (tasks) as a REST API:
package main
import (
"encoding/json"
"errors"
"log"
"math"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// ---------- Model ----------
// Task is a single to-do item.
//
// §8 (consistent naming): every JSON field is camelCase — note `createdAt`,
// NOT `created_at`. The Go field names stay idiomatic PascalCase; only the
// json struct tags define the wire format the client sees.
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
CreatedAt time.Time `json:"createdAt"`
}
// ---------- Store (unchanged: thread-safe in-memory map) ----------
var errNotFound = errors.New("task not found")
type store struct {
mu sync.RWMutex
tasks map[int]Task
nextID int
}
func newStore() *store { return &store{tasks: make(map[int]Task), nextID: 1} }
func (s *store) list() []Task {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Task, 0, len(s.tasks)) // non-nil so an empty result encodes as [] not null
for _, t := range s.tasks {
out = append(out, t)
}
return out
}
func (s *store) get(id int) (Task, error) {
s.mu.RLock()
defer s.mu.RUnlock()
t, ok := s.tasks[id]
if !ok {
return Task{}, errNotFound
}
return t, nil
}
func (s *store) create(title string, done bool) Task {
s.mu.Lock()
defer s.mu.Unlock()
t := Task{ID: s.nextID, Title: title, Done: done, CreatedAt: time.Now()}
s.tasks[t.ID] = t
s.nextID++
return t
}
func (s *store) update(id int, title string, done bool) (Task, error) {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.tasks[id]
if !ok {
return Task{}, errNotFound
}
t.Title, t.Done = title, done
s.tasks[id] = t
return t, nil
}
func (s *store) delete(id int) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.tasks[id]; !ok {
return errNotFound
}
delete(s.tasks, id)
return nil
}
// ---------- HTTP helpers ----------
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if v != nil {
_ = json.NewEncoder(w).Encode(v)
}
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func idFromPath(r *http.Request) (int, error) { return strconv.Atoi(r.PathValue("id")) }
// ---------- List endpoint: pagination, sorting, filtering (§6) ----------
// listResponse is the envelope returned by GET /v1/tasks. Wrapping the array
// in an object gives us a place to attach pagination metadata beside the data.
// §8: these fields are camelCase too.
type listResponse struct {
Data []Task `json:"data"`
Pagination pagination `json:"pagination"`
}
type pagination struct {
Page int `json:"page"`
Limit int `json:"limit"`
TotalItems int `json:"totalItems"`
TotalPages int `json:"totalPages"`
}
// sortableFields is an allowlist — clients may only sort by these, never by an
// arbitrary field name they invent.
var sortableFields = map[string]bool{"id": true, "title": true, "createdAt": true}
func (s *server) handleList(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
tasks := s.store.list()
// --- Filtering (§6): narrow the set by matching query params. ---
if raw := q.Get("done"); raw != "" {
done, err := strconv.ParseBool(raw)
if err != nil {
writeError(w, http.StatusBadRequest, "query param 'done' must be true or false")
return
}
filtered := make([]Task, 0)
for _, t := range tasks {
if t.Done == done {
filtered = append(filtered, t)
}
}
tasks = filtered
}
if title := strings.TrimSpace(q.Get("title")); title != "" {
needle := strings.ToLower(title)
filtered := make([]Task, 0)
for _, t := range tasks {
if strings.Contains(strings.ToLower(t.Title), needle) {
filtered = append(filtered, t)
}
}
tasks = filtered
}
// --- Sorting (§6) with sane defaults (§8: default = createdAt, descending). ---
sortBy := q.Get("sortBy")
if sortBy == "" {
sortBy = "createdAt" // sane default
}
if !sortableFields[sortBy] {
writeError(w, http.StatusBadRequest, "query param 'sortBy' must be one of: id, title, createdAt")
return
}
sortOrder := q.Get("sortOrder")
if sortOrder == "" {
sortOrder = "desc" // sane default
}
if sortOrder != "asc" && sortOrder != "desc" {
writeError(w, http.StatusBadRequest, "query param 'sortOrder' must be 'asc' or 'desc'")
return
}
sort.Slice(tasks, func(i, j int) bool {
var less bool
switch sortBy {
case "id":
less = tasks[i].ID < tasks[j].ID
case "title":
less = strings.ToLower(tasks[i].Title) < strings.ToLower(tasks[j].Title)
case "createdAt":
less = tasks[i].CreatedAt.Before(tasks[j].CreatedAt)
}
if sortOrder == "desc" {
return !less
}
return less
})
// --- Pagination (§6) with sane defaults (§8: default limit = 10). ---
page, err := parsePositiveInt(q.Get("page"), 1)
if err != nil {
writeError(w, http.StatusBadRequest, "query param 'page' must be a positive integer")
return
}
limit, err := parsePositiveInt(q.Get("limit"), 10)
if err != nil {
writeError(w, http.StatusBadRequest, "query param 'limit' must be a positive integer")
return
}
if limit > 100 {
limit = 100 // cap the page size so a client can't ask for everything at once
}
totalItems := len(tasks)
totalPages := int(math.Ceil(float64(totalItems) / float64(limit)))
// Slice out the requested page. A page past the end yields an EMPTY data
// array — still 200 OK, never 404 (§7: an empty list is not "not found").
start := (page - 1) * limit
if start > totalItems {
start = totalItems
}
end := start + limit
if end > totalItems {
end = totalItems
}
data := tasks[start:end]
if data == nil {
data = []Task{} // guarantee [] rather than null in the JSON
}
writeJSON(w, http.StatusOK, listResponse{
Data: data,
Pagination: pagination{
Page: page,
Limit: limit,
TotalItems: totalItems,
TotalPages: totalPages,
},
})
}
// parsePositiveInt returns def when raw is empty, otherwise the parsed value if
// it is a positive integer. Anything else (non-numeric, zero, negative) errors.
func parsePositiveInt(raw string, def int) (int, error) {
if raw == "" {
return def, nil
}
n, err := strconv.Atoi(raw)
if err != nil || n < 1 {
return 0, errors.New("invalid")
}
return n, nil
}
// ---------- Single-resource handlers (unchanged logic) ----------
type server struct{ store *store }
func (s *server) handleGet(w http.ResponseWriter, r *http.Request) {
id, err := idFromPath(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
t, err := s.store.get(id)
if err != nil {
writeError(w, http.StatusNotFound, "task not found")
return
}
writeJSON(w, http.StatusOK, t)
}
func (s *server) handleCreate(w http.ResponseWriter, r *http.Request) {
var body struct {
Title string `json:"title"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if body.Title == "" {
writeError(w, http.StatusBadRequest, "title is required")
return
}
writeJSON(w, http.StatusCreated, s.store.create(body.Title, false))
}
func (s *server) handleUpdate(w http.ResponseWriter, r *http.Request) {
id, err := idFromPath(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
var body struct {
Title string `json:"title"`
Done bool `json:"done"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if body.Title == "" {
writeError(w, http.StatusBadRequest, "title is required")
return
}
t, err := s.store.update(id, body.Title, body.Done)
if err != nil {
writeError(w, http.StatusNotFound, "task not found")
return
}
writeJSON(w, http.StatusOK, t)
}
func (s *server) handleDelete(w http.ResponseWriter, r *http.Request) {
id, err := idFromPath(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
if err := s.store.delete(id); err != nil {
writeError(w, http.StatusNotFound, "task not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------- Wiring ----------
func (s *server) routes() *http.ServeMux {
mux := http.NewServeMux()
// §3 (versioning): everything is namespaced under /v1, so a future breaking
// change can ship as /v2 without disturbing clients still using /v1.
mux.HandleFunc("GET /v1/tasks", s.handleList)
mux.HandleFunc("POST /v1/tasks", s.handleCreate)
mux.HandleFunc("GET /v1/tasks/{id}", s.handleGet)
mux.HandleFunc("PUT /v1/tasks/{id}", s.handleUpdate)
mux.HandleFunc("DELETE /v1/tasks/{id}", s.handleDelete)
// Health check stays unversioned — it's an infrastructure concern, not part
// of the versioned resource API.
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
return mux
}
func main() {
srv := &server{store: newStore()}
// Seed enough varied data to actually exercise pagination/sorting/filtering.
seed := []struct {
title string
done bool
}{
{"Learn Go", true},
{"Build a REST API", true},
{"Add pagination", false},
{"Add sorting", false},
{"Add filtering", false},
{"Write unit tests", false},
{"Add request logging", false},
{"Write API docs", false},
{"Set up CI pipeline", false},
{"Dockerize the service", false},
{"Add authentication", false},
{"Deploy to staging", false},
}
for _, sd := range seed {
srv.store.create(sd.title, sd.done)
}
const addr = ":8080"
httpServer := &http.Server{
Addr: addr,
Handler: srv.routes(),
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
log.Printf("listening on http://localhost%s (try /v1/tasks)", addr)
log.Fatal(httpServer.ListenAndServe())
}
What the code implements
One file, three clean layers — the same shape every real service grows from. Read from the bottom up, each layer only knows about the one beneath it.
1 · The model
A single struct, Task, with four fields. The json struct tags are doing quiet but important work: the Go fields stay idiomatic PascalCase (CreatedAt) while the wire format the client sees is camelCase (createdAt, never created_at). The tags are the one place the naming convention (§10) is enforced.
2 · The store
An in-memory map[int]Task guarded by a sync.RWMutex — the stand-in for a database. Reads (list, get) take a shared RLock; writes (create, update, delete) take an exclusive Lock, so concurrent requests can't corrupt the map. Two details matter for the API contract: list() returns a non-nil slice so an empty result serialises as [] rather than null, and a missing key returns the errNotFound sentinel that the HTTP layer turns into a 404. Swap this layer for Postgres later and not a single handler changes.
3 · The HTTP layer
This is where REST lives. Three small helpers keep every handler honest: writeJSON sets the content type, writes the status, and encodes the body in one place; writeError wraps a message in a consistent {"error": …} envelope; idFromPath pulls {id} out of the URL. The five resource handlers plus the health check each do one job, and routes() binds them to "METHOD /path" patterns on the Go 1.22 ServeMux — the router itself encodes which verb belongs to which URL, so the handlers never have to check the method by hand.
A request, start to finish
Take the exact command from the top of the file: curl ".../v1/tasks?limit=5&sortBy=title&sortOrder=asc&done=false". Following Diagram C: the router matches GET /v1/tasks and calls handleList. It snapshots all twelve seeded tasks, filters to the ten that are done=false, validates sortBy=title and sortOrder=asc (both pass), sorts A→Z, then takes page 1 at 5 per page — the first five rows. The reply:
Change one thing and the branch changes with it: POST /v1/tasks with a title returns 201 and the new row; GET /v1/tasks/999 returns 404; DELETE /v1/tasks/3 returns 204 with no body; and ?sortBy=colour is rejected up front with 400 because it isn't in the allow-list.
Which principles it demonstrates
This one file touches nearly every rule in the chapter — repetition on purpose, so you can see the theory land in real code.
| Principle | Where it shows up in the code |
|---|---|
| Resource nouns, plural §04 | /v1/tasks and /v1/tasks/{id} — a thing, never a verb like /getTask |
| Methods by intent §05 | GET reads · POST creates · PUT replaces · DELETE removes — one handler each |
| Honest status codes §08 | 200 · 201 · 204 · 400 · 404 — see the outcomes table |
| Statelessness §02 | No sessions; the store is shared and each request carries everything it needs |
| Consistency §10 | camelCase JSON, one {data, pagination} envelope, sane defaults everywhere |
| Versioning §11 | Every resource route under /v1; /healthz deliberately left unversioned |
| Page · sort · filter §07 | ?page ?limit ?sortBy ?sortOrder ?done ?title, with limit capped at 100 |
| Structured errors §12 | A consistent {"error": …} body on every failure |
The allow-list on sortBy means a client can only sort by fields you approved — never an arbitrary column name they invent. Capping limit at 100 stops one request from asking for the entire table. Returning [] instead of null saves every consumer a null-check. And DELETE replies 204 with no body, because there's nothing meaningful left to send.
This is a teaching skeleton, so a few production concerns are deliberately absent: persistence (swap the map for a real database behind the same store interface), authentication, a PATCH route for partial updates (only PUT/full-replace is here), and the richer RFC 9457 error envelope from §12. Each is a natural next step, and none of them changes the shape you see here.
One resource, three layers, a method-aware router, and an in-memory store behind a mutex — every REST rule in this chapter applied in a couple hundred lines you can actually go run and hit with curl.