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.

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.

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.

Where a convention actually lives A convention documented only in a style guide drifts, while one encoded in a shared lint ruleset every service extends is enforced on every change. conventions survive only where a machine checks them a style guide page read once, at onboarding drifts within a quarter aspirational review comments depends on the reviewer inconsistent by nature partial a shared ruleset extended by every service checked on every change actually enforced

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.

Detecting collisions across services Each service validates cleanly on its own, so a duplicate path is only visible once the combined surface is assembled in CI. a collision is invisible to every service individually per-service lint each spec is valid sees only its own paths cannot detect a collision combined surface in CI all services joined duplicate paths surface fails before release found at the gateway one route wins the other silently 404s far too late

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.

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.