Chapter 11 · API Design

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.


01

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.

Worth reading

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.

One resource · many representations
user id · name · createdAt JSON API client / server↔server HTML browser / human XML legacy consumer same data, different clothes

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.

State · the resource right now
cart id · userId · status earlier snapshot items: 1 · qty: 1 · total: $18.00 current state (now) items: 2 · qty: 3 · total: $47.00 updatedAt: just now a snapshot in time, not a history

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.

Transfer · representations on the wire
CLIENT browser / human SERVER where state lives GET /cart JSON { cart: … } 200 · current cart state state stays put — only copies travel
Mental model

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.

02

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.

The six constraints & what each one buys
ConstraintWhat it saysWhat it buys
1 · Client–ServerStrict separation of concerns: client owns UI/UX, server owns data & business logic.Each side evolves independently.
2 · Uniform InterfaceOne standardized way for all components to talk. (Four sub-constraints below.)Consistency across every service.
3 · Layered SystemHierarchical layers; a layer only sees the one immediately below it.Drop in load balancers / proxies without touching core logic.
4 · CacheResponses must label themselves cacheable or not.Less server load, faster responses.
5 · StatelessServer 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.

Statelessness → any server can answer
client full request load balancer round-robin server 1 server 2 server 3 DB

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.
  • HATEOASHypermedia 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.)
03

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.

The goal of this chapter

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.

04

Anatomy of a Route

Start with a normal website URL and its parts:

A typical URL, decomposed
https://api.example.com/v1/books/harry-potter?sort=name#reviews
Schemehttps, the encrypted transport.
Authority — domain + optional api. subdomain.
Versionv1, API versioning via path.
Path / resource — the thing you're accessing; / means hierarchy.
Query params — key/value pairs for filters & options.
Fragment — scrolls the browser to a section.

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:

1
Take the name: Harry Potter
2
Lowercase everything (avoid case-mismatch bugs across environments): harry potter
3
Replace spaces with hyphens: harry-potter/books/harry-potter

Rule 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.

05

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.

The five methods at a glance
MethodPurposeIdempotent?Has body?
GETRead / fetch a representationyes + safeNo
POSTCreate a resource / custom actionnoYes
PUTReplace a resource entirelyyesYes (full)
PATCHUpdate part of a resourceusuallyYes (partial)
DELETERemove a resourceyesNo

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.

GET /v1/books/123 HTTP/1.1 Host: api.example.com Authorization: Bearer eyJhbGci... Accept: application/json ← blank line = "headers done, no body" HTTP/1.1 200 OK Content-Type: application/json Cache-Control: max-age=300 ETag: "9a3f-1f" { "id": 123, "title": "The Hobbit" }
CodeOn a GET, this means
200 OKFound it; the body is the representation.
304 Not ModifiedYour cached copy is still fresh (you sent If-None-Match); no body returned — saves the payload.
301 / 302Moved; follow the Location header.
400 Bad RequestYour query string is malformed.
401 / 403Not authenticated / authenticated but not allowed.
404 Not FoundNo such single resource.
429 Too Many RequestsRate-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.

PUT vs PATCH in the real world

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

# PATCH — send ONLY what changes PATCH /v1/users/divyansh HTTP/1.1 Content-Type: application/json { "city": "Bangalore" } ← name & email untouched # PUT — send the ENTIRE representation PUT /v1/users/divyansh HTTP/1.1 Content-Type: application/json { "name": "Divyansh", "email": "d@x.com", "city": "Bangalore" }
The PUT trap — silent field wipe

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.

# GET first — server returns a version tag ETag: "v7" # later, when writing back, echo the version you saw PATCH /v1/users/divyansh HTTP/1.1 If-Match: "v7" # if someone else already bumped it to "v8": HTTP/1.1 412 Precondition Failed ← re-fetch, re-merge, retry
PATCH with optimistic-concurrency guard
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
}
CodeOn a PUT / PATCH, this means
200 OKUpdated; body holds the new state.
201 CreatedPUT only — created the resource at a client-chosen URL (upsert). PATCH never creates.
204 No ContentUpdated; nothing to return.
400 Bad RequestBody malformed.
404 Not FoundResource doesn't exist (PATCH); PUT without upsert.
409 ConflictChange collides with current state (e.g. unique-field clash).
412 Precondition FailedStale If-Match — somebody edited it first.
415 Unsupported Media TypeWrong Content-Type (esp. PATCH's merge-patch vs json-patch).
422 Unprocessable EntityWell-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:

POST /v1/orders HTTP/1.1 Authorization: Bearer eyJhbGci... Content-Type: application/json { "productId": "sku_42", "quantity": 2 } HTTP/1.1 201 Created Location: /v1/orders/ord_9f2a ← where the new resource lives { "id": "ord_9f2a", "status": "pending" }
The non-idempotency danger — and the fix

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.

making POST retry-safe with an idempotency key
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)
}
CodeOn a POST, this means
200 OKAction ran; no new resource created (e.g. "send email", custom actions).
201 CreatedA new resource was created; Location points to it.
202 AcceptedQueued for async processing — "I'll do it later" (reports, heavy jobs).
400 Bad RequestBody malformed or missing required fields.
409 ConflictCollides with existing state (e.g. "username already taken").
413 Payload Too LargeBody exceeds the server's size limit.
415 Unsupported Media TypeServer doesn't accept the Content-Type you sent.
422 Unprocessable EntityValid JSON, but fails validation at the business-rule level.
429 Too Many RequestsRate-limited.
Same call, twice — what changes on the server
PATCH name=B A → B then B → B state stable · idempotent DELETE /users/1 deleted then 404 no new effect · idempotent POST /books book id=1 then book id=2 new each time · NOT idempotent
06

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:

