Resource Relationships
Modeling associations between resources: direct endpoints vs. relationship endpoints, association data, and keeping route sets minimal.
Every resource has its own CRUD endpoints. Associations between resources get a separate set of endpoints. Mixing the two up leads to APIs that are hard to reason about and harder to maintain.
Direct Endpoints vs. Association Endpoints
Direct endpoints operate on a resource:
POST /projects
GET /projects
GET /projects/{projectId}
PATCH /projects/{projectId}
DELETE /projects/{projectId}
Association endpoints manage the link between two resources. In a many-to-many relationship like projects and collaborators, the association has its own lifecycle:
POST /projects/{projectId}/collaborators/{userId}
GET /projects/{projectId}/collaborators
PATCH /projects/{projectId}/collaborators/{userId}
DELETE /projects/{projectId}/collaborators/{userId}
Note there is no GET /projects/{projectId}/collaborators/{userId} detail route here. The user already has a top-level endpoint at GET /users/{userId}. The nested routes only manage the association itself.
Each verb means something different depending on which type of endpoint it targets:
| Verb | Direct endpoint | Association endpoint |
|---|---|---|
POST |
Creates a new resource | Links two existing resources together |
GET (collection) |
Returns all resources | Returns resources linked to the parent |
PATCH |
Modifies fields on the resource itself | Modifies fields that belong to the link (e.g. role, permissions) |
DELETE |
Destroys the resource | Unlinks the resources without destroying either one |
DELETE /projects/{projectId}/collaborators/{userId} removes the user from that project. The user account still exists.
Association-Specific Data
Links between resources often carry their own fields. A project-collaborator association might track the user's role and when they joined, data that belongs to neither the project nor the user on its own.
{
"project_id": "01932c6c-7616-7590-a8db-cb4b0e4d1c8a",
"user_id": "0194a7f3-2b11-7e80-9c3d-4e8f1a2b5c70",
"role": "editor",
"joined_at": "2026-04-10T09:15:00Z"
}
PATCH on the association endpoint updates these link-specific fields. To change the user's name or email, use PATCH /users/{userId} instead.
Fetching Associations
GET /projects/{projectId}/collaborators returns the associations for that project. The response might include only link-specific data (role, joined date) or it might inline the full user details. See Expanding Responses for patterns that give clients control over what gets included.
Don't Duplicate Detail Routes
When a resource already has a top-level endpoint, adding a nested GET detail route is redundant.
GET /teams/{teamId}/members # Useful: scoped list filtered by team
GET /teams/{teamId}/members/{memberId} # Redundant
GET /members/{memberId} # Already exists, use this
The nested collection route filters by the parent, which is valuable. The nested detail route just adds a second URL for the same resource. That means two cache keys, two sets of documentation, and two places to update when the response shape changes. Drop it.
Association-management verbs (POST to link, PATCH to update the link, DELETE to unlink) are still valid on the nested path. Those operate on the association, not the resource itself.
When Nesting Makes Sense
Nest the endpoint when the child resource only exists in the context of its parent, or when modeling an explicit many-to-many link.
| Scenario | Approach |
|---|---|
| Line items in an order | Nested: POST /orders/{orderId}/line-items |
| Members of a team | Nested: POST /teams/{teamId}/members/{memberId} |
| A user's profile | Flat: GET /users/{userId} (profile is part of the resource) |
| Categories (global taxonomy) | Flat: GET /categories |
For guidance on flat vs. nested naming in general, see Naming Conventions.