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.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
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.
1POST /createProject2POST /inviteUserToProject3POST /acceptInvite?token=…4POST /changeUserRole5POST /removeUserFromProject6GET /getProjectsForUser?userId=427 8# every new requirement mints a new verb;9# nothing is addressable, listable, or cacheable1POST /projects2POST /projects/{id}/invitations3GET /invitations/{id} # from the email link4POST /invitations/{id}/acceptance5GET /projects/{id}/memberships6PATCH /projects/{id}/memberships/{userId} # { "role": "admin" }7DELETE /projects/{id}/memberships/{userId}8GET /users/{id}/memberships # reverse traversalThe 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.
| Requirement sentence | Resource(s) it implies | Why |
|---|---|---|
| Users create projects | /projects | Consumers 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 members | same membership resource | Removal 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.
- 1Team → API: generates endpoints from the ORM models; the contract is the schema with slashes.
- 2Requirement → API: "invite users" has no table of its own yet, so it ships as
POST /inviteUser— the first phrasebook verb. - 3Consumers → API: store project ids from responses, but invitations are unaddressable; the accept flow works only through one magic token endpoint.
- 4Schema change → contract: the members join table is restructured; the
membersarray embedded in project responses changes shape and breaks clients. - 5Team → v2: the API is re-modeled under pressure, with every consumer migration paid at once instead of never.
- 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.
- • 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]]).
- • 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.
- • 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.
- • 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.