Designing Nested Resources in REST APIs: A Practical Guide

Clear patterns and best practices for designing nested resources in REST APIs—when to use them, how deep to nest, and how to keep URLs stable.

ASOasis
8 min read
Designing Nested Resources in REST APIs: A Practical Guide

Image used for representation purposes only.

Overview

Designing nested resources in a REST API looks straightforward—just put a child under its parent in the URL. In practice, it shapes discoverability, performance, security, and long‑term evolvability. This article distills practical guidance for when and how to use nested resources, how deep to nest, how to model many‑to‑many relations, and how to keep URLs stable while your product grows.

What is a nested resource?

A nested resource is a collection or entity addressed in the context of a parent resource.

Examples:

  • Collection under a parent: /projects/{projectId}/tasks
  • Single child under a parent: /projects/{projectId}/tasks/{taskId}

The nesting expresses a containment or scoping relationship: tasks belong to projects, comments belong to posts, etc.

When to use nesting (and when not to)

Use nesting when:

  • The child has no meaningful existence outside the parent (strict containment). Example: /carts/{cartId}/items.
  • The child is always accessed in the context of a single parent (natural scope, authorization boundary). Example: /organizations/{orgId}/members.
  • The relationship cardinality is one‑to‑many (1→N) and each child has exactly one parent.

Prefer flat top‑level endpoints with filters when:

  • The child can be accessed independently or belongs to multiple parents (N↔N). Example: /tasks?projectId=... and canonical /tasks/{taskId}.
  • You need cross‑cutting queries, search, reporting, or pagination across parents. Example: /events?type=error&since=....
  • Deep nesting harms URL stability or developer ergonomics.

Rule of thumb: use at most one level of nesting for canonical write operations. Provide flat, top‑level canonical URLs for independent resources.

URL design patterns

Good patterns:

# Canonical collections
/projects
/tasks

# Scoped collection (1 level deep)
/projects/{projectId}/tasks

# Canonical identity for the child (top-level)
/tasks/{taskId}

Avoid:

# Deep nesting (brittle, long, hard to cache)
/companies/{companyId}/departments/{deptId}/teams/{teamId}/projects/{projectId}/tasks/{taskId}

# Overloaded paths that confuse identity
/projects/{projectId}/tasks/{taskId}/assignees/{userId}/comments/{commentId}

Why avoid deep nesting?

  • Identity ambiguity: is the canonical location of taskId under a project, under a user, or both?
  • Operational cost: proxies and caches perform better with shorter, stable paths.
  • Authorization complexity: checking multiple ancestors for every request is fragile.

Depth and canonical identity

  • Canonical identity means a resource has exactly one primary URL (stable over time), e.g., /tasks/{taskId}.
  • Nested routes are convenience or scope routes, not the canonical identity. You can return 201 Created from a nested POST with a Location header pointing to the canonical top‑level URL.

Example creation flow:

POST /projects/{projectId}/tasks
Content-Type: application/json

{ "title": "Set up CI" }

201 Created
Location: /tasks/87421

{ "id": "87421", "projectId": "acme-42", "title": "Set up CI", "links": {"self": "/tasks/87421", "project": "/projects/acme-42"} }

HTTP methods and behaviors on nested resources

  • GET /projects/{id}/tasks — list tasks scoped to the project.
  • POST /projects/{id}/tasks — create a task within that project. Return 201 and Location of /tasks/{taskId}.
  • GET /projects/{id}/tasks/{taskId} — allowed for convenience; it should resolve only if task.projectId == {id}; otherwise 404.
  • PATCH/PUT /tasks/{taskId} — update via canonical URL. If re‑parenting is allowed, validate and return 409 Conflict if illegal.
  • DELETE /tasks/{taskId} — delete via canonical URL. Optionally support delete via nested route if parent matches.

Status codes to consider:

  • 404 Not Found if the parent does not exist or the child is not under that parent.
  • 409 Conflict when association constraints (e.g., single parent) would be violated.
  • 422 Unprocessable Entity for semantic validation errors (invalid state transitions, etc.).

