# How to enforce a naming convention across 40 microservice specs

> Run one naming ruleset in every repository, then compare terms across specs, because no per-document linter sees that billing says customer and CRM says client.

Source: https://voxgig.com/howto/enforce-naming-conventions-across-many-specs

- Audience: platform-team
- Level: advanced
- Verified: 2026-09-24
- Published: 2026-09-24

## Short answer

Put the naming rules in one Spectral ruleset that every service extends, and run it in each repository. Then build a model of every spec's entities and operations and compare terms across services, which no per-document linter can do. Rename at the next major version rather than on the branch where you found the violation, because an operationId becomes an SDK method name.

---
## You will need

Node 22 or later, and read access to every service spec, which in practice means a checkout or a
registry you can fetch from. The standard applied here is the one
[AIP-122](https://google.aip.dev/122) and the
[Zalando guidelines](https://opensource.zalando.com/restful-api-guidelines/#134) share, plural
collection segments, plus `snake_case` operation ids. [The OpenAPI
specification](https://spec.openapis.org/oas/v3.1.0.html#operation-object) recommends that an
`operationId` follow common programming naming conventions and stops there,[^1] which is how forty
services can each obey it and still disagree.

Voxgig maintains apidef. This page compares it with a shared Spectral ruleset, a Redocly
configuration that lists every API, and a hand-written cross-spec script.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A hand-written cross-spec script](https://spec.openapis.org/oas/v3.1.0.html#paths-object) | You need an answer no linter gives, such as which services disagree with each other | Your own walk of the Paths Object, your own idea of a term, and a script somebody has to own | The question is one a per-document rule can answer |
| [A shared Spectral ruleset](https://docs.stoplight.io/docs/spectral/83527ef2dd8c0-extending-rulesets) | Rules about one document, run in every repository's own CI | A package to publish and forty repositories to bump, and no view across documents | You need to compare names between services |
| [apidef model extraction](https://github.com/voxgig/apidef) | You want entities and operations already grouped and singular before you compare | A guide file per service, a temporary model directory, and its opinion of what an entity is | Your paths do not divide into entities, or raw strings are enough |
| [Redocly multi-API configuration](https://redocly.com/docs/cli/configuration/reference/apis) | One repository or a mirror holds every spec, and one command should lint them all | A list of APIs to keep current, and still one rule per document rather than across them | The specs live in forty repositories and nobody wants a mirror |

The per-document tools differ in where the rule runs, not in what it can see. Spectral runs where
the spec lives and needs a package bump to change a rule in forty places. Redocly lists every API
in one file and needs that file kept current. Neither reads two documents at once. The cross-spec
check does, and in exchange it needs a model, a schedule, and an owner, because nothing in any one
repository will run it for you.

## Write the rule once and run it in every repository

Two rules, both built on the `pattern` function Spectral ships, so the ruleset carries no code.

```yaml title=".spectral.yaml"
rules:
  operation-operationId: error
  operation-id-snake-case:
    description: operationId is snake_case
    given: "$.paths[*][get,put,post,delete,patch].operationId"
    severity: error
    then:
      function: pattern
      functionOptions:
        match: "^[a-z][a-z0-9]*(_[a-z0-9]+)*$"
  collection-path-plural:
    description: every literal path segment is plural
    given: "$.paths[*]~"
    severity: error
    then:
      function: pattern
      functionOptions:
        match: "^(/[a-z_]+s(/\\{[a-z_]+\\})?)+$"
```

`given` is a JSONPath, and the trailing `~` selects the key rather than the value, which is how a
rule gets to read the path string itself; the
[custom rulesets guide](https://docs.stoplight.io/docs/spectral/e5b9616d6d50c-custom-rulesets)
covers both. [`pattern`](https://docs.stoplight.io/docs/spectral/cb95cf0d26b83-core-functions) is a
core function. The plural check treats a trailing `s` as a plural, which is what makes it one line,
and also why `status` passes it and `moose` does not.[^2] The file extends `spectral:oas` with every
rule off so the run stays about naming; a real shared ruleset turns the rest back on.

Run it with the [CLI](https://docs.stoplight.io/docs/spectral/9ffa04e052cc1-spectral-cli), naming
the ruleset explicitly.

```bash
npx spectral lint -r .spectral.yaml specs/billing.json
```

```text output
 14:31  error  operation-id-snake-case  operationId is snake_case             paths./invoices.get.operationId
 16:21  error  collection-path-plural   every literal path segment is plural  paths./invoice/{id}
 17:31  error  operation-id-snake-case  operationId is snake_case             paths./invoice/{id}.get.operationId

✖ 3 problems (3 errors, 0 warnings, 0 infos, 0 hints)
```

Three findings, all inside one document. Run the same command in the CRM repository and it
reports `/contact`. Nothing it prints says that CRM and billing name the same person differently.

## List every API in one place

Redocly's configuration carries an `apis` map, and one
[`redocly lint`](https://redocly.com/docs/cli/commands/lint) covers every entry.

```yaml title="redocly.yaml"
apis:
  billing:
    root: specs/billing.json
  catalog:
    root: specs/catalog.json
  crm:
    root: specs/crm.json
  shipping:
    root: specs/shipping.json
  support:
    root: specs/support.json
rules:
  operation-operationId: error
  path-segment-plural: error
  rule/operation-id-snake-case:
    subject:
      type: Operation
      property: operationId
    assertions:
      pattern: /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/
    severity: error
    message: operationId must be snake_case
```

The plural rule is built in as
[`path-segment-plural`](https://redocly.com/docs/cli/rules/oas/path-segment-plural), off in the
recommended set until you turn it on,[^3] and the `snake_case` rule is a
[configurable rule](https://redocly.com/docs/cli/rules/configurable-rules) with a `pattern`
assertion.

```bash
npx redocly lint --format=stylish
```

```text output
specs/billing.json:
  14:31  error    rule/operation-id-snake-case  operationId must be snake_case
  17:31  error    rule/operation-id-snake-case  operationId must be snake_case
  16:5   error    path-segment-plural           path segment `invoice` should be plural.

specs/catalog.json:
  11:31  error    rule/operation-id-snake-case  operationId must be snake_case

specs/crm.json:
  14:5  error    path-segment-plural  path segment `contact` should be plural.
```

One command lints all five specs and prints findings for the three that have any. The `apis` map
is both the point and the cost, because a service missing from it goes ungoverned without a
warning. Each finding is still about one document.

## Compare terms across services, not inside a document

The cross-spec check needs two things a linter does not have: a term per service, and a table
saying which terms mean the same thing. The table is a decision the platform team writes down,
not a heuristic.

```json title="terms.json"
{
  "customer": ["client", "account"],
  "order": ["purchase"]
}
```

A service's terms are its literal path segments, singularized, plus every field ending in `_id`,
because `customer_id` on a shipment is a use of the word `customer` even though shipping has no
customers endpoint. The comparison then groups every term by the concept it names.

```js title="naming.mjs"
export function conflicts(models, synonyms) {
  const concept = new Map()
  for (const [chosen, others] of Object.entries(synonyms)) {
    concept.set(chosen, chosen)
    for (const other of others) concept.set(other, chosen)
  }
  const usage = new Map()
  for (const model of models) {
    for (const term of model.terms) {
      const c = concept.get(term)
      if (!c) continue
      if (!usage.has(c)) usage.set(c, new Map())
      const byTerm = usage.get(c)
      if (!byTerm.has(term)) byTerm.set(term, [])
      byTerm.get(term).push(model.service)
    }
  }
  return [...usage]
    .filter(([, byTerm]) => byTerm.size > 1)
    .map(([chosen, byTerm]) => ({
      concept: chosen,
      terms: [...byTerm].map(([term, services]) => ({ term, services: services.sort() })),
    }))
}
```

A concept used under one term by every service is not reported, however many services use it.
Only a split is.

```bash
node crossspec.mjs
```

```text output
model: raw paths and fields

service   ops  violations                      terms
billing   5    2 operationId, 1 singular path  customer, invoice
catalog   6    1 operationId                   customer, product, purchase
crm       6    1 singular path                 client, contact
shipping  4    none                            customer, event, order, shipment
support   6    none                            account, message, ticket

the same concept, different names
  customer  billing, catalog, shipping say customer; crm says client; support says account
  order     catalog says purchase; shipping says order
```

Read the last three lines. Shipping and support pass every rule, and between them they use three
words for one person. That finding exists in no single document, which is why no per-document
linter produces it, and why this script needs a schedule and an owner.

## Let apidef do the grouping

The raw walk decides nothing about structure. apidef builds an entity model per spec first, with
entities already singular, `/invoices` and `/invoice/{id}` under one `invoice`, and every operation
classified as list, load, create, update, or remove. The same report can read that model instead.

```js title="apidef-model.mjs"
  const build = await ApiDef.makeBuild({ folder, outprefix: `${service}-` })
  const def = relative(join(root, 'def'), resolve(file))
  const result = await build({ name: service, def }, { spec: { base: folder }, log: quiet }, {})
  if (!result.ok) throw new Error(`apidef failed on ${file}: ${result.err?.message}`)

  const ops = []
  const terms = new Set()
  for (const [name, entity] of Object.entries(result.apimodel.main.kit.entity)) {
    terms.add(name)
    for (const field of Object.keys(entity.fields ?? {})) if (field.endsWith('_id')) terms.add(field.slice(0, -3))
```

The build wants a project folder holding a guide file, so the adapter makes a temporary one per
service and passes a logger that says nothing. It removes the folder once the build has returned.

```bash
node crossspec.mjs --model=apidef
```

```text output
model: apidef entities

service   ops  violations                      terms
billing   5    2 operationId, 1 singular path  customer, invoice
catalog   6    1 operationId                   customer, product, purchase
crm       6    1 singular path                 client, contact
shipping  4    none                            customer, order, shipment, shipment_event
support   6    none                            account, message, ticket

the same concept, different names
  customer  billing, catalog, shipping say customer; crm says client; support says account
  order     catalog says purchase; shipping says order
```

One cell differs. Shipping's `event` became `shipment_event`, because apidef names a child entity
after its parent, and the conflicts are the same. That is the trade in one line. You get a model
that has already decided what an entity is, and you pay with a guide file, a temporary directory,
and an opinion your paths may not share. On five flat specs the raw walk is enough. On forty, with
nested paths and versioned prefixes, the grouping is the part you would otherwise write yourself.

## Check it worked

The report exits non-zero when it finds a violation or a conflict, so a scheduled job fails
visibly. The unit tests pin each finding to a document that should and a document that should not
produce it.

```bash
node --test naming.test.mjs
```

```text output
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 99.393255
```

## Measure a rename before you make it

Every finding in the report is a rename waiting to happen, and an `operationId` is not a private
detail.
[OpenAPI Generator](https://openapi-generator.tech/docs/customization/) uses it in method naming
and offers `operationIdNameMappings` to override it, and Speakeasy reads
[`x-speakeasy-name-override`](https://www.speakeasy.com/openapi/extensions) for the same reason. A
fix that renames an id renames a method in every SDK built from the spec.

```js title="naming.mjs"
export function impact(rename) {
  if (rename.kind === 'path') return 'URL change, reaches every caller'
  const before = methodNames(rename.from)
  const after = methodNames(rename.to)
  const same = before.python === after.python && before.typescript === after.typescript
  return same ? 'case only, survives re-casing' : `renames ${before.typescript}() in every SDK`
}
```

```bash
node rename-impact.mjs crm
```

```text output
service   rename                                          impact
crm       /contact -> /contacts                           URL change, reaches every caller
crm       list_clients -> list_customers                  renames listClients() in every SDK
crm       /clients -> /customers                          URL change, reaches every caller
crm       create_client -> create_customer                renames createClient() in every SDK
crm       get_client -> get_customer                      renames getClient() in every SDK
crm       /clients/{client_id} -> /customers/{client_id}  URL change, reaches every caller
crm       update_client -> update_customer                renames updateClient() in every SDK

7 renames: 0 case only, 4 SDK method renames, 3 URL changes
```

Bringing one service into line takes seven renames, and their cost depends on their kind. A
case-only change
survives a generator that re-cases ids for each language. A word change renames a method every
consumer calls. A path change reaches every caller, including the ones with no SDK at all. Record
the mismatch, ship the rename under a new [major version](https://semver.org/#spec-item-8), and
keep the old name deprecated beside the new one until then.

## When it goes wrong

The report lists a conflict that is not one. In support, `account` means a login, not a customer,
and the synonym table said otherwise. The table is a decision per concept, so split the entry
rather than deleting the rule.

Spectral passes in one repository and fails in another on the same spec. The two pin different
versions of the shared ruleset package. Pin the version in every repository and bump them together.

The plural rule flags a word with no plural. AIP-122 keeps `info` and `moose` singular for exactly
this reason. Add the word to an exception list in the rule, and keep the list in the shared package
so forty repositories do not each grow their own.

A team fixes every finding on a branch and ships it. Consumers of the generated SDK now call
methods that do not exist. The rename table is the review comment to leave before the merge.

## When not to do this

Do not rename published operation ids on the branch where you found them. A linter finding is a
fact about the document; the rename is a change to every SDK method built from it and, for a path,
to every URL a client calls. Record the finding, deprecate the old name, and rename at the next
major.

Do not reach for apidef to check one document. A Spectral rule is one line, and apidef wants a
guide file, a model directory, and a package install before it says a word. Its grouping earns its
keep when paths nest, prefixes vary, and you would otherwise write the singularizing and grouping
yourself. It also carries its own view of what an entity is, and a spec built around actions rather
than resources will not fit it.

Do not run the cross-spec check with no owner. It lives in no service repository, so no
repository's CI will run it, and a scheduled job nobody reads is a job that has already been
switched off.

Do not let the synonym table grow out of linter findings. It records what the platform has decided
a word means, and a table nobody argued about is a table that will flag a login as a customer.

## Related how-tos

- [Split and bundle a large OpenAPI document](/howto/split-and-bundle-an-openapi-document)

- [Tell an entity-shaped SDK from an endpoint-shaped one](/howto/measure-entity-versus-endpoint-shaped-output)

## Last verified

Verified 2026-09-24 against Node 22.22.2, `@stoplight/spectral-cli` 6.16.3, `@redocly/cli` 2.54.2
and `@voxgig/apidef` 8.17.0. Every output block is what the preceding command printed, run in the page's
code directory after `npm ci`. The five specs are local stand-ins for a fleet, so nothing here ran
against a live registry or a real service.

[^1]: The sentence is older than OpenAPI. [Swagger 2.0](https://swagger.io/specification/v2/)
said tools "MAY use the operationId to uniquely identify an operation, therefore, it is recommended
to follow common programming naming conventions".
[OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0.html#operation-object) keeps the words and
promotes the recommendation to a capitalized RECOMMENDED. Neither edition says whose conventions.
`snake_case` and `camelCase` are each common somewhere, so a fleet can follow the advice in five
different ways and every one of them is compliant.

[^2]: [AIP-122](https://google.aip.dev/122) requires plural collection identifiers and then spends
a paragraph on the words English declines to pluralize. `info` has no plural and `moose` has the
same one, so both stay singular, and a collection segment must not coin a word by adding an `s`.
A rule that looks for a trailing `s` enforces the first sentence and forbids the second, which is
a fair summary of what one line of regular expression knows about English.

[^3]: Redocly ships
[`path-segment-plural`](https://redocly.com/docs/cli/rules/oas/path-segment-plural) with its
severity off in the recommended configuration and an `ignoreLastPathSegment` option for the
singleton at the end of a path. The plural rule is the one every style guide agrees on, and the
one the vendor leaves off. That says something about how it fares on a fleet of real paths.