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/3 becomes unwieldy — link to /items/3 instead).
  • 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:

  1. (A) Action-verb URLs. POST /getOrder and POST /updateOrderTotal bury the verb in the path. Use GET /orders/{oid} and PATCH /orders/{oid} — method carries the verb, URL names the resource.
  2. (B) 200 for a not-found error. Returning 200 {"status": "error"} lies to HTTP-aware clients/caches. A missing resource is 404; use the centralized envelope (module 06).
  3. (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.
  4. (D) Unstable ETag. str(time.time()) changes every call, so If-None-Match never matches and 304 never 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 /doThing throws away REST predictability. Name resources; let HTTP methods be the verbs.
  • 200 for errors. Returning 200 with an error body lies to clients, proxies, and caches. Use the correct 4xx/5xx status.
  • Over-nesting. /a/1/b/2/c/3 is unwieldy. Nest one level; link deeper resources or use flat filters.
  • No versioning until it's too late. Start at /v1 so 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 304 never fires — overhead with no benefit. Hash canonical content.
  • Sending a body with 304. Defeats the purpose; 304 is empty by definition.
  • Silently ignoring Accept. Returning JSON to a client that demanded XML is wrong; return 406 if you can't satisfy the request.
  • Misclassifying 4xx/5xx. 500 for bad input breaks client retries and your alerting. Client errors are 4xx; only real server faults are 5xx.

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.

  1. Why is GET /orders/42 more "RESTful" than POST /getOrder with a body, and what concrete benefit does a client get from the former?
  2. When would you model a relationship as a nested path versus a flat query filter?
  3. Name the four API versioning approaches and give the main tradeoff of URI path versioning (the pragmatic default).
  4. Walk through an ETag revalidation: what does the server send first, what does the client send back, and what does a 304 response contain?
  5. What's the difference between If-None-Match (caching) and If-Match (concurrency), and what status does each failure/miss produce?
  6. Why is returning 200 with {"error": ...} for a failure a real problem, not just a style issue?
  7. Give three techniques for reducing the size of a large collection response, and which module each comes from.
Answers
  1. GET /orders/42 uses 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 /getOrder hides the verb in the body and loses caching/idempotency/predictability.
  2. 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.
  3. 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").
  4. The server sends the resource with an ETag header. On the next request the client sends If-None-Match: <etag>. If unchanged, the server replies 304 Not Modified with no body, and the client reuses its cached copy.
  5. If-None-Match is for caching: "only send the body if the ETag changed," miss → 304. If-Match is for optimistic concurrency: "only apply this update if the ETag still matches," failure → 412 Precondition Failed (someone else changed it first).
  6. HTTP-aware clients, proxies, and caches key off the status code. A 200 error 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.
  7. 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

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.