POST /organizations/5/archive POST /projects/123/clone

This keeps the hierarchy honest: all organizations → one specific organization → the action to run on it.

Why archive isn't just a PATCH

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."

07

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).

limit = 2, five organizations → three pages
sorted newest → oldest org 5 org 4 org 3 org 2 org 1 page 1 page 2 page 3 ?limit=2&page=2 → returns org 3, org 2 · total=5, page=2, totalPages=3

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: when page === 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.

list endpoint · page · sort · filter · sane defaults
// 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,
	})
}
08

Status Codes — done right

CodeMeaningUse it when
200 OKSuccessFetch, update (PATCH/PUT), or a custom action that ran.
201 CreatedCreatedA POST successfully created a new entity. Return the new entity in the body.
204 No ContentSuccess, empty bodyA successful DELETE — nothing to send back.
404 Not FoundResource missingClient 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.

404 or empty array?
request found nothing asked for ONE id GET /users/999 → 404 Not Found asked for a LIST GET /users?name=Zack → 200 OK · data: []

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.

FamilyClassWhose problem
1xxInformationalRare; you'll basically never handle these directly.
2xxSuccessIt worked.
3xxRedirectionGo look elsewhere / use your cache.
4xxClient errorYou sent something wrong — fix the request.
5xxServer errorThe server broke — not your fault; retry / alert ops.
The first-digit reflex

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

CodePlain meaning
200worked
201created (POST success)
204worked, no body (DELETE success)
301 / 302redirect
304your cache is still good
400your request is broken (syntax)
401log in
403logged in, but not allowed
404that one thing doesn't exist
409conflict with current state
422data fails validation (semantics)
429slow down
500they crashed
503they're overloaded / in maintenance
09

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.

ActionMethod + RouteSuccess
List organizationsGET /v1/organizations200 · paginated
Create organizationPOST /v1/organizations201 · new entity
Get oneGET /v1/organizations/:id200 / 404
Update (partial)PATCH /v1/organizations/:id200 · updated entity
DeleteDELETE /v1/organizations/:id204 · empty
Archive (custom)POST /v1/organizations/:id/archive200 · 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.

wiring the full resource · CRUD + 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
}
10

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 sortBycreatedAt 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 it desc in 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 organizations are plural and paginated with {data,total,page,totalPages}, then projects and tasks are too. Consumers integrate one endpoint, then assume the rest follow the same style. Reward that assumption.
Why consistency is the whole game

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.

The chapter in one breath

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.

11

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:

StrategyLooks likeTrade-off
URL path (most common)/v1/books/v2/booksDead obvious, trivial to debug, easy to route. Slightly "impure" REST (the version isn't part of the resource's identity). The pragmatic default.
HeaderAccept: application/vnd.example.v2+jsonKeeps URLs clean and "pure," but invisible in a browser and harder to test/debug. Used by GitHub for years.
Query param/books?version=2Simple 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.

Deprecation, done kindly

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.

12

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:

HTTP/1.1 422 Unprocessable Entity Content-Type: application/json { "error": { "code": "validation_failed", ← stable, machine-readable "message": "Some fields are invalid.", ← human-readable "details": [ ← per-field, for forms { "field": "email", "issue": "must be a valid email" }, { "field": "age", "issue": "must be >= 0" } ], "requestId": "req_9f2a3c" ← trace it in your logs } }

Why each piece earns its place:

  • code — a stable string the client can switch on. 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 with 422).
  • 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.
Never leak internals

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.

one error helper, used everywhere
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"},
)
There's a standard for this too

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.

The whole error story in one line

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.