Modeling relationships

One‑to‑many (strict containment)

  • Canonical child URL: top‑level /children/{id}.
  • Scoped collection: /parents/{id}/children.
  • Child contains parentId. Authorization checks parent membership.

Many‑to‑many (associations)

Use a join/association resource to keep identities stable:

# Canonical resources
/posts/{postId}
/tags/{tagId}

# Association collection (scoped)
/posts/{postId}/tags           # view linked tags
/tags/{tagId}/posts            # view linked posts

# Association resource (explicit)
/post-tags                     # collection of link objects
/post-tags/{linkId}            # canonical identity for a link

Payloads:

// POST /post-tags
{ "postId": "p123", "tagId": "t9" }

This avoids forcing a “child” to pick a single canonical parent when it belongs to many.

Polymorphic relationships

If a child can belong to different parent types (e.g., comments on posts or photos), prefer canonical top‑level identity plus typed association fields:

{ "id": "c7", "subject": { "type": "post", "id": "p42" }, "body": "Nice!" }

Expose convenience listings:

  • /posts/{postId}/comments
  • /photos/{photoId}/comments
  • Canonical: /comments/{commentId}

Query, pagination, and sorting for nested collections

Nested listings should accept the same controls as top‑level collections:

  • Pagination: page[size], page[after] (cursor) or limit/offset.
  • Sorting: sort=-createdAt,title.
  • Filtering: state=open, assignee=....

Example:

GET /projects/{id}/tasks?limit=50&after=eyJpZCI6Ijg3NDIwIn0&sort=-createdAt&state=open

Return pagination metadata and HATEOAS links:

{
  "data": [ {"id": "87421", "title": "Set up CI"} ],
  "page": { "next": "/projects/acme-42/tasks?after=..." },
  "links": {
    "self": "/projects/acme-42/tasks?limit=50",
    "project": "/projects/acme-42"
  }
}

Consistency rules and naming

  • Use consistent, plural nouns for collections: /users, /orders.
  • Prefer snake_case or kebab-case consistently in URLs; use camelCase or snake_case in JSON—pick one convention API‑wide.
  • IDs are opaque strings; don’t encode business meaning in them.
  • Never change segment order to reflect workflow; URLs represent identity, not steps.

Idempotency and safety across nested routes

  • GET is safe and idempotent. Repeating a GET on a nested route must not change state.
  • DELETE /parents/{id}/children/{childId} is idempotent; deleting an already‑deleted child returns 404 (if canonical child is gone) or 204 (if deletion was acknowledged earlier)—pick one behavior and document it.
  • POST is not idempotent; for retry safety, support idempotency keys via Idempotency-Key header for create operations.

Concurrency, caching, and conditional requests

  • Use ETags on resource representations and require If-Match on updates to avoid lost updates.
  • For nested listings, cache at the collection level with validators (ETag/Last-Modified).
  • Keep canonical child URLs short to maximize cache effectiveness.

Example conditional update:

PATCH /tasks/87421
If-Match: "e2a9fd"
Content-Type: application/json

{ "title": "Set up CI/CD" }

Error handling in nested contexts

Provide precise errors that reflect ancestry and association constraints.

Examples:

// 404 when parent not found
{ "error": "not_found", "message": "Project 'acme-42' does not exist." }

// 404 when child not under parent
{ "error": "not_found", "message": "Task '87421' is not under project 'acme-42'." }

// 409 when re-parenting would violate constraints
{ "error": "conflict", "message": "Task '87421' cannot be moved to project 'beta-7'." }

Security and authorization boundaries

  • Scope tokens to parent contexts: a token for org-7 should authorize /organizations/org-7/... but not others.
  • Always validate parent ownership before operating on nested children.
  • Avoid leaking child existence across parents. Return 404 for mismatched ancestry even if the child exists elsewhere.
  • Propagate rate limits per principal and optionally per parent to prevent hot‑spot abuse.

