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:
- Gateway access logs show
404or405responses for paths that exist in your OpenAPI spec. - URL nesting exceeds three segments (
/tenants/{tid}/users/{uid}/prefs), and changes to one service break consumers in another. - A PR refactoring a path from
/v1/orders/{id}/itemsto/v1/order-items/{id}silently breaks downstream client SDKs. - Generated SDK method names become unstable every time a URL restructuring merges.
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:
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:
-
Extract gateway logs. Pull
request.pathfrom 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. -
Run openapi-validator against the deployed spec. Path variable mismatches (
/{resource}/{id}/sub-resourcetemplate vs. actual handler registration) will surface as unresolved path variables.npx @ibm-cloud/openapi-validator openapi.yaml -
Check
OPTIONShandlers.405errors in browser clients frequently mask missingOPTIONSsupport on hierarchical routes. Ensure everyPOST,PUT, andPATCHpath has an explicitOPTIONShandler or a global CORS middleware that responds to preflight before the method dispatcher runs. -
Verify method-to-state-transition alignment. A
405fromPOST /v1/orders/{id}/itemsbeing routed to aPUThandler 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.
Related
- Resource Modeling Best Practices — parent guide covering aggregate root identification and canonical parent-child relationships
- API Design Fundamentals & Architecture — pillar reference for the full design system this page belongs to
- Idempotency Key Implementation — guard nested
POSToperations with idempotency keys to prevent duplicate sub-resource creation - HTTP Method Mapping Guidelines — authoritative reference for which verb owns each state transition, including sub-resource creation
- Statelessness and Caching Strategies — cache-control and surrogate key patterns for hierarchical resource responses