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 hub, 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.
Consistency across services needs enforcement rather than agreement. Every team follows the convention while it is fresh and drifts within two quarters, because nobody reads a style guide before adding an endpoint. A shared lint ruleset that every service extends turns the convention from something people remember into something the build knows.
Collisions at the gateway are a naming problem surfacing as a routing problem. When several services publish under one host, two teams choosing the same resource noun produce a route conflict neither service can see in isolation. Assembling the combined surface in CI and failing on a duplicate path catches it while it is still cheap — before either service has shipped and before consumers depend on either shape.
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.
Related
- Structuring OpenAPI Paths
- Best Practices for JSON Schema Composition in APIs
- Writing Custom Spectral Rules
- OpenAPI & AsyncAPI Schema Authoring
The organising principle is that a public API surface assembled from independently owned services is still one surface to the consumer, and it needs one owner for its consistency. That owner does not have to write the endpoints — they have to own the ruleset, the combined-surface check, and the conversation when two teams want the same noun. Without someone holding that, consistency degrades at exactly the rate new services are added.
Naming that owner early is cheap; retrofitting consistency across a dozen services that each chose sensibly on their own is not. The work is mostly one ruleset, one combined-surface check, and a standing agreement about who arbitrates — which is far less than it sounds and far more than most estates have.
The alternative — hoping each team independently reaches the same conclusions — is not a strategy, and the evidence for that is every multi-service API where the noun for a customer changes depending on which team you ask.
Consistency is a property of the whole surface, so it can only be owned at the level of the whole surface — which is the argument for naming that owner rather than distributing the responsibility and hoping it converges.
Convergence without an owner happens occasionally and by luck; with one it happens by default, which is the whole difference between an estate that reads as one API and one that reads as a directory of unrelated services.