Modelling REST Resources That Don’t Fight Your Database: Nouns, Collections and Sub-Resources

Most REST APIs go wrong before a single handler is written, at the moment someone opens the router file and starts naming endpoints instead of resources. The endpoints get named after the screens the front end happens to need, or worse, after the database queries the ORM makes convenient. The result is a surface that mirrors your tables, leaks your schema, and has to be renegotiated every time either side changes. The fix is cheap and it happens before any code: model the resources first, decide the URI scheme, and hand a codegen tool a map it can trust.

This is the method I use to turn a domain into a clean resource hierarchy, worked through for an orders-and-customers domain in FastAPI 0.115 on Python 3.12. FastAPI emits OpenAPI 3.1 by default, so the shape you design here is the contract your clients generate from.

Start with nouns, not endpoints

A resource is a thing your domain talks about that has identity and a lifecycle: a customer, an order, a payment, an address. It is not an action and it is not a screen. The first pass is to list every noun the business uses in conversation and cross out the ones that are only attributes of another noun. A customer’s name is not a resource; it is a field. A customer’s order is a resource, because it has its own identity, its own state, and outlives the request that created it.

Free · 4 minutes

If your most senior engineer left tomorrow, would anyone still understand the system?

Fourteen questions on documentation, dependencies, and the gap between how the architecture works and how many people know it. Banded finding on screen, full sheet by email.

The test that sorts fields from resources is simple: does the thing need its own URL that someone would want to bookmark, link to, or address directly? If yes, it is a resource. If it only ever appears embedded inside its parent and nobody would ever request it on its own, it is a field. Get this wrong in the direction of too many resources and you build a chatty API that needs six calls to render one page. Get it wrong the other way and you bury addressable things inside blobs that clients have to parse by hand.

Collection or singleton

Almost every resource is one of two shapes. A collection is a set of resources of the same kind that you can list, filter, and add to: /customers. Its members are addressed by identity: /customers/42. A singleton is a resource of which there is exactly one per context, addressed without an id: /customers/42/settings. There is one settings resource for a customer, so it takes no identifier.

The mistake here is treating a singleton as a one-member collection, which forces clients to fetch a list and take the first element, or to invent an id that means nothing. If there can only ever be one, model it as a singleton and let GET, PUT and DELETE act on the single thing. Reserve POST to a collection for creating a new member; a singleton has nothing to create into.

Nest a sub-resource, or link to it

This is the decision that separates a clean hierarchy from a tangle. When two resources are related, you either nest one under the other in the path or you keep both at the top level and connect them with a foreign-key field and a filter. The rule I apply: nest only when the child cannot exist without the parent and is never addressed outside it. An order’s line items are a genuine sub-resource. A line item has no meaning without its order, nobody asks for line item 88 in isolation, and its identity is only unique within the order. So /orders/1001/items is right.

An order and a customer are different. An order references a customer, but it has its own independent lifecycle, its own top-level identity, and you frequently want all orders regardless of customer. Nesting orders under customers as /customers/42/orders/1001 forces every client that already holds an order id to also carry the customer id, and it makes cross-customer reporting awkward. So orders stay top-level at /orders, and “this customer’s orders” becomes a filter: /orders?customer_id=42. Deep nesting beyond one level is nearly always a smell. If you find yourself writing /a/1/b/2/c/3, at least one of those relationships is a link pretending to be a hierarchy.

Plural nouns, no verbs in the path

Two conventions save a great deal of argument later. Use plural nouns for collections consistently, so /customers and /customers/42 rather than a mix of /customer and /customers. And keep verbs out of the path. The verb is the HTTP method. POST /orders creates an order; there is no /createOrder. When an operation genuinely is not a plain create-read-update-delete on a resource, model the operation itself as a resource where it fits, or use a clearly-scoped sub-path such as POST /orders/1001/cancellation rather than smuggling a verb into the URL. The moment your paths read like function names, your API has stopped being resource-oriented and clients can no longer reason about it from the URL alone.

The route table, in FastAPI

Here is the orders-and-customers domain expressed as two routers. Each decorator carries the resulting path in a comment, so the file doubles as the URI map you hand to a codegen tool. Customers own their addresses, so addresses nest. Orders stand alone and link back to customers with a query filter. Line items nest under orders because they have no life of their own.

from fastapi import APIRouter, FastAPI, status
from pydantic import BaseModel

app = FastAPI(title="Sales API", version="1.0.0")  # emits OpenAPI 3.1

customers = APIRouter(prefix="/customers", tags=["customers"])
orders = APIRouter(prefix="/orders", tags=["orders"])


class CustomerIn(BaseModel):
    name: str
    email: str


class CustomerOut(CustomerIn):
    id: int


# --- Customers: a collection of noun resources ---
@customers.get("")                       # GET  /customers      (list, filterable)
def list_customers() -> list[CustomerOut]: ...

@customers.post("", status_code=status.HTTP_201_CREATED)
def create_customer(body: CustomerIn) -> CustomerOut: ...   # POST /customers

@customers.get("/{customer_id}")         # GET  /customers/{customer_id}
def get_customer(customer_id: int) -> CustomerOut: ...

# A customer's addresses: a genuine sub-resource (owned, no independent identity)
@customers.get("/{customer_id}/addresses")     # GET /customers/{customer_id}/addresses
def list_addresses(customer_id: int) -> list[dict]: ...

# A single settings object per customer: a singleton, so no id in the path
@customers.get("/{customer_id}/settings")      # GET /customers/{customer_id}/settings
def get_settings(customer_id: int) -> dict: ...


# --- Orders: top-level, because an order outlives the browsing context ---
@orders.get("")                          # GET /orders?customer_id=42  (link, not nesting)
def list_orders(customer_id: int | None = None) -> list[dict]: ...

@orders.get("/{order_id}")               # GET /orders/{order_id}
def get_order(order_id: int) -> dict: ...

# Line items: a sub-resource of an order, unique only within that order
@orders.get("/{order_id}/items")         # GET /orders/{order_id}/items
def list_items(order_id: int) -> list[dict]: ...

@orders.post("/{order_id}/cancellation", status_code=status.HTTP_202_ACCEPTED)
def cancel_order(order_id: int) -> dict: ...   # operation as a resource, not a verb


app.include_router(customers)
app.include_router(orders)

Run this and FastAPI produces an OpenAPI 3.1 document at /openapi.json that reflects the hierarchy exactly. The tags on each router group the operations, so a generated client lands as client.customers.list() and client.orders.get(order_id) rather than a flat wall of functions. That grouping is a direct payoff of getting the resources right first.

The schema is only the shape. The behaviour still has to be correct underneath it: creates should be safe to retry with idempotency keys on POST, access to each resource has to be scoped and authenticated with properly validated tokens, and machine callers need credentials that are scoped and rotatable rather than a single shared secret. A tidy resource hierarchy makes all three easier to reason about, because each of them attaches cleanly to a resource rather than to an ad-hoc endpoint.

Do this work on a whiteboard, in an afternoon, before the first handler. A resource map costs a few hours to draw and a full rewrite to retrofit. The APIs that age badly are the ones where nobody ever decided what the nouns were.

Free interactive tool

Website compliance checklist

What your site has to do, based on what it actually does

Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.

Can you trust the architecture you have?

Architecture diagrams rarely show the reality of how systems actually operate. An independent review establishes what is really there.