13

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

  1. Client–Server — split the UI from data & logic so each side evolves on its own.
  2. Uniform Interface — one standard way to address and manipulate every resource.
  3. Layered System — let proxies, caches, and gateways sit between client and origin, invisibly.
  4. Cacheable — every response declares whether it may be cached.
  5. Stateless — each request carries everything it needs; the server remembers nothing between calls.
  6. 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

The eight conventions & where this manual covers them
ConventionIn one lineCovered in
Resources as plural nounsModel things, not actions; the method is the verb. /orders/123, never /getOrder.§04 Routes
Methods by intentGET read · POST create/action · PUT replace · PATCH partial · DELETE remove.§05 Methods
Honest status codesLet 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 consistencyOne casing, one date format, one pagination shape — everywhere.§10 Golden Rules
VersioningBump only on breaking changes; additive changes need no new version.§11 Versioning
Page · sort · filterNever dump a whole table; paginate lists and give sane defaults.§07 List APIs
Structured errorsOne consistent, machine-readable envelope on every failure.§12 Errors

* Statelessness is the one item that lands on both lists — see below.

Where the two lists touch

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.

Which list does someone want?

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.

14

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.)

The through-line

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.

15

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.

Execution flow · from “go run” to a listening server
go run ./RestAPI compile → run main() entry point newStore() seed 12 tasks http.Server timeouts 10s ListenAndServe :8080 · ready now serving — every incoming request runs the flow below

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.

Request flow · the router dispatches on METHOD + PATH
HTTP request method + path ServeMux method-aware · Go 1.22 GET /v1/tasks → handleList 200 POST /v1/tasks → handleCreate 201 GET /v1/tasks/{id} → handleGet 200 PUT /v1/tasks/{id} → handleUpdate 200 DELETE /v1/tasks/{id} → handleDelete 204 GET /healthz → health check 200 · unmatched route / method 404

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.

Deep dive · every branch inside “GET /v1/tasks”
GET /v1/tasks handleList() list() — snapshot of all tasks (RLock) is ?done a valid bool? strconv.ParseBool no valid ↓ 400 Bad Request done → true or false filter: done + title (substring) sortBy in allow-list? & sortOrder asc | desc? no defaults: createdAt · desc 400 Bad Request bad sortBy / sortOrder sort.Slice(tasks, …) page ≥ 1 and limit ≥ 1? parsePositiveInt no defaults: page 1 · limit 10 cap 100 400 Bad Request page / limit must be ≥ 1 slice out the page · cap 100 past the end → [] (still 200) 200 OK { data, pagination }

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.

RequestWhenResponse
GET /v1/tasksValid query, or a page past the end200{data, pagination}; empty page → data: []
GET /v1/tasksdone, sortBy, sortOrder, page or limit malformed400 — message naming the bad param
GET /v1/tasks/{id}Numeric id that exists200 — the task
GET /v1/tasks/{id}id is not a number400invalid id
GET /v1/tasks/{id}Numeric id that does not exist404task not found
POST /v1/tasksValid JSON with a non-empty title201 — the created task
POST /v1/tasksBody is not valid JSON400invalid JSON body
POST /v1/taskstitle missing or empty400title is required
PUT /v1/tasks/{id}Valid id + valid body200 — the updated task
PUT /v1/tasks/{id}Bad id, bad JSON, or empty title400 — the matching message
PUT /v1/tasks/{id}id does not exist404task not found
DELETE /v1/tasks/{id}Numeric id that exists204 — no body
DELETE /v1/tasks/{id}Bad id400invalid id
DELETE /v1/tasks/{id}id does not exist404task not found
GET /healthzAlways200{status: "ok"}
anything elseNo route matches the method + path404 — default mux

The complete program

task API · net/http · Go 1.22+
// 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:

GET /v1/tasks?limit=5&sortBy=title&sortOrder=asc&done=false 200 OK { "data": [ { "id": 5, "title": "Add filtering", "done": false, "createdAt": "2026-07-02T…" }, … four more, ordered by title A→Z … ], "pagination": { "page": 1, "limit": 5, "totalItems": 10, "totalPages": 2 } }

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.

PrincipleWhere 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 §05GET reads · POST creates · PUT replaces · DELETE removes — one handler each
Honest status codes §08200 · 201 · 204 · 400 · 404 — see the outcomes table
Statelessness §02No sessions; the store is shared and each request carries everything it needs
Consistency §10camelCase JSON, one {data, pagination} envelope, sane defaults everywhere
Versioning §11Every 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 §12A consistent {"error": …} body on every failure
Small touches worth stealing

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.

What's intentionally left out

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.

The file in one breath

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.