Deletion, cascading, and orphans

Decide and document lifecycle rules:

  • Cascade delete: deleting a parent deletes all children. Dangerous for audit; pair with soft‑delete or tombstones.
  • Restrict delete: block parent deletion if children exist; return 409 with guidance.
  • Reassign children: allow moving children to a different parent before parent deletion.

State the policy explicitly in error messages and docs.

Versioning and evolution

Nesting is part of your public surface; changing it breaks clients. To evolve safely:

  • Introduce new top‑level canonical endpoints while keeping old nested routes for a deprecation window.
  • Use semantic versioning in media types or URL (e.g., /v2) sparingly; prefer additive changes.
  • Announce deprecations with Deprecation and Sunset headers and link to docs via Link: rel="deprecation".

Documentation with OpenAPI (example)

paths:
  /projects/{projectId}/tasks:
    get:
      summary: List tasks in a project
      parameters:
        - in: path
          name: projectId
          required: true
          schema: { type: string }
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 100 }
      responses:
        '200':
          description: OK
    post:
      summary: Create a task in a project
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties: { title: { type: string } }
      responses:
        '201':
          description: Created
          headers:
            Location:
              description: Canonical URL of the new task
              schema: { type: string }
  /tasks/{taskId}:
    get: { summary: Get task }
    patch: { summary: Update task }
    delete: { summary: Delete task }

Migration patterns

  • Start nested for strong containment (e.g., /carts/{id}/items). When items later need search/reporting, add canonical /items/{itemId}; keep nested routes as alternate views.
  • If deep nesting exists, introduce short canonical URLs and 301/308 redirect reads from old deep URLs. For writes, prefer 201 with Location to canonical.

Testing nested resource behavior

  • Unit‑test ancestry checks: parent existence, membership, and mismatches.
  • Contract‑test status codes for edge cases (conflicts, orphans, re‑parenting).
  • Load‑test nested listings to validate pagination and cache.

Anti‑patterns to avoid

  • Encoding filters into paths: /projects/active/tasks/today instead of query params.
  • Using names as identifiers in paths when they are mutable: /projects/{slug} unless slugs are immutable.
  • Varying the same resource identity by multiple parents: both /users/{u}/tasks/{t} and /projects/{p}/tasks/{t} as canonical.
  • Returning 200 OK with an error body for authorization failures; use 403/404 appropriately.

Practical checklist

Before adding or deepening nesting, ask:

  • Does the child have exactly one parent? If not, avoid nesting as canonical.
  • Will clients need to list/search children across parents? If yes, add a top‑level collection.
  • Is the parent a natural authorization boundary? If yes, keep a scoped route for listing/creating.
  • Can you cap nesting at one level? If not, reconsider the model.
  • Have you defined canonical URLs, pagination, errors, and ETag/If‑Match semantics?

Example end‑to‑end flow

  1. Create a task under a project:
POST /projects/acme-42/tasks
{ "title": "Publish roadmap" }

201 Created
Location: /tasks/9012
  1. Retrieve via canonical URL:
GET /tasks/9012
200 OK
{ "id": "9012", "projectId": "acme-42", "title": "Publish roadmap" }
  1. List tasks for the project with pagination:
GET /projects/acme-42/tasks?limit=25&after=...&state=open
  1. Update with optimistic concurrency:
PATCH /tasks/9012
If-Match: "w-3"
{ "state": "in_progress" }
  1. Delete via either route (if parent matches):
DELETE /tasks/9012
# or
DELETE /projects/acme-42/tasks/9012

Conclusion

Use nested resources to express scope, not identity. Keep canonical URLs short and unique, cap nesting at one level, and model many‑to‑many relations with explicit association resources. Apply consistent pagination, filtering, and concurrency controls. With these patterns, your API remains intuitive today and resilient to tomorrow’s requirements.

Related Posts