Structuring OpenAPI Paths & Resources
Path structure is the decision that ripples furthest through an API program: it shapes generated SDK method names, the developer-portal sidebar, gateway routing rules, and every consumer’s mental model of your service. Get it wrong and you ship /getUserData, four-level nesting, and silent breaking renames that consumers debug as 404s. This guide is part of OpenAPI & AsyncAPI Schema Authoring and covers resource-oriented design, parameter and versioning strategy, automated path linting in CI, and aggregating many service specs into one portal.
Scope: this page covers naming conventions, path/query parameter placement, versioning, deprecation signaling, and multi-service aggregation for the paths object in OpenAPI 3.1. It does not cover payload schema design — see Defining JSON Schema Components — or per-operation auth, covered in Security Schemes & OAuth Flows. For service-boundary specifics, see the long-form guide on structuring OpenAPI paths for microservices.
Apply these naming rules consistently; they are what the linting rules below enforce:
| Rule | Good example | Avoid |
|---|---|---|
| Plural nouns for collections | /users, /orders |
/user, /getUsers |
| HTTP method carries the action | POST /orders |
/createOrder |
| Two-level nesting maximum | /orders/{orderId}/items |
/orders/{id}/items/{id}/variants/{id} |
| Kebab-case for multi-word segments | /order-items |
/orderItems, /order_items |
| Path params identify, query params filter | /users/{userId}?status=active |
/users/active/{userId} |
Prerequisites & Environment Setup
Install the pinned toolchain locally and in CI so everyone lints against identical rules:
npm install --save-dev @stoplight/spectral-cli@6
npm install --save-dev @redocly/cli@2 # v2.x ships the `join` command
node --version # expect v20.x
npx spectral --version # expect 6.x
npx redocly --version # expect 2.x
You need a valid OpenAPI 3.1 document with a populated paths object and a .spectral.yaml ruleset at the repo root. If your spec is split across files with $ref, run npx redocly bundle openapi.yaml -o bundled.yaml before linting so the linter sees fully resolved paths.
Core Configuration
Define each path with explicit parameter typing and validation. The parameters block at path level applies to every operation under it; operation-level parameters add to or override it. Annotate the non-obvious keys inline:
# openapi.yaml
openapi: 3.1.0
info:
title: Example API
version: "1.0.0"
servers:
- url: https://api.example.com/v1 # base path/version lives here, not in path keys
description: Production
- url: https://staging-api.example.com/v1
description: Staging
paths:
/users/{userId}:
parameters:
- name: userId
in: path
required: true # path params are always required
schema:
type: string
format: uuid # advisory; pattern below does the real validation
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 user by ID
operationId: getUserById # drives the generated SDK method name
tags: [Users] # groups this op in the portal sidebar
parameters:
- name: include
in: query # filters/expansions go in query, never the path
required: false
schema: { type: string, enum: [orders, profile] }
responses:
'200':
description: User found
content:
application/json:
schema: { $ref: '#/components/schemas/User' }
'404': { description: User not found }
For versioning, prefer URL path versioning (/v1) declared in the servers array for public APIs and portals — it is explicit in logs, history, and bookmarks. Reserve header-based versioning for internal services behind a gateway. Mark a sunset endpoint with deprecated: true at the operation level and document the removal date in its description, then emit a Sunset response header from the gateway so clients see the timeline programmatically.
Integration Pattern
Run path linting on every pull request and fail the build on convention violations so drift never merges. This workflow bundles $refs first, lints, and verifies a clean multi-service join:
# .github/workflows/validate-openapi.yml
name: Validate OpenAPI Paths
on:
pull_request:
paths: ['**/*.yaml', '.spectral.yaml']
jobs:
validate-paths:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Bundle multi-file spec
run: npx redocly bundle openapi.yaml -o bundled.yaml
- name: Lint path conventions
run: npx spectral lint bundled.yaml --ruleset .spectral.yaml --fail-severity error
- name: Detect path collisions across services
run: npx redocly join services/*.yaml -o /tmp/combined.yaml
The ruleset enforces kebab-case segments, a nesting ceiling, and no trailing slash:
# .spectral.yaml
extends: ["spectral:oas"]
rules:
path-naming-convention:
description: Paths use kebab-case segments, max three segments.
severity: error
given: $.paths[*]~ # the ~ targets the path KEY, not its value
then:
function: pattern
functionOptions:
match: "^/([a-z0-9-]+)(/[a-z0-9-]+|/\\{[a-zA-Z][a-zA-Z0-9]*\\}){0,3}$"
no-trailing-slash:
description: Paths must not end with a trailing slash.
severity: error
given: $.paths[*]~
then:
function: pattern
functionOptions: { notMatch: "/$" }
operation-id-required:
description: Every operation needs an operationId for SDK generation.
severity: error
given: $.paths[*][get,post,put,patch,delete]
then: { field: operationId, function: truthy }
A failing run exits with code 1 and prints the offending path with a line number, blocking the merge until it is fixed.
Advanced Options
Tag-driven portal navigation. Declare top-level tags with descriptions and assign every operation a tag. Portal generators turn tags into sidebar groups, so a disciplined tag taxonomy is your information architecture:
tags:
- name: Users
description: User account management
- name: Orders
description: Order creation and fulfillment
Server-relative base paths. Keep the version and host out of path keys entirely and put them in servers. This makes the same paths block valid across production, staging, and local without edits, and lets the portal offer a server dropdown in its “Try It” panel.
Collision-safe aggregation. When merging service specs, give each service a distinct resource prefix and wire redocly join into CI. It refuses to merge duplicate path keys, turning a silent portal overwrite into a loud build failure you can fix before release.
Consistency is worth more than any individual convention
Most path debates — plural or singular nouns, nesting depth, hyphens or underscores — have no correct answer, and teams spend disproportionate time on them. What genuinely costs consumers is not which convention you chose but that different parts of the API chose differently. A developer who learns your naming once should never have to learn it again, and every inconsistency is a small tax paid by every integrator forever.
That is also why path conventions belong in an automated check rather than in review comments. A reviewer catches an inconsistency if they happen to know the convention and happen to be paying attention; a lint rule catches it every time, and it makes the convention discoverable to someone who has never read the style guide. Enforcing API style guides with Spectral in CI covers wiring those rules into a gate.
Paths become names everywhere downstream
A path is not only a route. It becomes the sidebar structure in the rendered portal, part of the generated SDK’s namespace, the grouping in a changelog, and the anchor that other documentation links to. That multiplication is what makes path design worth doing carefully once rather than adjusting later — a rename is cheap in the router and expensive everywhere it has propagated.
Two practical consequences follow. Keep nesting shallow, because deep paths produce deep SDK namespaces and unreadable navigation trees for no gain in expressiveness — a resource that can be addressed directly should be. And give collections a consistent pagination shape, because collection endpoints are where inconsistency is most visible to consumers and most expensive in generated clients; Designing pagination parameters in OpenAPI covers doing that once rather than per endpoint.
Verification & Testing
Confirm both structural validity and convention compliance before merging:
npx redocly lint openapi.yaml # structural + best-practice checks
npx spectral lint openapi.yaml --ruleset .spectral.yaml --fail-severity error
echo $? # 0 means all path rules passed
Build the portal and inspect the sidebar: every operation should appear under its declared tag, paths should read as plural nouns, and the server dropdown should list each servers entry. For multi-service portals, run redocly join and confirm it completes without a duplicate-key error before deploying.
Deciding what is a resource
Most path problems are really modelling problems that surfaced as naming problems. Before arguing about pluralisation, settle which things in your domain are resources, which are sub-resources, and which are actions that do not deserve a path of their own.
A useful test is whether something has an identity a consumer can hold onto. An invoice does — it can be fetched, listed, and referred to later — so it is a resource. “Send this invoice by email” does not; it is something you do to an invoice, and it wants either a sub-path expressing the action or a state change on the resource itself. Treating every verb as a resource produces paths that read like function calls and SDK namespaces full of methods that do not group.
Sub-resources are worth the extra nesting only when the child genuinely cannot be addressed without its parent. Line items on an invoice usually can be — they have their own identifiers and can be fetched directly — so nesting them buys nothing and costs a longer path in every generated client. Comments attached to a specific review often cannot, and there the nesting expresses a real constraint.
The other recurring modelling question is what to do with filtering, sorting, and searching. All three are properties of a request against a collection rather than separate resources, so they belong in query parameters on the collection endpoint. A dedicated /invoices/search path is a common shape and a costly one: it duplicates the collection’s response schema, needs its own pagination, and gives consumers two ways to do the same thing that behave subtly differently. When a search endpoint already exists, the migration is usually to fold it into the collection and deprecate the separate path.
Making the structure survive growth
A path structure that reads well with twenty operations can become unnavigable at two hundred, and the failure is gradual enough that nobody notices the point at which it happened. Two habits keep it manageable.
The first is to give every operation a tag from a deliberately small, stable set, because tags rather than paths determine how the reference groups. A tag set that grows with every feature produces a sidebar nobody can scan; one that describes the handful of areas a consumer thinks in terms of stays useful indefinitely. Tag names are also public — they appear in navigation, in generated SDK namespaces, and in changelog groupings — so they deserve the same care as paths.
The second is to review new paths against the existing structure rather than in isolation. A route that is perfectly reasonable on its own can still be the fifth different way your API expresses the same idea, and that is only visible when someone looks at the whole. A short checklist in the pull request template — does this match the existing shape, does it need a new tag, could it be a parameter on an existing collection — catches most of it without ceremony.
Troubleshooting
Duplicate operationId during SDK generation — Two operations share an operationId, so the generator cannot produce distinct method names. Make every operationId unique and descriptive (listOrders, getOrderById); the operation-id-required rule plus a uniqueness check catches this early.
Spectral reports no matches for a path rule — The JSONPath targets the path value instead of the key. Use the trailing ~ ($.paths[*]~) to match the path string itself; without it, the regex runs against the path-item object and never fires.
Over-nested paths produce unwieldy SDK names — A path like /tenants/{id}/users/{id}/posts/{id}/comments generates deeply chained method names and brittle docs. Flatten with query parameters: GET /comments?userId={id}&postId={id}. Enforce the limit with the nesting regex above.
Silent breaking change from a renamed path — Removing or renaming a path without deprecation forces consumers to debug 404s. Keep the old path with deprecated: true, document the replacement in its description, and retain it for at least one major version while emitting a Sunset header.
Changing a path after it ships
Renaming a route is a breaking change, and the fact that it is trivially easy in the router is what makes it dangerous. Every consumer calling the old path breaks at the moment of the rename, and unlike a schema change there is no partial degradation — the request simply fails.
The workable pattern is the same one that applies to any retirement. Add the new path, keep the old one working, mark the old one deprecated with a published sunset date, and emit the runtime headers that tell a running client it is calling something with an expiry. Only after the notice period has actually elapsed does the old route return a deliberate 410 naming its replacement, and only after that does it disappear.
That sequence is slow by design, and the temptation to shortcut it is strongest for changes that feel cosmetic — fixing a typo in a path, correcting a singular to a plural. Those are exactly the renames that break integrations for no functional benefit, so the honest question before any path change is whether the improvement is worth a deprecation cycle. Often it is not, and living with an imperfect path is cheaper than the migration it would cost to fix.
Where a rename genuinely is worth it, batching several into one major version is far kinder than trickling them out individually. A consumer can plan one migration; five small ones spread across a year is five interruptions, and by the third one they stop believing your compatibility promises.
FAQ
Should I use URL versioning or header-based versioning?
URL versioning such as /v1/orders is recommended for public APIs and developer portals because it is visible in browser history, logs, and bookmarks and is trivial to route at a gateway. Header-based versioning suits internal microservices where a gateway enforces and abstracts the version negotiation.
How do I prevent path collisions when merging multiple specs?
Give each service a distinct path prefix such as /v1/orders and /v1/users, then run a collision-detection step before portal generation. The redocly join command merges specs and errors on duplicate path keys, so wiring it into CI catches clashes before they reach the portal.
What is the maximum recommended path nesting depth?
Two levels, for example /users/{userId}/posts. Flatten anything deeper using query parameters or independent top-level resources so SDK method names and portal navigation stay readable.
Can I enforce path conventions automatically in CI?
Yes. Spectral custom rules with regex patterns enforce kebab-case segments, parameter formats, and nesting limits on every pull request. Fail the build on error-severity findings so non-conforming paths cannot merge.
Related
- OpenAPI & AsyncAPI Schema Authoring — parent overview
- How to structure OpenAPI paths for microservices
- Defining JSON Schema Components — payload schemas referenced by your paths
- Security Schemes & OAuth Flows — per-operation auth requirements
- Webhook & Callback Definitions — paths that trigger outbound events
The through-line across all of this is that paths are cheap to design and expensive to change, which inverts the usual instinct to defer decisions until requirements are clearer. Spending an afternoon settling conventions before the first dozen endpoints exist buys a structure that stays predictable for years; deferring it produces an API where each area reflects whoever built it, and where the only remedy is a migration nobody wants to fund.
The conventions themselves matter far less than having them written down, enforced automatically, and applied uniformly from the first endpoint onward.
A convention that exists only in someone’s head is not a convention; it is a preference that happens to have been followed so far, and it will stop being followed the moment that person is not the reviewer.