How to Structure OpenAPI Paths for Microservices
A microservice estate accumulates path inconsistencies quickly: one team ships /userOrders, another /user-orders, a third nests four levels deep. The result is route collisions at the gateway, bloated SDK method names, and a developer portal whose navigation no longer reads cleanly. This guide is a concrete blueprint for enforcing path conventions across distributed services. It belongs to the Structuring OpenAPI Paths section of the OpenAPI & AsyncAPI Schema Authoring pillar, and it targets teams managing many service specs in a monorepo or across federated repositories.
Problem & Context
Path design feels like a local decision inside each service, but at the gateway it becomes a shared namespace. Two services that both expose /items collide unless the gateway disambiguates them, and the only durable way to disambiguate is a stable per-service prefix. Casing drift compounds the problem: portal generators key navigation off path segments, so /userOrders and /user-orders produce two unrelated nav entries for what is conceptually one resource. Deep nesting hurts a third constituency — SDK generators flatten path segments into method names, so /orders/{orderId}/items/{itemId}/variants/{variantId} becomes an unreadable ordersOrderIdItemsItemIdVariantsVariantIdGet.
The fix is a small set of conventions enforced mechanically rather than by review etiquette. The targets for this guide are: a service-boundary prefix of the form /v{n}/{service-domain}/, kebab-case static segments, a maximum nesting depth of two levels, custom Spectral validation in CI, and explicit alignment between spec paths and gateway routes.
Step-by-Step Solution
1. Assign service-boundary prefixes
Give each service a unique prefix that encodes both the API version and the service domain:
/v1/orders/ → Order Service
/v1/users/ → User Service
/v1/payments/ → Payment Service
Prefix isolation prevents route collisions during gateway aggregation and makes ownership explicit without extra annotations. Record the prefix-to-service mapping in a registry file at the monorepo root and validate uniqueness during the spec merge step:
# service-registry.yaml
services:
- prefix: /v1/orders
owner: orders-team
- prefix: /v1/users
owner: identity-team
- prefix: /v1/payments
owner: payments-team
2. Apply parameterization and nesting rules
Adopt four design rules and apply them everywhere:
- Use kebab-case for static segments:
/order-items, never/orderItemsor/order_items. - Cap nesting at two levels:
/orders/{orderId}/itemsis fine; anything deeper should be flattened. - Push filtering and sorting into query parameters rather than new path segments.
- Use path parameters only for resource identifiers.
A correctly shaped pair of paths looks like this:
paths:
/v1/orders/{orderId}:
get:
operationId: getOrder
/v1/orders/{orderId}/items:
get:
operationId: listOrderItems
parameters:
- name: status
in: query
schema:
type: string
Filtering items by status lives in a query parameter, not in /items/active, which keeps the path count bounded and the SDK method names readable.
3. Enforce conventions with Spectral in CI
Encode the prefix and casing rule as a custom Spectral rule that targets path keys:
# .spectral.yaml
extends: ["spectral:oas"]
rules:
path-prefix-enforced:
description: Paths must start with /v{n}/{service-name}/ and use kebab-case
given: $.paths[*]~
severity: error
then:
function: pattern
functionOptions:
match: '^\/v\d+\/[a-z0-9-]+\/'
The ~ modifier targets the path key itself rather than its value. Run Spectral in a pull-request workflow with a failing severity:
# .github/workflows/validate-openapi.yml
name: Validate OpenAPI Paths
on: [pull_request]
jobs:
validate-openapi:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm install -g @stoplight/spectral-cli
- run: spectral lint openapi.yaml --ruleset .spectral.yaml --fail-severity error
A path that violates the prefix rule produces a non-zero exit and this output:
openapi.yaml
15:3 error path-prefix-enforced Paths must start with /v{n}/{service-name}/ and use kebab-case
✖ 1 problem (1 error, 0 warnings)
4. Align paths with the gateway
Mismatches between the spec and the gateway surface as 404 or 502 errors in production. Map each path to an explicit route and keep the full path on forward:
# Kong route managed with decK
services:
- name: order-service
url: http://order-service.internal:8080
routes:
- name: order-service-v1
paths:
- /v1/orders
strip_path: false
strip_path: false preserves the prefix when forwarding so the backend receives the same path the spec advertises. Validate the route regex against the live gateway before deploying any spec change.
Complete Working Example
A single self-contained openapi.yaml that demonstrates the prefix, two-level nesting, a UUID path parameter, and query-based filtering:
# openapi.yaml (OpenAPI 3.1.0) — lint with the .spectral.yaml above
openapi: 3.1.0
info:
title: Order Service API
version: 1.0.0
paths:
/v1/orders/{orderId}:
parameters:
- name: orderId
in: path
required: true
schema:
type: string
format: uuid
pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'
get:
summary: Retrieve a single order
operationId: getOrder
tags: [Orders]
responses:
'200':
description: Order found
content:
application/json:
schema:
$ref: '#/components/schemas/Order'
'404':
description: Order not found
/v1/orders/{orderId}/items:
parameters:
- name: orderId
in: path
required: true
schema:
type: string
format: uuid
get:
summary: List items in an order
operationId: listOrderItems
tags: [Orders]
parameters:
- name: status
in: query
schema:
type: string
enum: [pending, shipped, cancelled]
- name: page
in: query
schema:
type: integer
minimum: 1
default: 1
responses:
'200':
description: Items list
components:
schemas:
Order:
type: object
required: [id, status]
properties:
id:
type: string
format: uuid
status:
type: string
Gotchas & Edge Cases
Over-nested paths quietly degrade the generated SDK. A method named getOrdersOrderIdItemsItemIdVariantsVariantId is the symptom; the cause is treating every relationship as a path segment. Flatten beyond two levels by exposing the sub-resource as a top-level path (/v1/variants/{variantId}) or by filtering with query parameters.
Trailing-slash handling differs between spec and gateway. Many gateways treat /orders and /orders/ as distinct routes, so a spec that is inconsistent about trailing slashes produces intermittent 404s. Pick one stance, enforce it with the built-in no-path-trailing-slash Spectral rule, and configure the gateway to normalize the other form.
Shared resources tempt teams into cross-service paths. When two services genuinely need the same resource, do not let each publish its own copy of the path. Define the canonical path on an aggregation service or gateway facade and keep internal service-to-service calls as private implementation details that never appear in a public spec.
FAQ
How do I handle shared resources across microservices in OpenAPI paths?
Route shared resources through a dedicated aggregation service or gateway facade and define the canonical path there. Internal service-to-service calls stay implementation details and do not need their own public paths.
What is the recommended maximum nesting depth?
Two levels, such as /orders/{orderId}/items. Anything deeper should use query parameters for filtering or expose the sub-resource as an independent top-level path.
How do I validate paths in a monorepo with 50+ microservices?
Run Spectral as a GitHub Actions matrix job over each service spec. Lint only changed specs on pull requests with a paths filter, and lint every spec on merges to the main branch.