Module 08: RESTful Architecture and Best Practices
Why this matters
You can now build individual endpoints that work. This module is about the
system they form. REST isn't a framework or a library — it's a set of
architectural constraints for designing APIs around resources and the
uniform semantics of HTTP so that any competent client can predict how
your API behaves without reading your source. When an API is genuinely
RESTful, a developer who's used any REST API already knows that GET /orders/42 fetches order 42, that DELETE on it removes it, that a 404
means it's gone, and that the operation is safe to retry if it's idempotent.
That predictability is the entire value proposition. An API that invents its
own verbs (POST /getOrderById, POST /deleteOrderNow) throws that away and
forces every client to learn your idiosyncrasies.
The design decisions here are the ones you live with for years, because
changing them breaks clients. How do you model a nested relationship —
/users/42/orders or /orders?user=42? How do you version the API so you can
evolve it without breaking the mobile app that shipped last year? How does a
client that wants XML instead of JSON ask for it? How do you let clients cache
responses safely so they don't re-fetch unchanged data — and revalidate
cheaply when it might have changed, using ETags? These aren't
micro-optimizations; they're the difference between an API that ages
gracefully and one that becomes a liability. And critically, this module sets
up module 09: the best way to make these decisions consistent is to design
against an OpenAPI spec — to think in terms of a formal contract before
you write handlers.
Concepts
Resources, not actions: modeling around nouns
The core REST idea: your API exposes resources (nouns) — users,
orders, tasks — and you act on them with the standard HTTP methods
(the verbs), not with custom action names baked into the URL. The URL
identifies what; the method says what to do.
GET /orders list orders
POST /orders create an order
GET /orders/42 fetch order 42
PUT /orders/42 replace order 42
PATCH /orders/42 modify order 42
DELETE /orders/42 delete order 42
RPC-STYLE (verb in URL) RESOURCE-ORIENTED (verb in method)
POST /createOrder ► POST /orders
POST /getOrderById ► GET /orders/42
POST /updateOrderTotal ► PATCH /orders/42
POST /deleteOrderNow ► DELETE /orders/42
one endpoint per action one URL per resource, methods = verbs
client must learn each any REST client already knows it
Not POST /createOrder, POST /getOrder, POST /updateOrder. The verb is
already in the HTTP method. Collections are plural nouns (/orders), a single
item is /orders/{id}. When an operation genuinely isn't CRUD — "cancel this
order" — you model the state change as a sub-resource or a controlled field
update (POST /orders/42/cancellation, or PATCH /orders/42 {"status": "cancelled"}) rather than a POST /cancelOrder. Getting comfortable
expressing verbs-as-resource-changes is the main REST modeling skill.
Relationships and nesting
When resources relate, you can express it two ways, and both are valid for different needs:
- Nested path for "belongs to" containment:
GET /users/42/orders— orders of user 42. Reads naturally, scopes clearly. Don't nest more than one level deep (/users/42/orders/7/items/3becomes unwieldy — link to/items/3instead). - Filtering on the flat collection:
GET /orders?user_id=42— same result, and more flexible when you need to combine filters (?user_id=42&status=open). This ties back to module 07's filtering.
Rule of thumb: use nesting for a clear ownership hierarchy the client navigates; use query filters for cross-cutting queries. Many APIs offer both.
API versioning
Your API will change in backward-incompatible ways. Versioning lets old clients keep working while new ones use the new shape. Four common approaches, with real tradeoffs:
- URI path versioning:
GET /v1/orders,GET /v2/orders. Most explicit, most visible, easiest to route and cache. Downside: it technically violates the "one URI per resource" purist ideal (the same order has two URLs). By far the most common in practice. - Header versioning:
Accept-Version: 2(a custom header). Keeps URLs clean, but versioning is invisible in the URL and harder to test in a browser. - Query-string versioning:
GET /orders?version=2. Simple, but mixes versioning with filtering params and can complicate caching. - Media-type (content negotiation) versioning:
Accept: application/vnd.myapi.v2+json. The most "RESTfully pure" (version is part of the representation), but the most obscure and hardest for casual clients.
There's no universally right answer; URI path versioning is the pragmatic default for most public APIs because it's obvious and toolable. Pick one, be consistent, and document it.
from fastapi import APIRouter
v1 = APIRouter(prefix="/v1")
v2 = APIRouter(prefix="/v2")
# mount both; v1 stays stable while v2 evolves
app.include_router(v1); app.include_router(v2)
Content negotiation
A client tells the server what representation it wants via the Accept
header, and the server responds with Content-Type describing what it sent.
Accept: application/json → JSON; Accept: application/xml → XML (if you
support it). This is content negotiation: one resource, multiple possible
representations, chosen per request. Most JSON APIs support only JSON and
that's fine — but you should return 406 Not Acceptable if a client demands a
representation you can't produce, rather than silently sending JSON anyway.
The same mechanism underlies media-type versioning above and compression
(Accept-Encoding: gzip, module 04).
Client-side caching with ETags
Re-sending data the client already has is waste. HTTP has a built-in
revalidation mechanism: the server sends an ETag (an opaque version
identifier, often a hash of the content) with a response; the client stores it
and, on the next request, sends If-None-Match: <etag>. If the resource
hasn't changed, the server replies 304 Not Modified with no body — the
client reuses its cached copy. Huge bandwidth savings for frequently-fetched,
rarely-changed resources.
1st request: client ──GET /orders/42──► server
client ◄─200 + ETag:abc123─ server (stores body + etag)
2nd request: client ──GET /orders/42, If-None-Match: abc123──► server
compares etag
unchanged ◄─304 Not Modified (no body)─────────────────────┤ same
changed ◄─200 + body + ETag:def456────────────────────── ┘ differs
import hashlib
from fastapi import Request, Response
@router.get("/orders/{oid}")
async def get_order(oid: int, request: Request, response: Response, svc=Depends(...)):
order = svc.get_order(oid)
etag = hashlib.sha256(repr(order).encode()).hexdigest()[:16]
if request.headers.get("if-none-match") == etag:
return Response(status_code=304) # client's copy is current
response.headers["ETag"] = etag
return order
ETags also power optimistic concurrency: a client sends If-Match: <etag> on an update, and the server rejects with 412 Precondition Failed
if the resource changed since — preventing a lost-update race. Cache-Control
headers (max-age, no-cache, private) complement ETags by telling the
client/proxies how long a response may be reused before revalidating.
Meaningful exceptions and error semantics
RESTful error handling means using the right status code and a useful,
consistent body (module 06's envelope). The status code is machine-readable
semantics; the body is human/debugging detail. Don't return 200 with
{"error": ...} — that lies to every HTTP-aware client, proxy, and cache.
Capture exceptions with meaningful messages that help the caller fix the
problem, while never leaking internals (module 02). The status-code families
matter: 4xx = client's fault (they can fix it), 5xx = server's fault
(they can't). This distinction drives client retry logic and your own alerting.
Optimizing large payloads
Big requests and responses cost bandwidth, memory, and latency. Techniques:
- Compression (module 04): gzip/brotli responses over a size threshold.
- Pagination (module 07): never return an unbounded collection.
- Field selection / sparse fieldsets: let clients request only the fields
they need —
GET /orders?fields=id,total— so mobile clients don't download everything. - Avoid over-nesting/over-fetching: don't embed entire related objects by
default; link to them and let the client fetch on demand (or offer an
?expand=opt-in). - Streaming for genuinely huge responses, so you don't hold the whole body in memory.
Designing with an OpenAPI spec in mind
The through-line of good REST design: decide the contract first. What are the resources, their URLs, their methods, their request/response schemas, their status codes, their errors? FastAPI generates an OpenAPI spec from your code (module 09), but the discipline is to think spec-first even when coding — because that's what forces the consistency (uniform envelopes, predictable status codes, versioning strategy) this module is about. When you design against the contract, every endpoint in your API feels like it was designed by one person, because it was designed to one spec.
Command reference
| Concept | Pattern | Example |
|---|---|---|
| Resource URLs | plural nouns + HTTP methods | GET /orders, DELETE /orders/42 |
| Nested relationship | one level of containment | GET /users/42/orders |
| Flat + filter | cross-cutting queries | GET /orders?user_id=42&status=open |
| URI versioning | prefix routers | APIRouter(prefix="/v1") |
| Content negotiation | Accept / Content-Type |
Accept: application/json |
| Unacceptable representation | 406 Not Acceptable |
when you can't satisfy Accept |
| ETag revalidation | ETag + If-None-Match → 304 |
see above |
| Optimistic concurrency | If-Match → 412 Precondition Failed |
conditional update |
| Cache directives | Cache-Control |
Cache-Control: private, max-age=60 |
| Field selection | ?fields= sparse fieldsets |
GET /orders?fields=id,total |
304 Not Modified must have no body. The whole point is to not resend
the payload. Return an empty Response(status_code=304); sending a body
defeats the optimization and confuses clients.
4xx vs 5xx is a contract, not a mood. 4xx tells the client "you can
fix this and it's worth retrying only after changing the request"; 5xx tells
it "server problem, safe to retry with backoff." Misclassifying (e.g.
returning 500 for bad input) breaks client retry logic and pollutes your
alerting — a 5xx spike should mean you broke something.
Version from day one, even if it's just /v1. Adding versioning after
clients depend on unversioned URLs is painful. Start at /v1 so /v2 has
somewhere to go. It costs nothing up front.
ETags need a stable computation. Hash the canonical content
(sorted keys, stable serialization). If your ETag changes when nothing
meaningful did (e.g. dict ordering, a timestamp field that always updates),
clients never get 304s and you've added overhead for no benefit.
Hands-on exercises
Continue in the api-layer project.
1. Refactor action-URLs into resource semantics
Suppose you have (or imagine) POST /createTask, POST /getTask, POST /deleteTaskNow. Rewrite them as POST /tasks, GET /tasks/{id}, DELETE /tasks/{id}. Confirm each uses the correct method and status (module 07).
Expected: the URLs now name resources, the methods carry the verbs, and any
REST-literate client can predict the behavior.
2. Model a "cancel" without a verb URL
Add order cancellation without a POST /cancelOrder. Implement it as
PATCH /orders/{id} with {"status": "cancelled"}, validating the state
transition in the service (can't cancel an already-shipped order → domain
error → 409). Expected: the state change is expressed as a resource
modification, not a custom verb.
3. Nested vs. flat relationships
Expose user orders two ways: GET /users/{id}/orders (nested) and GET /orders?user_id={id} (flat filter). Implement both against the same service
method. Expected: identical results; note in a comment when you'd prefer each
(nesting for clear ownership navigation, filter for combining criteria).
4. URI path versioning
from fastapi import APIRouter
v1 = APIRouter(prefix="/v1", tags=["v1"])
v2 = APIRouter(prefix="/v2", tags=["v2"])
@v1.get("/orders/{oid}")
async def get_order_v1(oid: int): return {"id": oid, "total": 100}
@v2.get("/orders/{oid}")
async def get_order_v2(oid: int): return {"id": oid, "amount": {"value": 100, "currency": "USD"}}
app.include_router(v1); app.include_router(v2)
Call /v1/orders/42 and /v2/orders/42. Expected: different response shapes
at different versions, both live simultaneously — old clients unaffected by
the v2 redesign.
5. Content negotiation with 406
from fastapi import Request, HTTPException
@router.get("/orders/{oid}/report")
async def report(oid: int, request: Request):
accept = request.headers.get("accept", "application/json")
if "application/json" not in accept and "*/*" not in accept:
raise HTTPException(status_code=406, detail="only application/json is available")
return {"order": oid, "report": "..."}
Call with Accept: application/json (works) and Accept: application/xml
(406). Expected: the server refuses to pretend it can produce XML rather
than silently sending JSON with the wrong Content-Type.
6. ETags and 304
Implement the ETag pattern from Concepts on GET /orders/{id}. First request:
note the ETag header. Second request with -H "If-None-Match: <that etag>":
curl -i localhost:8000/orders/1 # 200 + ETag: abc123
curl -i localhost:8000/orders/1 -H "If-None-Match: abc123" # 304, empty body
Expected: the second returns 304 with no body. Now modify the order and
re-request with the old ETag — expected 200 with a new ETag (the content
changed, so the cache is stale).
7. Optimistic concurrency with If-Match
Extend the update endpoint to require If-Match: <etag> and return 412 Precondition Failed if the current ETag differs. Simulate two clients:
client A reads (gets etag), client B updates (etag changes), client A tries to
update with its stale etag. Expected: A's update is rejected 412 — the
lost-update race is prevented. Write down why this is safer than
last-write-wins.
8. Sparse fieldsets
Add ?fields=id,total support to GET /orders: parse the comma-separated
list, whitelist it against allowed fields (module 07 discipline), and return
only those keys. Call ?fields=id and ?fields=id,total. Expected: responses
contain only the requested (allowed) fields; an unknown field name → 400.
9. Diagnose and fix
This API "works" but violates REST in four ways that will bite clients: it
uses action-verb URLs, returns 200 for errors, has no versioning so a shape
change will break clients, and its ETag changes on every request so 304
never happens. Identify and fix each.
@router.post("/getOrder") # (A)
async def get_order(oid: int):
order = ORDERS.get(oid)
if not order:
return {"status": "error", "message": "not found"} # (B) 200 for error
import time
etag = str(time.time()) # (D) changes every call
return {"data": order, "etag": etag}
@router.post("/updateOrderTotal") # (A) again; no /v1 (C)
async def update_total(oid: int, total: float):
ORDERS[oid]["total"] = total
return {"status": "ok"}
Solution
Four REST violations:
- (A) Action-verb URLs.
POST /getOrderandPOST /updateOrderTotalbury the verb in the path. UseGET /orders/{oid}andPATCH /orders/{oid}— method carries the verb, URL names the resource. - (B)
200for a not-found error. Returning200 {"status": "error"}lies to HTTP-aware clients/caches. A missing resource is404; use the centralized envelope (module 06). - (C) No versioning. There's no
/v1, so the first backward-incompatible change breaks every client. Mount routers under a version prefix from the start. - (D) Unstable ETag.
str(time.time())changes every call, soIf-None-Matchnever matches and304never happens — pure overhead. Hash the canonical content instead.
v1 = APIRouter(prefix="/v1", tags=["orders"])
@v1.get("/orders/{oid}")
async def get_order(oid: int, request: Request, response: Response):
order = ORDERS.get(oid)
if order is None:
raise OrderNotFound() # -> 404 envelope
etag = hashlib.sha256(repr(sorted(order.items())).encode()).hexdigest()[:16]
if request.headers.get("if-none-match") == etag:
return Response(status_code=304)
response.headers["ETag"] = etag
return order
@v1.patch("/orders/{oid}")
async def update_total(oid: int, body: OrderUpdate):
...
Independent challenge
No code given. Design and implement /v1/projects and its nested
/v1/projects/{id}/tasks as a small, fully RESTful sub-API. Model every
operation with the correct method and status code, express "archive a project"
as a state change rather than a verb URL, support both nested access and a
flat filtered /v1/tasks?project_id= view, add ETag-based revalidation to the
single-project GET, and return 406 for any Accept you can't satisfy.
Reuse module 07's pagination/filtering/sorting on the collection endpoints and
module 06's consistent error envelope for every failure. Then write a short
paragraph justifying your versioning choice against the four approaches in
this module, and describe what would have to change if you needed a breaking
/v2 of the project schema while keeping /v1 alive.
Hint
For "archive," add status to the project with an allowed transition set and
expose it via PATCH /v1/projects/{id} {"status": "archived"}, rejecting
illegal transitions with a domain error → 409. For the /v2 question, the
answer is that URI versioning lets you mount a second router with new schemas
while the /v1 router and its models stay frozen — no shared response model
between versions, so v1 clients never see v2's changes.
Common mistakes & troubleshooting
- Action-verb URLs.
POST /doThingthrows away REST predictability. Name resources; let HTTP methods be the verbs. 200for errors. Returning200with an error body lies to clients, proxies, and caches. Use the correct4xx/5xxstatus.- Over-nesting.
/a/1/b/2/c/3is unwieldy. Nest one level; link deeper resources or use flat filters. - No versioning until it's too late. Start at
/v1so breaking changes have somewhere to go. Retrofitting versioning onto unversioned clients is painful. - Unstable ETags. An ETag that changes when content didn't (timestamps,
dict ordering) means
304never fires — overhead with no benefit. Hash canonical content. - Sending a body with
304. Defeats the purpose;304is empty by definition. - Silently ignoring
Accept. Returning JSON to a client that demanded XML is wrong; return406if you can't satisfy the request. - Misclassifying
4xx/5xx.500for bad input breaks client retries and your alerting. Client errors are4xx; only real server faults are5xx.
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
- Why is
GET /orders/42more "RESTful" thanPOST /getOrderwith a body, and what concrete benefit does a client get from the former? - When would you model a relationship as a nested path versus a flat query filter?
- Name the four API versioning approaches and give the main tradeoff of URI path versioning (the pragmatic default).
- Walk through an ETag revalidation: what does the server send first, what
does the client send back, and what does a
304response contain? - What's the difference between
If-None-Match(caching) andIf-Match(concurrency), and what status does each failure/miss produce? - Why is returning
200with{"error": ...}for a failure a real problem, not just a style issue? - Give three techniques for reducing the size of a large collection response, and which module each comes from.
Answers
GET /orders/42uses the resource URL + standard method, so any REST-literate client already knows it fetches order 42, that it's safe and idempotent (cacheable, retryable), and how errors will look — no need to read your docs.POST /getOrderhides the verb in the body and loses caching/idempotency/predictability.- Nested path for a clear ownership/containment hierarchy the client
navigates (
/users/42/orders); flat query filter for cross-cutting queries that combine criteria (/orders?user_id=42&status=open). Many APIs offer both; avoid nesting more than one level. - URI path (
/v1/...), header (Accept-Version), query string (?version=), media-type (Accept: application/vnd.api.v2+json). URI path is most explicit/toolable/cacheable but technically gives one resource multiple URLs (violates purist "one URI per resource"). - The server sends the resource with an
ETagheader. On the next request the client sendsIf-None-Match: <etag>. If unchanged, the server replies304 Not Modifiedwith no body, and the client reuses its cached copy. If-None-Matchis for caching: "only send the body if the ETag changed," miss →304.If-Matchis for optimistic concurrency: "only apply this update if the ETag still matches," failure →412 Precondition Failed(someone else changed it first).- HTTP-aware clients, proxies, and caches key off the status code. A
200error body makes them treat a failure as success — retry logic won't fire, caches may store the error, monitoring won't flag it. The status code is machine-readable semantics and must be truthful. - Compression (module 04), pagination with a capped page size (module 07), and sparse fieldsets / field selection (module 08). Also avoiding over-nesting/over-fetching and streaming for huge bodies (module 08).
Further reading & sources
- MDN — HTTP conditional requests - how
ETag/If-None-Match(caching) andIf-Match(concurrency) work end to end. - MDN — ETag - the validator header, strong vs weak ETags, and stable computation.
- MDN — Content negotiation - the
Accept/Content-Typemechanism and when to return406. - Microsoft REST API Guidelines - an industry reference for resource modeling, versioning, and error conventions.
- Google API Design Guide — Resource-oriented design - modeling APIs around resources and standard methods rather than RPC verbs.
- FastAPI — Bigger Applications (routers) - mounting versioned routers (
/v1,/v2) side by side.
Free PDF notes
Download the notes for each video on this page as a printable PDF, with every diagram and interview questions: SD-C-001: REST Principles, SD-C-005: API Resource Modeling & URI Design. Each section in one file: Phase 2, APIs (PDF).
Next
09-openapi-standards — you've been designing against a contract in your head; now you'll make it explicit with OpenAPI: what the spec is, how FastAPI generates it, the tooling ecosystem, and API-first development.