Skip to content

Lesson 1 of 9 · 30m

Modeling Resources: Nouns, IDs, and Endpoints You Won't Regret

Resources are nouns, methods are verbs, and public IDs are unguessable — a route table you'll still like in three years.

Every internal API starts life as a handful of reasonable endpoints and ends as an archeological dig: GET /getUser, POST /updateUser, GET /v1/user/orders/list. Nobody can guess the surface, so nobody can secure it uniformly either — your gateway rules, your authz policies, and your rate limits all key off paths. Resource modeling is the discipline that keeps the surface legible.

Resources are nouns, methods are verbs

A resource is a thing your API manages: users, invoices, api_keys, audit_events. A collection is plural, an item is addressed by ID:

GET    /v1/api_keys            # list
POST   /v1/api_keys            # create
GET    /v1/api_keys/{id}       # read
PATCH  /v1/api_keys/{id}       # partial update
DELETE /v1/api_keys/{id}       # delete

The HTTP method carries the verb. GET must be safe and idempotent — a GET that mutates state is a security bug waiting for a prefetcher or a crawler to find it. PUT/PATCH/DELETE are idempotent enough to retry; POST is not, which is why unsafe retries need an idempotency key (more in the docs lesson).

For operations that genuinely aren't CRUD — cancel a report, rotate a key, resend a webhook — prefer a sub-resource (POST /v1/api_keys/{id}/rotation) or an explicit action suffix (POST /v1/reports/{id}:cancel). Use them sparingly. If half your endpoints end in a colon, you haven't modeled resources, you've built RPC with extra steps.

Public IDs are not your database primary key

The bug that ships: exposing your auto-increment bigint as the public ID. /v1/invoices/1042 tells an attacker three things — the ID is guessable, the count is enumerable, and the object probably exists. The fix is not "authorization" vs. "obscurity" as a binary; it's both. Keep the sequential integer as your internal primary key (it's great for joins and BTREE locality), and expose a separate public ID: a UUIDv7 (RFC 9562, 2024 — time-ordered so it indexes well) or a 128-bit random value.

CREATE TABLE invoices (
    id         BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    public_id  UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE,
    ...
);

Routes key off public_id. The sequential ID never crosses the wire — not in URLs, not in response bodies, not in Location headers.

Keep the tree shallow

/v1/orgs/{o}/teams/{t}/users/{u}/keys/{k} looks rigorous and is miserable: every hop is a lookup and an authz check. Flatten aggressively — /v1/api_keys/{k} — and derive the tenant from the object, not from the path. The path is an address, not a proof of ownership. A caller can always lie about {o}; your data layer must check the object's actual tenant anyway.

Response shape

Pick one envelope and never vary it. Return the resource itself; don't leak column names (usr_pwd_hash has shipped in production APIs). A consistent shape is also a security control — it's what makes a schema-validation gateway or contract test possible later.

{ "id": "7f3a...", "type": "api_key", "created_at": "...", "last_used_at": null }

Try this

Model a secrets store: organizations own projects, which own secrets, which have versions and produce audit_events. Write the full route table — every method — then mark which routes are safe (GET), which are idempotent, and which need idempotency keys. If you wrote a verb in a path, justify it.

Pitfalls

  • Noun drift: user here, users there, account in the auth service. Pick one word per concept and put it in the style guide.
  • Over-nesting as false security: depth in the URL is not depth in authorization.
  • Leaking internals: column names, internal status codes, and _internal_ flags in responses.
  • GET with side effects: breaks caches, breaks retries, gets exploited by prefetch.