Resourcesresourcesmodelingpathsdomainrelationships

From Domain to Resources

Resources are the nouns your consumers need to point at — not your tables, not your classes. Deriving /users, /projects, /memberships and /invitations from one requirement shows the reasoning; the paths are just the residue.

Follow the failure

Frame the contract

API design starts with a consumer, a design question and a guarantee — never with a URL.

Design question
Which concepts in this domain deserve a stable, addressable identity in the contract — and which are just attributes of something else?
Consumers
Clients that need to point at things: "show me *this* project", "revoke *that* invitation", "list *these* members". Anything a consumer must reference later — in a URL, a webhook payload, a support ticket — is a candidate resource.
The promise
A well-modeled resource set lets consumers name everything their tasks operate on, with identities stable enough to store, and relationships expressed where clients need to traverse them.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

Extract the nouns a consumer must point at

Start from the requirement, spoken as sentences: "users create projects, invite people to them, assign roles to members, and remove members". The candidate resources are not the database tables — they are the things a consumer will need to *refer to again*: a project (to open it), an invitation (to accept or revoke it), a membership (to change its role or end it).

The test for each candidate is referential, not structural: does any consumer task need to say "that one"? An invitation passes — the invited person clicks a link that must resolve to *this* invitation, and an admin must be able to revoke it before acceptance. A "role assignment event" fails — nobody ever addresses it later; it is an attribute change on a membership, not a thing.

Notice that membership earned its place even though the requirement never used the word. "Assign roles to members" and "remove members" both operate on the *relationship* between a user and a project, not on either endpoint of it. Relationships that carry their own state (role, joined-at, status) and their own lifecycle are resources; flattening them into a members array on the project makes every one of those operations awkward (see The "Everything Is CRUD" Trap for where flattening leads).

  • Has identity a consumer stores or shares → resource. Project ids appear in URLs, bookmarks, webhooks.
  • Has independent lifecycle → resource. An invitation exists before any membership does, and can die (expire, be revoked) without one.
  • Is a stateful relationship → resource. Membership carries role and status; it is not just an edge.
  • Is only ever read or written as part of its parent → attribute. A project's title needs no address of its own.
  • Is a transient computation → not a resource yet. "Suggested members" is a query until someone needs to reference a suggestion later.

Relationships choose the paths

Once the resources are known, paths mostly write themselves — the only real decision is where a resource *hangs*. Nesting under a parent (/projects/{id}/memberships) says "this thing is scoped by and found through its parent"; a top-level collection (/invitations/{id}) says "this thing has an identity that outlives any one traversal". Both are correct in different places, and the consumer's task decides.

Memberships are almost always reached through a project ("who is in this project?") or a user ("what am I a member of?"), so nested paths serve the traversals consumers actually make. Invitations are the opposite: the invited person holds a bare invitation id from an email link and has no project context yet — forcing them through /projects/{id}/invitations/{iid} would demand knowledge they do not have. The rule of thumb: nest for scoped listing and creation, expose top-level identity for anything referenced from outside the hierarchy.

Verb-first: operations without a model
1POST /createProject
2POST /inviteUserToProject
3POST /acceptInvite?token=…
4POST /changeUserRole
5POST /removeUserFromProject
6GET /getProjectsForUser?userId=42
7
8# every new requirement mints a new verb;
9# nothing is addressable, listable, or cacheable
Derived from the domain: four resources, standard operations
1POST /projects
2POST /projects/{id}/invitations
3GET /invitations/{id} # from the email link
4POST /invitations/{id}/acceptance
5GET /projects/{id}/memberships
6PATCH /projects/{id}/memberships/{userId} # { "role": "admin" }
7DELETE /projects/{id}/memberships/{userId}
8GET /users/{id}/memberships # reverse traversal

The right-hand surface is not "more RESTful" — it is more *derivable*. A consumer who knows the four resources can guess every path; a consumer of the left-hand API memorizes a phrasebook. The grammar also gives evolution room: invitation expiry becomes a field, not a fifth verb.

Resources are contract entities, not persistence

The resource model is a *consumer-facing* model, and it will diverge from storage — that divergence is healthy, not a smell. membership might be a join table, a document array, or three tables after the sharding project; the contract does not care, and keeping it ignorant is what lets storage change (see Response Contracts Are Not Database Rows). Model resources from the requirement sentences, then map to storage — never the reverse.

Divergence runs both directions. Some resources aggregate several tables (a project response includes its member count); some tables never become resources at all (audit rows, denormalized caches). And some resources are pure contract fictions with no table anywhere — an /invitations/{id}/acceptance exists so that accepting is addressable and retryable, even if the implementation just flips two columns.

