intermediate

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

Web app

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.

Mobile app

Same tasks on a worse network — small payloads, no chatty sequences, and every mutation safe to retry after a timeout.

Internal admin service

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

User

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.

Project

The unit of collaboration and the permission boundary. Owns nothing about users except through memberships.

Membership

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.

Invitation

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

OperationPurposeDesign notes
POST /projectsCreate 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 /projectsList 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}/membershipsList 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}/invitationsInvite 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}/acceptInvitee 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

CodeStatusWhenRetryable
VALIDATION_FAILED400Malformed body — missing name, bad email format. `details` lists field-level problems.no
FORBIDDEN403Authenticated but the caller's role doesn't permit the operation — a `member` changing roles, a non-owner deleting the project.no
NOT_FOUND404Project 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_MEMBER409Inviting a user who already belongs to the project, or accepting an invitation twice from different sessions.no
LAST_OWNER409The 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_EXPIRED410Accepting 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.

Membership is a first-class resource, not a `members[]` array on the project.
Reason · Role changes, removal, and joined-at metadata need an addressable target; and the admin service needs to query memberships by user, which an embedded array can't serve.
Alternative · Embed members[] in the project document and mutate it with PATCH.
Trade-off · One more resource to document and one more request to render a project page — bought back by embedding a first page of members in GET /projects/{id}.
Invitation is a separate resource with its own lifecycle, not a membership with `status: "pending"`.
Reason · Different audience (the invitee has no project access), different lifecycle (expiry, revocation), different data (email, not user id). Pending-membership designs leak invitee emails to anyone who can list members.
Alternative · Memberships with a pending status flag.
Trade-off · Two resources where one table might do in the database — the contract and the storage model are allowed to differ (Response Contracts Are Not Database Rows).
"Leave" is `DELETE` on your own membership, not `POST /projects/{id}/leave`.
Reason · Leave and remove are one transition with two authorization rules; one operation means one place for the last-owner guard and one audit path.
Alternative · A dedicated /leave action endpoint.
Trade-off · Slightly less discoverable in docs — mitigated by documenting "leaving a project" as a named task that points at the DELETE.
Role is a field on the membership, changed with PATCH — no `/promote` or `/setRole` actions.
Reason · A role change is a value change with validation, not a multi-step process. Actions earn their place when there's a workflow (Designing State Transitions); here there isn't.
Alternative · Command endpoints per transition.
Trade-off · The PATCH handler must validate role transitions (last-owner demotion) that a command endpoint would make more visually obvious in the API surface.
Non-members get `404`, not `403`, for projects they can't see.
Reason · A 403 on /projects/{id} confirms the id exists — an enumeration primitive on guessable ids. 404 keeps existence private.
Alternative · Honest 403 everywhere.
Trade-off · Genuinely confusing for a developer whose token is merely missing a grant; the docs must state the policy loudly.
Cursor pagination on memberships and invitations from V1.
Reason · Collections that grow with customer success are exactly the ones that explode; adding pagination later changes the response shape for every client (Cursor Pagination: An Opaque Bookmark, Not a Position).
Alternative · Return full arrays until someone complains.
Trade-off · Every client writes a pagination loop on day one for lists that are usually 5 items long.

How it evolves

  • Teams: a Team resource plus team-based memberships arrive additively — Membership gains an optional source: "direct" | "team" field; old clients that never read source keep working because direct memberships look exactly as before.
  • Guest access: a new role value is dangerous for old clients that switch on role, so guests ship as role: "guest" *plus* a capabilities[] 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}/events collection, purely additive; the events reuse membership/invitation ids so existing resources become the audit vocabulary.
  • Async deletion: DELETE /projects/{id} starts returning 202 with a job reference for huge projects while keeping 204 for small ones — possible only because V1 never promised synchronous deletion in the contract (Long-Running Operations: 202 and the Job Resource).

Lessons behind this design