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.
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.
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
| 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.
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.
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
Scheme: https, the encrypted transport.
Authority: domain + optional api. subdomain.
Version: v1, 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.
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
| 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.
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” }
| 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.
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” }
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
}@app.patch("/v1/organizations/{id}")
def update_organization(id: str, body: dict, if_match: str | None = Header(None)):
org = store.get(id)
if org is None:
raise HTTPException(404, "organization not found")
# optimistic concurrency: reject stale writes
if if_match is not None and if_match != org.etag:
raise HTTPException(412, "resource changed; re-fetch and retry")
updated = store.patch(id, body) # merge only the given fields, don't replace
return Response(
content=json.dumps(updated),
media_type="application/json",
headers={"ETag": updated["etag"]},
) # 200 + updated entityapp.patch('/v1/organizations/:id', async (req, res) => {
const org = await store.get(req.params.id)
if (!org) {
return res.status(404).json(errBody('organization not found'))
}
// optimistic concurrency: reject stale writes
const ifMatch = req.get('If-Match')
if (ifMatch && ifMatch !== org.etag) {
return res.status(412).json(errBody('resource changed; re-fetch and retry'))
}
const updated = await store.patch(req.params.id, req.body) // merge, don't replace
res.set('ETag', updated.etag)
res.status(200).json(updated) // 200 + updated entity
})import type { Request, Response } from 'express'
export async function updateOrganization(req: Request, res: Response): Promise<void> {
const org = await store.get(req.params.id)
if (org === null) {
res.status(404).json(errBody('organization not found'))
return
}
// optimistic concurrency: reject stale writes
const ifMatch = req.get('If-Match')
if (ifMatch !== undefined && ifMatch !== org.etag) {
res.status(412).json(errBody('resource changed; re-fetch and retry'))
return
}
// `Partial<Organization>` IS the PATCH contract written as a type: every
// field optional, and absent means "leave it alone". Typing the body as
// `Organization` here would quietly re-introduce the PUT trap.
const patch = req.body as Partial<Organization>
const updated = await store.patch(req.params.id, patch) // merge, don't replace
res.set('ETag', updated.etag)
res.status(200).json(updated) // 200 + updated entity
}@PatchMapping("/v1/organizations/{id}")
ResponseEntity<?> updateOrganization(
@PathVariable String id,
@RequestBody Map<String, Object> patch, // only the fields to change
@RequestHeader(value = "If-Match", required = false) String ifMatch) {
Organization org = store.get(id);
if (org == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(errBody("organization not found"));
}
// optimistic concurrency: reject stale writes
if (ifMatch != null && !ifMatch.equals(org.etag())) {
return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED)
.body(errBody("resource changed; re-fetch and retry")); // 412
}
Organization updated = store.patch(id, patch); // merge, don't replace
return ResponseEntity.ok()
.eTag(updated.etag())
.body(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:
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” }
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)
}@app.post("/v1/payments", status_code=201)
def create_payment(body: dict, idempotency_key: str = Header(...)):
# already processed this exact request? return the SAME result, don't re-charge
prior = idem_store.get(idempotency_key)
if prior is not None:
return JSONResponse(status_code=prior["status"], content=prior["body"])
payment = charge(body["amount"]) # the real, non-idempotent side effect
idem_store.save(idempotency_key, 201, payment) # remember it, keyed by the idempotency key
return paymentapp.post('/v1/payments', async (req, res) => {
const key = req.get('Idempotency-Key') // client-generated UUID
// already processed this exact request? return the SAME result, don't re-charge
const prior = await idemStore.get(key)
if (prior) {
return res.status(prior.status).json(prior.body)
}
const payment = await charge(req.body.amount) // the real, non-idempotent side effect
await idemStore.save(key, 201, payment) // remember it, keyed by the idempotency key
res.status(201).json(payment)
})import type { Request, Response } from 'express'
interface StoredResult {
status: number
body: Payment
}
export async function createPayment(req: Request, res: Response): Promise<void> {
const key = req.get('Idempotency-Key') // client-generated UUID
if (key === undefined) {
res.status(400).json(errBody('Idempotency-Key header is required'))
return
}
// already processed this exact request? return the SAME result, don't re-charge
const prior: StoredResult | null = await idemStore.get(key)
if (prior !== null) {
res.status(prior.status).json(prior.body)
return
}
// Worth knowing: this read-then-write is itself racy if two retries land
// at once. In production the key is claimed with a single atomic insert
// (a unique index on the key), and a duplicate insert means "someone else
// is already charging this, wait for their result".
const payment = await charge(req.body.amount as number) // the non-idempotent side effect
await idemStore.save(key, 201, payment) // remember it, keyed by the idempotency key
res.status(201).json(payment)
}@PostMapping("/v1/payments")
ResponseEntity<?> createPayment(@RequestBody PaymentRequest in,
@RequestHeader("Idempotency-Key") String key) {
// already processed this exact request? return the SAME result, don't re-charge
Optional<StoredResult> prior = idemStore.get(key);
if (prior.isPresent()) {
return ResponseEntity.status(prior.get().status()).body(prior.get().body());
}
Payment payment = charge(in.amount()); // the real, non-idempotent side effect
idemStore.save(key, 201, payment); // remember it, keyed by the idempotency key
return ResponseEntity.status(HttpStatus.CREATED).body(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:
POST /organizations/5/archive POST /projects/123/clone
This keeps the hierarchy honest: all organizations -> one specific organization -> the action to run on it.
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.
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,
})
}# GET /v1/organizations?status=active&sortBy=name&sortOrder=ascending&page=1&limit=10
@app.get("/v1/organizations")
def list_organizations(
status: str | None = None,
sortBy: str = "createdAt", # sane default
sortOrder: str = "descending", # sane default
page: int = 1, # sane default
limit: int = 10, # sane default
):
filters = {}
if status:
filters["status"] = status # ?status=active
rows, total = store.query(filters, sortBy, sortOrder, page, limit)
total_pages = (total + limit - 1) // limit # ceil division
return {
"data": rows,
"total": total,
"page": page,
"totalPages": total_pages,
}// GET /v1/organizations?status=active&sortBy=name&sortOrder=ascending&page=1&limit=10
app.get('/v1/organizations', async (req, res) => {
const q = req.query
// --- sane defaults: never crash if the client omits params ---
// Note the `||` rather than `??`: a query param arrives as a string, so
// Number('abc') is NaN and Number('') is 0, and both must fall back too.
const page = Number(q.page) || 1
const limit = Number(q.limit) || 10
const sortBy = q.sortBy || 'createdAt'
const sortOrder = q.sortOrder || 'descending'
const filters = {}
if (q.status) {
filters.status = q.status // ?status=active
}
const { rows, total } = await store.query(filters, sortBy, sortOrder, page, limit)
const totalPages = Math.ceil(total / limit)
res.status(200).json({ data: rows, total, page, totalPages })
})import type { Request, Response } from 'express'
interface ListPage<T> {
data: T[]
total: number
page: number
totalPages: number
}
// GET /v1/organizations?status=active&sortBy=name&sortOrder=ascending&page=1&limit=10
export async function listOrganizations(req: Request, res: Response): Promise<void> {
const q = req.query
// --- sane defaults: never crash if the client omits params ---
// Everything in `req.query` is `unknown`-ish by nature (a string, an
// array of strings, or missing), so each param is coerced once, here,
// and the rest of the function works with real numbers and strings.
const page = Number(q.page) || 1
const limit = Math.min(Number(q.limit) || 10, 100) // and cap it, see below
const sortBy = String(q.sortBy ?? 'createdAt')
const sortOrder = q.sortOrder === 'ascending' ? 'ascending' : 'descending'
const filters: Record<string, string> = {}
if (typeof q.status === 'string') {
filters.status = q.status // ?status=active
}
const { rows, total } = await store.query(filters, sortBy, sortOrder, page, limit)
const body: ListPage<Organization> = {
data: rows,
total,
page,
totalPages: Math.ceil(total / limit),
}
res.status(200).json(body)
}// GET /v1/organizations?status=active&sortBy=name&sortOrder=ascending&page=1&limit=10
@GetMapping("/v1/organizations")
ResponseEntity<?> listOrganizations(
// --- sane defaults: never crash if the client omits params ---
// defaultValue does the whole job; the param is never null here.
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int limit,
@RequestParam(defaultValue = "createdAt") String sortBy,
@RequestParam(defaultValue = "descending") String sortOrder,
@RequestParam(required = false) String status) {
Map<String, String> filters = new HashMap<>();
if (status != null) {
filters.put("status", status); // ?status=active
}
QueryResult<Organization> result = store.query(filters, sortBy, sortOrder, page, limit);
int totalPages = (result.total() + limit - 1) / limit; // ceil division
return ResponseEntity.ok(Map.of(
"data", result.rows(),
"total", result.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. |
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.
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
}from fastapi import FastAPI, Response
app = FastAPI()
# list + create share the collection URL, split by method
@app.get("/v1/organizations")
def list_organizations(): ... # (see section 07)
@app.post("/v1/organizations", status_code=201) # 201 Created
def create_organization(body: dict):
status = body.get("status") or "active" # sane default
return store.insert( # id, createdAt set server-side
name=body["name"], status=status, description=body.get("description"),
)
# get-one / update / delete share /{id}, split by method
@app.get("/v1/organizations/{id}")
def get_organization(id: str):
org = store.get(id)
if org is None:
raise HTTPException(404, "organization not found") # single id -> 404
return org
@app.patch("/v1/organizations/{id}") # partial update -> 200
def update_organization(id: str, body: dict):
return store.update(id, body)
@app.delete("/v1/organizations/{id}", status_code=204) # No Content
def delete_organization(id: str):
store.delete(id)
return Response(status_code=204)
# custom action: verb at the end -> POST, returns 200 (created nothing)
@app.post("/v1/organizations/{id}/archive")
def archive_organization(id: str):
return store.archive(id) # flips status + cascades: projects, tasks, emails...import { Router } from 'express'
const router = Router()
// list + create share the collection URL, split by method
router.get('/v1/organizations', listOrganizations) // (see section 07)
router.post('/v1/organizations', createOrganization)
// get-one / update / delete share /:id, split by method
router.get('/v1/organizations/:id', getOrganization)
router.patch('/v1/organizations/:id', updateOrganization)
router.delete('/v1/organizations/:id', deleteOrganization)
// custom action: verb at the end of a specific resource
router.post('/v1/organizations/:id/archive', archiveOrganization)
async function createOrganization(req, res) {
const { name, status, description } = req.body
const org = await store.insert({
name,
status: status || 'active', // sane default, don't force the client to send the obvious
description,
}) // id, createdAt set server-side
res.status(201).json(org) // 201 Created + the new entity
}
async function getOrganization(req, res) {
const org = await store.get(req.params.id)
if (!org) {
return res.status(404).json(errBody('organization not found')) // single id -> 404
}
res.status(200).json(org)
}
async function updateOrganization(req, res) {
res.status(200).json(await store.update(req.params.id, req.body)) // partial update -> 200
}
async function deleteOrganization(req, res) {
await store.delete(req.params.id)
res.sendStatus(204) // No Content
}
async function archiveOrganization(req, res) {
const org = await store.archive(req.params.id) // flips status + cascades: projects, tasks, emails...
res.status(200).json(org) // custom action -> 200, NOT 201
}import { Router, type Request, type Response } from 'express'
const router = Router()
// list + create share the collection URL, split by method
router.get('/v1/organizations', listOrganizations) // (see section 07)
router.post('/v1/organizations', createOrganization)
// get-one / update / delete share /:id, split by method
router.get('/v1/organizations/:id', getOrganization)
router.patch('/v1/organizations/:id', updateOrganization)
router.delete('/v1/organizations/:id', deleteOrganization)
// custom action: verb at the end of a specific resource
router.post('/v1/organizations/:id/archive', archiveOrganization)
// The create body and the stored entity are DIFFERENT types, and saying so
// is the point: `id` and `createdAt` are server-side, so they are absent
// from the input type and a client cannot smuggle them in.
interface CreateOrganization {
name: string
status?: string
description?: string
}
async function createOrganization(req: Request, res: Response): Promise<void> {
const body = req.body as CreateOrganization
const org: Organization = await store.insert({
name: body.name,
status: body.status ?? 'active', // sane default
description: body.description,
})
res.status(201).json(org) // 201 Created + the new entity
}
async function getOrganization(req: Request, res: Response): Promise<void> {
const org = await store.get(req.params.id)
if (org === null) {
res.status(404).json(errBody('organization not found')) // single id -> 404
return
}
res.status(200).json(org)
}
async function updateOrganization(req: Request, res: Response): Promise<void> {
const patch = req.body as Partial<Organization>
res.status(200).json(await store.update(req.params.id, patch)) // partial update -> 200
}
async function deleteOrganization(req: Request, res: Response): Promise<void> {
await store.delete(req.params.id)
res.sendStatus(204) // No Content
}
async function archiveOrganization(req: Request, res: Response): Promise<void> {
const org = await store.archive(req.params.id) // flips status + cascades
res.status(200).json(org) // custom action -> 200, NOT 201
}@RestController
@RequestMapping("/v1/organizations") // the collection URL, written once
class OrganizationController {
// list + create share the collection URL, split by method
@GetMapping
ListPage<Organization> list() { /* (see section 07) */ }
@PostMapping
@ResponseStatus(HttpStatus.CREATED) // 201 Created + the new entity
Organization create(@RequestBody CreateOrganization body) {
String status = (body.status() == null) ? "active" : body.status(); // sane default
// id, createdAt set server-side
return store.insert(body.name(), status, body.description());
}
// get-one / update / delete share /{id}, split by method
@GetMapping("/{id}")
Organization getOne(@PathVariable String id) {
Organization org = store.get(id);
if (org == null) {
// single id -> 404
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "organization not found");
}
return org;
}
@PatchMapping("/{id}") // partial update -> 200
Organization update(@PathVariable String id, @RequestBody Map<String, Object> body) {
return store.update(id, body);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT) // 204, nothing to send back
void delete(@PathVariable String id) {
store.delete(id);
}
// custom action: verb at the end -> POST, returns 200 (created nothing)
@PostMapping("/{id}/archive")
Organization archive(@PathVariable String id) {
return store.archive(id); // flips status + cascades: projects, tasks, emails...
}
}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, Node, Spring, or any other 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.
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.
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.
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 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.
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"},
)from fastapi import Request
from fastapi.responses import JSONResponse
def error_response(status, code, message, details=None, request_id=""):
return JSONResponse(status_code=status, content={"error": {
"code": code,
"message": message,
"details": details or [],
"requestId": request_id,
}}) # SAME shape for every error in the whole API
# usage
error_response(
422, "validation_failed", "Some fields are invalid.",
details=[
{"field": "email", "issue": "must be a valid email"},
{"field": "age", "issue": "must be >= 0"},
],
)function writeError(res, status, code, message, details = []) {
res.status(status).json({
error: {
code,
message,
details,
requestId: res.locals.requestId, // put there by the request-id middleware
},
}) // SAME shape for every error in the whole API
}
// usage
writeError(res, 422, 'validation_failed', 'Some fields are invalid.', [
{ field: 'email', issue: 'must be a valid email' },
{ field: 'age', issue: 'must be >= 0' },
])import type { Response } from 'express'
interface FieldError {
field: string
issue: string
}
// Exporting the envelope type is the quiet win here: the client imports
// the same declaration, so `switch (err.error.code)` is checked against
// the codes the server can actually send, on both sides of the wire.
export interface ErrorEnvelope {
error: {
code: string
message: string
details: FieldError[]
requestId: string
}
}
export function writeError(
res: Response,
status: number,
code: string,
message: string,
details: FieldError[] = [],
): void {
const body: ErrorEnvelope = {
error: { code, message, details, requestId: res.locals.requestId },
}
res.status(status).json(body) // SAME shape for every error in the whole API
}
// usage
writeError(res, 422, 'validation_failed', 'Some fields are invalid.', [
{ field: 'email', issue: 'must be a valid email' },
{ field: 'age', issue: 'must be >= 0' },
])// Named ApiFieldError so it does not collide with Spring's own FieldError.
public record ApiFieldError(String field, String issue) {}
public record ErrorEnvelope(String code, String message,
List<ApiFieldError> details, String requestId) {}
@RestControllerAdvice
class ApiErrors {
static ResponseEntity<Map<String, ErrorEnvelope>> writeError(
HttpStatus status, String code, String message, List<ApiFieldError> details) {
var envelope = new ErrorEnvelope(code, message, details, MDC.get("requestId"));
// SAME shape for every error in the whole API
return ResponseEntity.status(status).body(Map.of("error", envelope));
}
// The framework's OWN validation failures get funnelled through the same
// helper, so a bean-validation error and a hand-written one are
// indistinguishable to the client. That is what "consistent" has to mean.
@ExceptionHandler(MethodArgumentNotValidException.class)
ResponseEntity<?> onInvalid(MethodArgumentNotValidException e) {
List<ApiFieldError> details = e.getBindingResult().getFieldErrors().stream()
.map(f -> new ApiFieldError(f.getField(), f.getDefaultMessage()))
.toList();
return writeError(HttpStatus.UNPROCESSABLE_ENTITY,
"validation_failed", "Some fields are invalid.", details);
}
}
// usage
writeError(HttpStatus.UNPROCESSABLE_ENTITY,
"validation_failed", "Some fields are invalid.",
List.of(new ApiFieldError("email", "must be a valid email"),
new ApiFieldError("age", "must be >= 0")));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 sec 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 sec 02.
The eight conventions: how to build one well
The eight conventions & where this manual covers them
| Convention | In one line | Covered in |
|---|---|---|
| Resources as plural nouns | Model things, not actions; the method is the verb. /orders/123, never /getOrder. | sec 04 Routes |
| Methods by intent | GET read / POST create/action / PUT replace / PATCH partial / DELETE remove. | sec 05 Methods |
| Honest status codes | Let the code carry the outcome, don’t bury an error inside a 200. | sec 08 Status |
| Statelessness * | Every request self-contained -> any server can answer it -> horizontal scaling. | sec 02 Constraints |
| Ruthless consistency | One casing, one date format, one pagination shape, everywhere. | sec 10 Golden Rules |
| Versioning | Bump only on breaking changes; additive changes need no new version. | sec 11 Versioning |
| Page / sort / filter | Never dump a whole table; paginate lists and give sane defaults. | sec 07 List APIs |
| Structured errors | One consistent, machine-readable envelope on every failure. | sec 12 Errors |
* Statelessness is the one item that lands on both lists, see below.
The Eight Principles
sec 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 sec 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 sec 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 sec 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 (sec 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 sec 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. (sec 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. (sec 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. (sec 12 shows a concrete envelope.)
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.
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
Go
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:
//
// sec 3 URL versioning -> every route lives under /v1
// sec 6 List query parameters -> pagination + sorting + filtering
// sec 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.
//
// sec 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 (sec 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.
// sec 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 (sec 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 (sec 6) with sane defaults (sec 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 (sec 6) with sane defaults (sec 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 (sec 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()
// sec 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 (sec 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.
| Principle | Where it shows up in the code |
|---|---|
| Resource nouns, plural sec 04 | /v1/tasks and /v1/tasks/{id}, a thing, never a verb like /getTask |
| Methods by intent sec 05 | GET reads / POST creates / PUT replaces / DELETE removes, one handler each |
| Honest status codes sec 08 | 200 / 201 / 204 / 400 / 404, see the outcomes table |
| Statelessness sec 02 | No sessions; the store is shared and each request carries everything it needs |
| Consistency sec 10 | camelCase JSON, one {data, pagination} envelope, sane defaults everywhere |
| Versioning sec 11 | Every resource route under /v1; /healthz deliberately left unversioned |
| Page / sort / filter sec 07 | ?page ?limit ?sortBy ?sortOrder ?done ?title, with limit capped at 100 |
| Structured errors sec 12 | A consistent {"error": ...} body on every failure |
The Backend Lens / Field Manual v2, Chapter 11.
Further reading: MDN / REST / MDN / Web APIs / Roy Fielding’s 2000 dissertation on REST.
Backend from First Principles / Chapter 07 / REST. Code targets Go 1.22+, Python 3.11+, Node.js 20+, TypeScript 5+ and Java 17+ (Spring Boot). The complete worked program at the end is Go only.