Expect the model to grow asymmetrically. New requirements add resources ("projects can be archived" adds nothing; "users can comment" adds comments) — and each addition re-runs the same test: does a consumer need to point at it later? A model built this way stays a grammar; a model built by exposing tables becomes a liability the day the schema needs to move.

The derivation, requirement by requirement
Requirement sentenceResource(s) it impliesWhy
Users create projects/projectsConsumers open, share and list them — identity is stored everywhere
Invite people to a project/projects/{id}/invitations, /invitations/{id}Independent lifecycle: exists before membership, can expire or be revoked; referenced from an email with no project context
Assign roles to members/projects/{id}/memberships/{userId}The relationship carries state (role); the operation targets the relationship, not the user
Remove memberssame membership resourceRemoval is the end of the relationship's lifecycle, not a mutation of the user

Key points

  • A resource is anything a consumer must point at again: stored ids, shared links, later operations. Referential need, not table existence, is the test.
  • Stateful relationships (membership) are resources in their own right; flattening them into parent arrays cripples the operations that target them.
  • Nest paths for scoped traversal; give top-level identity to anything referenced from outside the hierarchy (invitation links).
  • The resource model is consumer-facing and legitimately diverges from storage — in both directions.
  • A derivable surface is the payoff: consumers who learn the resources can guess the operations.

Follow the failure

How the contract fails or gets misused, hop by hop — and what it costs when it completes.

  1. 1
    Team → API: generates endpoints from the ORM models; the contract is the schema with slashes.
  2. 2
    Requirement → API: "invite users" has no table of its own yet, so it ships as POST /inviteUser — the first phrasebook verb.
  3. 3
    Consumers → API: store project ids from responses, but invitations are unaddressable; the accept flow works only through one magic token endpoint.
  4. 4
    Schema change → contract: the members join table is restructured; the members array embedded in project responses changes shape and breaks clients.
  5. 5
    Team → v2: the API is re-modeled under pressure, with every consumer migration paid at once instead of never.
What breaks
  • Consumers cannot address what their tasks operate on — revoking an invitation or changing one role requires workarounds or full re-writes of parent objects.
  • Storage refactors become breaking changes because tables leaked into the contract.
  • The surface stops being guessable; every new operation is a new memorized verb, and integration cost grows linearly with surface size.

Design, observe, evolve

A contract decision is incomplete until you know how you would notice it failing and how it changes later.

Design the contract
  • • Derive resources from requirement sentences with the referential test, before any path is written.
  • • Promote stateful relationships (membership, subscription, assignment) to resources with their own paths.
  • • Decide nesting per traversal: nested for scoped list/create, top-level for externally referenced identity — write the reason next to each choice.
  • • Keep an explicit mapping layer between resources and storage so neither dictates the other (see [[response-contracts]]).
Observe in production
  • • Client code doing read-modify-write on parent objects to change one child (fetch project, edit members array, PUT back) reveals a missing resource.
  • • Support requests like "how do I cancel an invite?" that have no endpoint answer are unaddressed lifecycle — the model missed a resource.
  • • Endpoints named with verbs accumulating after launch (`/reassignOwner`, `/bulkRemove`) show the grammar has run out and a phrasebook is forming.
Evolve without breaking
  • • New requirements re-run the same derivation: most add fields or sub-resources, and the referential test says which.
  • • A relationship that started as an attribute can be promoted to a resource additively — add the new paths, keep the embedded field until consumers migrate (see [[api-migration]]).
  • • Resources age out with their requirement; a deprecated resource is removed as a tracked migration, not a silent 404.
What it costs
  • • Modeling takes a design session that endpoint generation skips — the payback is invisible until the first schema change or the fifth verb.
  • • Promoted relationship resources mean more paths to document and authorize; a `members` array is genuinely simpler while there is exactly one operation on it.
  • • Nested paths bake traversal assumptions into URLs; if the primary traversal changes later, the old paths remain as aliases to maintain.

Misconceptions

Claim
“Resources are just my database entities exposed over HTTP.”
Reality
Resources are what consumers must address; tables are how you store. Membership may be a join table, an invitation may be two columns, an acceptance may be no table at all — the contract model and the storage model serve different masters and diverge legitimately.
Claim
“Everything must be nested under its parent — that is proper REST.”
Reality
Nesting encodes one traversal. Anything referenced from outside the hierarchy (an invitation link in an email, a webhook payload id) needs top-level identity, or consumers must reconstruct context they do not have. Choose per consumer task, not per doctrine.
Claim
“If it is not a noun, it cannot be in the API.”
Reality
Operations that carry domain meaning can be modeled as resources (an acceptance, a cancellation) or as actions — see Resource or Action?. The noun rule is a heuristic for addressability, not a grammar police.

Apply it