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.

Path-prefix routing through a gateway An API gateway routes /v1/orders, /v1/users, and /v1/payments to their owning services by prefix. API gateway prefix match /v1/orders → Orders /v1/users → Users /v1/payments → Payments

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 /orderItems or /order_items.
  • Cap nesting at two levels: /orders/{orderId}/items is 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.