How to Design RESTful Resource Hierarchies for Microservices

This page is part of the Resource Modeling Best Practices guide, which sits within the API Design Fundamentals & Architecture reference.

When microservice boundaries shift or aggregate roots are misaligned, RESTful path hierarchies degrade into brittle routing matrices. 404 Not Found and 405 Method Not Allowed errors in distributed environments rarely indicate a simple routing typo; they signal structural drift between gateway routing tables and underlying service contracts. This guide gives you a concrete decision workflow, spec-driven guardrails, and codegen configuration to fix and prevent those mismatches.


When this problem occurs

Use this guide when you observe any of the following:


Spec snippet: bounded-context-aligned path design

The following OpenAPI 3.1.0 fragment shows two services — order-service and inventory-service — each owning exactly one aggregate root. Notice that nesting stays within three segments and sub-resources never cross service owners.

# openapi.yaml (Order Service — owns /orders and its direct children)
openapi: "3.1.0"
info:
  title: Order Service API
  version: "1.0.0"
paths:
  /orders:
    post:
      operationId: createOrder
      summary: Create a new order
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OrderRequest"
      responses:
        "201":
          description: Order created
          headers:
            Location:
              schema:
                type: string
                example: /orders/ord_9kx2
  /orders/{orderId}:
    get:
      operationId: getOrder
      parameters:
        - name: orderId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Order resource
  /orders/{orderId}/items:
    get:
      operationId: listOrderItems
      parameters:
        - name: orderId
          in: path
          required: true
          schema:
            type: string
        - name: ids
          in: query
          schema:
            type: array
            items:
              type: string
          style: form
          explode: false
      responses:
        "200":
          description: Paginated list of line items

The ids query parameter on GET /orders/{orderId}/items enables batched fetches that prevent N+1 round-trips — without adding a fourth path segment that would cross into inventory territory.


Step-by-step design and resolution guide

Step 1 — Map each path segment to one aggregate root

Before writing any path, draw a bounded context map. Each service owns one aggregate root; its direct children may appear as a single sub-collection (/resource/{id}/sub-collection). If a candidate path crosses two bounded contexts, it must become two separate endpoints on two separate services.

Rule of thumb: if resolving the path requires a JOIN across two databases you don’t control, the path is in the wrong service.

The diagram below illustrates the mapping from bounded context to URL structure:

Bounded context to URL path mapping Two bounded contexts — Order Service and Inventory Service — each mapped to their own URL root, with sub-resources staying within a three-segment depth limit. Order Service /orders /orders/{orderId} /orders/{orderId}/items ❌ /orders/{id}/items/{id}/stock boundary Inventory Service /products /products/{productId} /products/{productId}/stock ❌ /products/{id}/orders Cross-context reference GET /orders/{id}/items → productId in response body max depth: 3 segments max depth: 3 segments reference via field, not path

Step 2 — Apply the three-segment depth limit

Enforce a hard maximum of three path segments per service boundary: /resource/{id}/sub-resource. Anything deeper requires a cross-service join and must become a separate endpoint or a query parameter reference.

Add this Spectral rule to your .spectral.yaml:

# .spectral.yaml
rules:
  max-path-depth:
    description: "Paths must not exceed three segments within a single service boundary"
    severity: error
    given: "$.paths[*]~"
    then:
      function: pattern
      functionOptions:
        match: "^(/[^/]+){1,3}(/\{[^}]+\}){0,3}$"

Run it locally before pushing:

npx @stoplight/spectral-cli lint openapi.yaml --ruleset .spectral.yaml

Step 3 — Diagnose gateway routing mismatches

When a deployed service returns 404 or 405 for a path that exists in the spec, work through this diagnostic sequence:

  1. Extract gateway logs. Pull request.path from your API gateway access logs. Look for overlapping regex patterns or greedy catch-all routes that consume hierarchical segments before the target handler sees them.

  2. Run openapi-validator against the deployed spec. Path variable mismatches (/{resource}/{id}/sub-resource template vs. actual handler registration) will surface as unresolved path variables.

    npx @ibm-cloud/openapi-validator openapi.yaml
  3. Check OPTIONS handlers. 405 errors in browser clients frequently mask missing OPTIONS support on hierarchical routes. Ensure every POST, PUT, and PATCH path has an explicit OPTIONS handler or a global CORS middleware that responds to preflight before the method dispatcher runs.

  4. Verify method-to-state-transition alignment. A 405 from POST /v1/orders/{id}/items being routed to a PUT handler is a spec–implementation mismatch. Cross-reference against HTTP method mapping guidelines to confirm which verb owns each state transition.

Step 4 — Validate the spec, not just the implementation

Schema diffing gates catch hierarchy-breaking changes before they reach production. The following GitHub Actions workflow blocks any PR that introduces a breaking path change:

# .github/workflows/api-hierarchy-validation.yaml
name: API Hierarchy Validation
on:
  pull_request:
    paths:
      - "openapi/**/*.yaml"
jobs:
  validate-hierarchy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Fetch base spec
        run: git show origin/main:openapi/openapi.yaml > /tmp/base.yaml
      - name: Run OpenAPI Diff
        run: |
          npx openapi-diff /tmp/base.yaml openapi/openapi.yaml --json > /tmp/diff.json
      - name: Block breaking path changes
        run: |
          BREAKING=$(jq '[.[] | select(.type == "breaking" and .property == "path")] | length' /tmp/diff.json)
          if [ "$BREAKING" -gt 0 ]; then
            echo "::error::$BREAKING breaking path hierarchy change(s) detected. Maintain backward compatibility or introduce a versioned path."
            exit 1
          fi
      - name: Run Spectral depth lint
        run: npx @stoplight/spectral-cli lint openapi/openapi.yaml --ruleset .spectral.yaml

