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.

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.

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.