OpenAPI governance: catching broken specs before they break your docs
A valid OpenAPI spec can still be a bad one. Here's how to lint, validate, and version specs so broken contracts never reach your docs.
A field gets renamed from user_id to userId in a PATCH request. The engineer updates the handler, updates the tests, ships it. Nobody touches the OpenAPI spec, because the spec still “works” — it’s syntactically valid YAML, it still parses, your docs generator still renders a page from it. Three weeks later a customer’s integration throws a 400 on a field that the docs still call user_id. Nobody broke a build. Nothing failed CI. The spec was never actually wrong in a way any tool checked for — it was just incomplete, and incompleteness doesn’t throw errors.
This is the failure mode that makes OpenAPI governance different from ordinary code review. A broken function throws a stack trace. A broken spec throws nothing — it just quietly generates a page that’s missing an example, or documents a field that no longer exists, or lists a required parameter as optional. The spec is technically “valid” the whole time. That’s the trap.
How a bad OpenAPI spec becomes bad docs, silently
Docs generators are honest tools: they render exactly what the spec says, no more and no less. That’s usually a feature. When the spec is bad, it becomes the mechanism of failure.
Here’s the propagation path, concretely:
- An endpoint gets a new required field, but the engineer forgets to mark it
requiredin the schema. The spec still validates. The generated docs show it as optional. - A response schema keeps an old enum value that the backend stopped returning two releases ago. Nothing flags it, because “documenting a value that no longer occurs” isn’t a spec error — it’s a stale spec.
- A path parameter has no
descriptionfield. The generator can’t invent one, so the page ships with a blank cell, or the parameter name doing double duty as its own explanation. - Two endpoints use different casing for what is conceptually the same field —
created_aton one resource,createdAton another — because two different teams wrote the specs six months apart. Each spec is individually valid. Together, they make your API look like it was designed by two companies. - An
exampleblock is copy-pasted from a different endpoint and never updated. The rendered docs show a request that would actually 422 if you ran it.
None of these are YAML syntax errors. None of them fail a basic swagger-cli validate. They’re semantic problems — the spec is a legal document that says the wrong thing — and a docs generator has no way to know that, because its job is to render the spec faithfully, not to judge whether the spec is telling the truth.
This is the core distinction worth sitting with: doc generation trusts the spec. Governance is what makes that trust earned. If you only ever check that the spec parses, you’ve verified the container, not the contents. The rot happens inside a spec that never fails a build.
Linting rules worth enforcing — naming, required fields, examples, descriptions
Most teams that adopt spec linting start too narrow (JSON Schema validity only) or too broad (a 200-rule style guide nobody reads). The useful middle ground is a small set of rules that map directly to what shows up broken in rendered docs.
Naming consistency. Pick one casing convention — camelCase or snake_case — and enforce it across every schema property, path parameter, and query parameter. Mixed casing across endpoints is the single most common thing that makes an API “feel” undocumented even when every field technically has a description.
Required-field accuracy. Every property that the API actually enforces as mandatory must be listed under required in the schema. This one can’t be fully automated (a linter can’t know your backend’s runtime behavior), but you can at least enforce that required arrays exist and aren’t empty on request bodies, and pair that with contract tests that catch drift between the spec and the actual API behavior.
Examples on every operation. Every request body and every 2xx/4xx response should carry a realistic example or examples block. A schema without an example forces the reader to mentally construct a valid payload from type definitions — that’s the difference between docs someone can copy-paste and docs someone has to reverse-engineer.
Descriptions everywhere it matters. Operations, parameters, and non-trivial schema properties need a description. Not “the user id” — an actual sentence about what the value means, its constraints, or when it’s populated.
Structural hygiene. Every operation has a unique operationId, every response declares a content-type, every error response uses a shared error schema instead of an ad hoc one-off shape.
A minimal Spectral ruleset that enforces the first four looks like this:
# .spectral.yaml
extends: [[spectral:oas, all]]
rules:
camel-case-properties:
description: Schema properties must use camelCase naming
given: "$.components.schemas..properties[*]~"
severity: error
then:
function: casing
functionOptions:
type: camel
operation-must-have-examples:
description: Every request body and response must include an example
given: "$.paths[*][*].responses[*].content[*]"
severity: error
then:
field: examples
function: truthy
operation-description-required:
description: Every operation needs a non-trivial description
given: "$.paths[*][*]"
severity: warn
then:
field: description
function: length
functionOptions:
min: 20
request-body-required-fields-declared:
description: Request body schemas must declare a required array
given: "$.paths[*][*].requestBody.content[*].schema"
severity: error
then:
field: required
function: truthy
That’s roughly 30 lines and it catches a large share of the drift that would otherwise surface as a confusing docs page. Start there. Add rules as you find real incidents, not speculative ones — a ruleset built from imagination instead of postmortems turns into noise nobody fixes.
Adding spec validation to CI so broken specs never merge
A lint ruleset only pays off if it runs before a bad spec reaches main. Bolting it onto a docs build step that runs after merge means you find out the spec was bad the same way you found out today — from a rendered page, after the fact.
The rule to enforce: any PR that touches the OpenAPI spec runs linting as a blocking check, not a warning.
- ❌ Lint runs, posts a comment on the PR, merge button stays green regardless. Everyone learns to scroll past the comment within two weeks.
- ✅ Lint runs as a required status check. A failing rule blocks the merge button, the same way a failing test suite would.
A GitHub Actions job that does this:
# .github/workflows/openapi-lint.yml
name: OpenAPI Governance
on:
pull_request:
paths:
- 'openapi.yaml'
- 'openapi/**'
jobs:
lint-spec:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate spec is well-formed
run: npx @redocly/cli lint openapi.yaml
- name: Run governance ruleset
run: npx @stoplight/spectral-cli lint openapi.yaml --ruleset .spectral.yaml --fail-severity=error
- name: Diff against previous version for breaking changes
run: npx @redocly/cli diff openapi.yaml --base origin/main
Three checks, three different failure modes:
- Validity catches structural breakage — malformed refs, invalid schema types, missing required OpenAPI fields. This is the floor, not the ceiling.
- Governance rules catch the naming, examples, and description gaps from the previous section.
- A diff check against the previous version catches breaking changes to the contract itself — a field that went from optional to required, a removed enum value, a response shape that changed type.
The third check matters as much as the first two. A spec can be perfectly well-formed and perfectly well-documented and still break every existing integration if a field silently goes from a string to an object. Diffing against the base branch is what catches that before it ships, not after a customer files a ticket.
Treat this the same way you’d treat a failing test suite: it’s a blocking status check on the PR, required before merge, no override without an explicit reviewer sign-off. Anything softer becomes a suggestion, and suggestions get ignored under deadline pressure.
Versioning and deprecating spec fields without breaking generated docs
Linting stops new problems. It doesn’t tell you how to retire old fields without breaking every downstream consumer of the spec — including your own docs.
The failure pattern here is different from the ones above: the spec is fine, the field is genuinely being removed on purpose, but the removal happens as a hard delete instead of a staged deprecation. The generated docs page for that field just disappears between one build and the next, with no explanation, and anyone who bookmarked that section or scraped it into an internal wiki hits a 404 of meaning.
A staged deprecation looks like this instead:
components:
schemas:
Invoice:
type: object
properties:
totalAmount:
type: number
description: >
Total invoice amount in cents. Replaces `amount`, which is
deprecated and will be removed on 2026-11-01.
amount:
type: number
deprecated: true
description: >
Deprecated. Use `totalAmount` instead. Scheduled for removal
on 2026-11-01. See migration guide: /docs/migrations/invoice-amount
Three things make this work well downstream:
deprecated: trueis a first-class OpenAPI keyword — most generators render it as a visible badge automatically, so you don’t need custom tooling to flag it in the docs.- The description carries the removal date and the replacement field, so the rendered page tells the reader what to do without them filing a support ticket.
- The old field stays in the spec through the deprecation window instead of disappearing the moment the new one ships. Docs, SDKs, and any codegen that reads the spec all get one consistent signal, at the same time, instead of the spec and the docs disagreeing about whether the field still exists.
Add a lint rule that enforces this pattern structurally — any property marked deprecated: true must have a description matching a removal-date pattern, and can’t be deleted from the spec until that date has passed. That turns “please remember to deprecate gracefully” from a norm into something CI actually checks.
This is also where keeping the spec as the source of truth pays off twice over. If your docs regenerate from the spec on every merge — which is the model we build around at GitDoc — then a well-governed deprecation flows through automatically: the badge appears, the migration note appears, the removal happens on schedule, and nobody has to remember to manually edit a docs page in three different places. Auto-sync only produces good results when what it’s syncing from is trustworthy — that’s the whole argument for governance existing as a discipline separate from sync itself.
Tools for OpenAPI governance at scale
Below a few dozen endpoints, a single Spectral ruleset and a CI job cover almost everything. Past that, a few more pieces earn their keep:
- Spectral — the de facto standard for custom OpenAPI linting. Rulesets are portable YAML/JSON, extendable, and shareable across repos as an npm package, which matters once you have more than one service with a spec.
- Redocly CLI — spec validation, bundling for multi-file specs, and a genuinely useful
diffcommand for catching breaking changes between versions. - Optic — tracks your API’s actual traffic against the spec and flags when real requests/responses diverge from what’s documented, which catches the gap that pure static linting can’t: the spec matching itself but not matching reality.
- A shared style-guide package — once you have more than two or three services each with their own spec, publish your Spectral ruleset as an internal package (
@yourco/openapi-ruleset) so every repo pulls the same rules instead of copy-pasting a.spectral.yamlthat drifts out of sync with itself. - A required-checks policy at the org level — GitHub branch protection rules or equivalent, applied uniformly so “spec linting is optional for this one service” never becomes a quiet exception.
At scale, the goal isn’t a bigger ruleset — it’s the same small ruleset, enforced everywhere, with zero repos that get to skip it. A governance policy that only fifteen of your twenty services follow isn’t really a policy; it’s a suggestion with extra steps, and the five exceptions are exactly where the silent breakage above will happen next.
None of this replaces sync — it’s what makes sync worth trusting. If a spec is well-governed, generating docs from it (or feeding it into a platform like GitDoc that keeps reference pages current automatically) is a mechanical last step. If the spec is ungoverned, you’ve automated the fast delivery of wrong information — which is worse than a slow, manual process that at least has a human noticing something is off along the way.
GitDoc keeps your docs in sync with your codebase on every push. Start free →