How-to › Describe your API

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.

Audience
Platform team
Level
advanced
Topic
Lint and govern API specs
Verified

Every service passes its own lint, but the platform still reads like five teams wrote it. Three services use three names for the same person: billing exposes /customers, CRM exposes /clients, and support keys its tickets by account_id. The linter in each repository is green, because a rule about one document cannot see the other thirty-nine.

What you get

You will end up with a shared ruleset every service runs, a cross-spec report of each service’s violations and disputed terms, and a table of what each fix renames for consumers. This is for you if you own the API standard for more than a handful of services.

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 and the Zalando guidelines share, plural collection segments, plus snake_case operation ids. The OpenAPI specification 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

ApproachWhen it fitsWhat it costs youWhen to pick something else
A hand-written cross-spec scriptYou need an answer no linter gives, such as which services disagree with each otherYour own walk of the Paths Object, your own idea of a term, and a script somebody has to ownThe question is one a per-document rule can answer
A shared Spectral rulesetRules about one document, run in every repository’s own CIA package to publish and forty repositories to bump, and no view across documentsYou need to compare names between services
apidef model extractionYou want entities and operations already grouped and singular before you compareA guide file per service, a temporary model directory, and its opinion of what an entity isYour paths do not divide into entities, or raw strings are enough
Redocly multi-API configurationOne repository or a mirror holds every spec, and one command should lint them allA list of APIs to keep current, and still one rule per document rather than across themThe 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.

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 covers both. pattern 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, naming the ruleset explicitly.

npx spectral lint -r .spectral.yaml specs/billing.json
 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 covers every entry.

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, off in the recommended set until you turn it on,3 and the snake_case rule is a configurable rule with a pattern assertion.

npx redocly lint --format=stylish
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.

{
  "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.

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.

node crossspec.mjs
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.

  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.

node crossspec.mjs --model=apidef
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.

node --test naming.test.mjs
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 uses it in method naming and offers operationIdNameMappings to override it, and Speakeasy reads x-speakeasy-name-override for the same reason. A fix that renames an id renames a method in every SDK built from the spec.

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`
}
node rename-impact.mjs crm
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, 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.

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.

Footnotes

  1. The sentence is older than OpenAPI. Swagger 2.0 said tools “MAY use the operationId to uniquely identify an operation, therefore, it is recommended to follow common programming naming conventions”. OpenAPI 3.1 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. ↩︎ Back to text

  2. AIP-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. ↩︎ Back to text

  3. Redocly ships 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.