Step 5 — Decouple SDK namespaces from URL shape

Deeply nested paths produce brittle generated clients: when /v1/orders/{id}/items becomes /v1/order-items/{id}, every OrdersApi.listOrderItems() call site breaks. Configure the generator to use stable operationId values as method names instead of deriving them from path depth.

TypeScript (openapi-generator-cli):

# generator-config.yaml
generatorName: typescript-axios
outputDir: ./sdk
configOptions:
  useSingleRequestParameter: true
  apiPackage: api
  modelPackage: models
  withSeparateModelsAndApi: true
additionalProperties:
  operationIdNamingConvention: camelCase
  # Stable operationIds from spec — not derived from URL depth
  useOperationIdAsMethodName: true

Python (openapi-python-client):

# openapi-python-client.toml
[generator]
field_prefix = ""
# Class names come from operationId tags, not path segments
class_overrides = {}

With useOperationIdAsMethodName: true, the generated diff between the old and new path is confined entirely to the spec — client call sites remain unchanged:

- # old path: /v1/orders/{orderId}/items
+ # new path: /v1/order-items/{orderId}
  # generated method name: unchanged
  client.listOrderItems(orderId="ord_9kx2", ids=["item_1", "item_2"])

RFC and standard compliance

Concern Standard Clause
Path as resource identifier RFC 3986 §3.3 — path components identify a resource; segments must be hierarchical only when there is a genuine parent-child ownership relation
Uniform interface / resource addressability REST architectural constraints (Fielding, 2000) §5.1.5 — each resource must have a unique identifier independent of the representation
Idempotency of GET on hierarchical paths RFC 9110 §9.3.1 — GET is safe and idempotent; caches key on the full request URI including all path segments
Method semantics for sub-resources RFC 9110 §9.3 — POST to a sub-collection creates a child resource owned by the collection, not by the parent resource

Idempotency, safety, and caching implications

Hierarchical GET endpoints must carry cache headers that account for both the parent and child cache keys. Attach Cache-Control: public, max-age=3600, stale-while-revalidate=86400 to GET /orders/{id}/items responses, and use surrogate keys (Surrogate-Key: order-{id} order-{id}-items) so that a PUT /orders/{id} can purge child caches atomically without a thundering-herd event.

For non-idempotent sub-resource creation — POST /orders/{id}/items — inject an idempotency key into every retry attempt. Without it, a network partition that acknowledges the parent creation but drops the child creation response causes the client to re-POST, producing duplicate line items. The key must be scoped to the aggregate root so retries on the same parent don’t accidentally create items under a different order.

Stateless authentication tokens must also encode the aggregate root scope. See statelessness and caching strategies for JWT claim patterns that restrict token scope to a single resource hierarchy without server-side session state.


SDK and codegen downstream effect

The table below shows how path depth decisions translate into generated client code across three generators:

Generator Path-derived name operationId-derived name Breaks on path refactor?
openapi-generator typescript-axios (default) ordersOrderIdItemsGet() listOrderItems() Yes (default) / No (with useOperationIdAsMethodName)
openapi-python-client get_orders_order_id_items() list_order_items() Yes (default) / No (with operationId override)
oapi-codegen (Go) GetOrdersOrderIdItems() ListOrderItems() Yes (default) / No (with -generate-types-only + manual wiring)

Always assign explicit operationId values in the spec before running any generator. Retrospectively adding operationId to an existing spec is itself a non-breaking spec change but produces a breaking SDK change — coordinate the migration with a deprecation notice and a major SDK version bump.


Common mistakes

Mistake Correct approach
Nesting paths across service boundaries (/orders/{id}/items/{id}/stock) Own one aggregate root per service; reference cross-context resources by ID in the response body, not the path
Deriving SDK method names from URL depth instead of operationId Set operationId on every operation in the spec; configure the generator to use it as the method name
Omitting OPTIONS handlers on hierarchical POST/PUT paths Register an explicit OPTIONS response or a global CORS middleware before the method dispatcher, so preflight requests do not surface as 405
Purging only the parent cache key when a parent resource mutates Use surrogate keys to cascade invalidation to all child resource cache entries atomically
Using a global idempotency key across retries on different parent resources Scope the idempotency key to {operationId}:{parentResourceId}:{clientRequestId} so retries are safe within one aggregate root only

FAQ

How do I diagnose 405 errors caused by hierarchical path mismatches in microservices?

Trace API gateway routing tables against OpenAPI path templates; verify HTTP method mapping aligns with resource state transitions and check for overlapping route patterns that intercept requests before reaching the target handler. See the HTTP method mapping guidelines for the method-to-state-transition reference.

What CI/CD guardrails prevent breaking resource hierarchy changes?

Run openapi-diff on every PR, enforce Spectral path-depth rules, and require automated contract tests to pass before merging. Block deployments on any diff result classified as breaking for path templates.

How should client SDKs handle deeply nested RESTful paths?

Use code generator configurations to flatten or alias nested routes into logical method namespaces derived from operationId values, decoupling client code from volatile URL structures.

When should I split a hierarchical path into separate microservice endpoints?

Split when path segments cross bounded contexts, introduce cross-database joins, or violate single-responsibility for resource ownership. Each service should own exactly one aggregate root and its direct children.