Case Study: User & Project API
The collaboration core of a SaaS product: users create projects, invite people, assign roles, leave, and delete.
This is the study to internalize before any of the others, because it shows the full path from requirements to operations with nothing exotic in the way. The domain sounds like CRUD — users, projects — but the moment you write down what people actually do (invite someone who has no account yet, change a member's role, leave a project you don't own), plain CRUD stops covering it. The design work is deciding which of those verbs deserve their own resources (From Domain to Resources), which are operations on relationships, and which are just field updates. Follow the reasoning, not the endpoint shapes: the shapes fall out once the resources are right.
Consumers
Renders a project page with members and pending invitations in one or two calls; needs the caller's own role to know which buttons to show.
Same tasks on a worse network — small payloads, no chatty sequences, and every mutation safe to retry after a timeout.
Bulk-audits memberships across projects for compliance; needs to list relationships by user, not only by project.
Requirements
- • A user can create a project and becomes its owner.
- • A member with sufficient rights can invite others — including people who don't have an account yet — and an invitee explicitly accepts or declines.
- • Members have roles (
owner,admin,member) that gate what they may do; roles can be changed by admins. - • A member can leave a project at any time, except the last owner, who must transfer ownership first.
- • An owner can delete a project; deletion removes access for everyone.
- • Both "who is in this project" and "which projects is this user in" must be answerable efficiently.
Resources
The account identity. Exists independently of any project — a user who leaves every project still exists. Never embed user profile data into project responses beyond id, name, avatar; the rest is `/users/{id}`'s job.
The unit of collaboration and the permission boundary. Owns nothing about users except through memberships.
The user↔project relationship, promoted to a real resource because it carries state of its own: `role`, `joined_at`. "Assign role" and "leave" are operations on the membership, not on the user or the project — modeling it explicitly is what makes those operations addressable.
Not a membership-in-waiting but its own resource with its own lifecycle (`pending` → `accepted`/`declined`/`expired`/`revoked`) and its own audience: the invitee, who may not even have an account and certainly can't see the project yet.
Operations
| Operation | Purpose | Design notes |
|---|---|---|
| POST /projects | Create a project; the caller becomes owner. | Returns 201 with the full representation and a Location header. The owner membership is created atomically in the same operation — a project without an owner must be unrepresentable. |
| GET /projects | List projects the caller belongs to. | Scoped to the caller by default rather than a ?user_id= filter — the common task is "my projects", and scoping by identity keeps the authorization story simple. |
| GET /projects/{id} | Fetch one project, including the caller's own role. | Embedding my_role saves every UI a second request just to decide which buttons to render — a Consumer-First Design call, not a purity one. |
| GET /projects/{id}/memberships | List members with roles. | Cursor-paginated from day one. Member lists feel small until one customer has 4,000 — retrofitting pagination onto an unbounded array is a breaking change (Unbounded Collections: The Anti-Pattern With a Fuse). |
| PATCH /projects/{id}/memberships/{userId} | Change a member's role. | A field update on the membership resource — no /setRole action needed, because the membership exists as an addressable thing. Rejects demoting the last owner with a structured 409. |
| DELETE /projects/{id}/memberships/{userId} | Remove a member; called on yourself, this is "leave". | "Leave" and "kick" are the same state transition with different authorization rules, so they share an operation. The last owner gets LAST_OWNER instead of a silent orphaned project. |
| POST /projects/{id}/invitations | Invite by email. | Sub-resource of the project because an invitation can't exist without one, and listing "pending invitations for this project" is a first-class task for admins. |
| POST /invitations/{id}/accept | Invitee accepts; a membership is created. | Top-level path, not under the project — the invitee can't read the project yet, so the accept operation must be reachable without project access. A command-style POST because "accept" is a domain event with side effects, not a field edit (Resource or Action?). |
| DELETE /invitations/{id} | Revoke a pending invitation. | Idempotent: revoking an already-revoked invitation returns 204 again rather than 404 — the caller's goal ("this invitation must not work") is satisfied either way. |
| DELETE /projects/{id} | Delete the project. | Owner-only. V1 deletes synchronously; the contract deliberately doesn't promise *how* deletion happens, which is what later lets it become async without a breaking change. |
Error contract
| Code | Status | When | Retryable |
|---|---|---|---|
| VALIDATION_FAILED | 400 | Malformed body — missing name, bad email format. `details` lists field-level problems. | no |
| FORBIDDEN | 403 | Authenticated but the caller's role doesn't permit the operation — a `member` changing roles, a non-owner deleting the project. | no |
| NOT_FOUND | 404 | Project or membership doesn't exist — also returned instead of `403` when the caller isn't a member at all, so the API doesn't leak which project ids exist. | no |
| ALREADY_MEMBER | 409 | Inviting a user who already belongs to the project, or accepting an invitation twice from different sessions. | no |
| LAST_OWNER | 409 | The last owner tries to leave, be removed, or be demoted. `details` names the transfer-ownership path so the client can guide the user. | no |
| INVITATION_EXPIRED | 410 | Accepting an invitation past its expiry. `410` rather than `404`: it existed, and the distinction tells the UI to offer "ask for a new invite". | no |
Decision log
Decision → reason → alternative → trade-off. The alternative is part of the record.
members[] in the project document and mutate it with PATCH.GET /projects/{id}.pending status flag./leave action endpoint.403 on /projects/{id} confirms the id exists — an enumeration primitive on guessable ids. 404 keeps existence private.403 everywhere.How it evolves
- • Teams: a
Teamresource plus team-based memberships arrive additively —Membershipgains an optionalsource: "direct" | "team"field; old clients that never readsourcekeep working because direct memberships look exactly as before. - • Guest access: a new role value is dangerous for old clients that
switchon role, so guests ship asrole: "guest"*plus* acapabilities[]array clients are told to prefer — the enum-evolution lesson applied before it hurts (Enum Evolution: The New Value That Broke Old Clients). - • Audit log: a read-only
GET /projects/{id}/eventscollection, purely additive; the events reuse membership/invitation ids so existing resources become the audit vocabulary. - • Async deletion:
DELETE /projects/{id}starts returning202with a job reference for huge projects while keeping204for small ones — possible only because V1 never promised synchronous deletion in the contract (Long-Running Operations: 202 and the Job Resource).