skip to content

How would you design HTTP endpoints for a many-to-many relationship — say users belonging to teams — where the membership itself carries data such as a role and a joined-at timestamp?

level: seniorimportance: should knowfreq 40%

answer

  1. relationship with attributes = its own resource
  2. POST /teams/{id}/memberships → Location: /memberships/{id}
  3. both sides list symmetrically
  4. link-only → PUT/DELETE /teams/{id}/members/{userId}, idempotent
  5. duplicate membership → 409; delete removes the link, not the user

basics

~20 s

Promote the relationship to a first-class resource. POST /teams/{id}/memberships creates one, GET|PATCH|DELETE /memberships/{id} manages it, and both sides list through it (/teams/{id}/memberships, /users/{id}/memberships). A bare link-only relation can instead use PUT/DELETE on /teams/{id}/members/{userId} with no body.

solid answer

~50 s

Two cases, two designs. **Relationship with attributes** (role, joined_at, invited_by) — it is an entity, so give it a resource: ``` POST /teams/7/memberships {"userId":"u1","role":"admin"} -> 201 Location: /memberships/m9 GET /memberships/m9 PATCH /memberships/m9 {"role":"viewer"} DELETE /memberships/m9 GET /teams/7/memberships?role=admin GET /users/u1/memberships ``` The membership has its own id, so it can be read, audited, and modified without inventing composite URLs, and both parents list it symmetrically. **Pure link, no attributes** — a naturally idempotent association endpoint works: ``` PUT /teams/7/members/u1 -> 204 (idempotent add) DELETE /teams/7/members/u1 -> 204 GET /teams/7/members ``` `PUT` is right here because adding an existing member must be a no-op. Avoid `POST /teams/7/members` with a user id in the body for the link-only case — it makes a repeated call ambiguous. And do not model membership as a mutable array field on the team unless the team owns the whole list, since concurrent edits then lose writes.

code

http · 13 lines
http
POST /teams/7/memberships HTTP/1.1
Content-Type: application/json

{"userId":"u1","role":"admin"}

201 Created
Location: /memberships/m9

POST /teams/7/memberships HTTP/1.1
{"userId":"u1","role":"admin"}

409 Conflict
{"error":"membership_exists","membership":"/memberships/m9"}

go deeper

for a junior

Say that a many-to-many link gets its own endpoint, and that if the link carries data like a role it becomes its own resource.

for a middle

Give both designs, use PUT for the attribute-free link because it is idempotent, and show symmetric listing from both parents.

for a senior

Lead with the entity-versus-link decision, canonical membership URIs for concurrency and audit, 409 on duplicates, and the authorization and enumeration risks.

for a principal

Frame the long-term contract: how the model absorbs new relationship attributes without new endpoints, how uniqueness and concurrency are enforced, and where the permission boundary for role assignment lives.

## The modelling decision A many-to-many relationship has no natural owner: a user is not inside a team and a team is not inside a user. So the first question is whether the *relationship itself* has state. - **No state** — the link is just "these two are connected". Model it as an association endpoint under one side. - **State** — role, joined date, inviter, expiry, status — the relationship is an entity in its own right (a "membership", "enrollment", "assignment"). Give it a resource with its own identifier. Getting this wrong is the usual failure: teams start with a plain member list, then someone needs roles, and the API grows `POST /teams/7/members/u1/role` and other one-off endpoints instead of admitting there is a Membership. ## Design A: the relationship as a resource ``` POST /teams/7/memberships -> 201, Location: /memberships/m9 GET /memberships/m9 PATCH /memberships/m9 DELETE /memberships/m9 GET /teams/7/memberships?role=admin&limit=50 GET /users/u1/memberships GET /memberships?teamId=7&userId=u1 ``` Why this shape: - **Symmetry.** Both parents can list their side without either owning the relationship. - **A single canonical URI** for the membership means updates, ETags, concurrency control and audit all have one address. Trying to `PATCH /teams/7/members/u1` instead means the resource's identity is a composite key spread across the path, which is workable but awkward once you need optimistic concurrency or references from other resources (an audit log entry pointing at "the membership"). - **Room to grow.** Adding `status: invited|active|suspended` is a field, not a new endpoint. - **Uniqueness** is a server-side constraint: creating a second membership for the same (team, user) should return `409 Conflict` — that is the honest status for a request that violates the current state of the resource — with a body pointing at the existing membership. Creation can be posted to either parent's collection; pick one (usually the side that owns permissions — the team) and document it, so there is one code path enforcing invariants. ## Design B: the link-only association endpoint When the relationship carries nothing: ``` PUT /teams/7/members/u1 -> 204 No Content DELETE /teams/7/members/u1 -> 204 No Content GET /teams/7/members ``` `PUT` on the full membership URI is the right verb: the request expresses a desired end state ("u1 is a member"), so repeating it is a no-op and retries after a network failure are safe. `DELETE` is likewise naturally idempotent — deleting a non-member can return `204` or `404`; `204` is friendlier for retries, just be consistent. Some APIs also offer bulk forms (`PUT /teams/7/members` with the full set, or `POST /teams/7/members:add` style batch operations); a full-set `PUT` is dangerous under concurrency because it silently overwrites additions made by others between read and write, so require a conditional request (`If-Match` on the collection's ETag) if you offer it. ## Cross-cutting concerns **Authorization.** Membership endpoints are a classic privilege-escalation surface. Creating a membership with `role: admin` must be checked against the caller's rights *on the team*, not merely their ability to read the user. Deleting your own membership (leaving) and deleting someone else's (removing) are often different permissions on the same endpoint. **Enumeration.** `GET /users/u1/memberships` reveals which teams a user belongs to. Scope it: a caller should generally only see memberships for teams they can see, which means the listing is filtered by the caller's visibility, not just by the path. **Pagination and filtering** belong on the membership collections like any other collection — team member lists get large. **Representation.** Return the membership with links or ids to both sides, and support expanding the user or team inline for the common list view, so a member list does not force one request per member. **Deletion semantics.** `DELETE /memberships/m9` removes the relationship, never the user or the team. Make that explicit in docs, because it is a question interviewers ask and an assumption that has caused real incidents.

  • Why `PUT /teams/7/members/u1` rather than `POST /teams/7/members` with the user id in the body?
    `PUT` addresses the exact relationship and states a desired end result, so sending it twice leaves the same state — a safe retry after a timeout. `POST` to the collection is a request to create something new, so a retried call is ambiguous: the server must either detect the duplicate or create a second one. For an attribute-free link, `PUT` gives idempotency for free.
  • A client posts a membership that already exists. Which status code and why?
    `409 Conflict` with a body pointing at the existing membership. The request is well-formed and authorized but conflicts with current state, which is exactly what 409 means. Returning `200` with the existing resource is a defensible alternative if you document creation as idempotent, but silently creating a duplicate is not.
  • How do you keep membership endpoints from becoming a privilege-escalation path?
    Authorize on the team, not the user: creating or updating a membership with an elevated role requires the caller to hold that authority on that team. Treat role changes as a separate permission from adding a member, and distinguish removing yourself from removing someone else. Also filter listings by what the caller may see, so membership endpoints do not leak team structure.

saying these in an interview costs you the question

  • Modelling a relationship that has attributes as a plain id array on one parent
  • Using POST for an attribute-free link, making retries create duplicates or ambiguity
  • Assuming DELETE on a membership deletes the user or the team
  • Checking permissions on the user rather than on the team when assigning a role
  • Offering a full-list PUT with no conditional request, so concurrent membership changes are silently lost

context