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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A hand-written cross-spec script | 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 | 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 | 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 | 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.
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.
Related how-tos
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
-
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_caseandcamelCaseare each common somewhere, so a fleet can follow the advice in five different ways and every one of them is compliant. ↩︎ Back to text -
AIP-122 requires plural collection identifiers and then spends a paragraph on the words English declines to pluralize.
infohas no plural andmoosehas the same one, so both stay singular, and a collection segment must not coin a word by adding ans. A rule that looks for a trailingsenforces 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 -
Redocly ships
path-segment-pluralwith its severity off in the recommended configuration and anignoreLastPathSegmentoption 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