Enforcing Style Guides Across Monorepo Specs
This page belongs to Contract Linting & Governance in the API Design Fundamentals & Architecture reference. A style guide that lives in one service’s config file governs one service. Applying it to forty specifications in one repository raises three problems the single-service case never faces: distribution, existing violations, and CI time.
The decision trigger
| Symptom | Cause |
|---|---|
| Each service has its own drifted lint config | ruleset copied rather than shared |
| Nobody enabled the guide because “we have 400 violations” | no baseline; all-or-nothing adoption |
| CI takes twelve minutes to lint on a one-line change | every specification linted every time |
| One service disabled a rule and nobody noticed | overrides invisible and permanent |
| A shared component change broke a dependent service’s lint | dependents not linted |
| Two services return different error shapes | no cross-cutting rule enforced |
Spec snippet: the ruleset as a versioned package
# packages/api-style/rulesets/base.yaml — published as @acme/api-style
extends: ["spectral:oas"]
rules:
# ── naming ────────────────────────────────────────────────
operation-operationId: error
operation-operationId-unique: error
operationid-lower-camel:
description: operationId must be lowerCamelCase (verb + Resource).
severity: error
given: "$.paths[*][get,put,post,delete,patch].operationId"
then: { function: pattern, functionOptions: { match: "^[a-z][a-zA-Z0-9]+$" } }
path-kebab-case:
description: Path segments must be kebab-case, plural for collections.
severity: error
given: "$.paths"
then: { field: "@key", function: pattern, functionOptions: { match: "^(/[a-z0-9-]+|/\\{[a-zA-Z]+\\})+$" } }
# ── cross-cutting contracts ───────────────────────────────
errors-use-problem-json:
description: Every 4xx/5xx must return application/problem+json.
severity: error
given: "$.paths[*][*].responses[?(@property.match(/^[45]/))].content"
then: { field: "application/problem+json", function: truthy }
collections-are-enveloped:
description: A 200 list response must be an object, not a top-level array.
severity: error
given: "$.paths[*].get.responses['200'].content['application/json'].schema"
then: { field: type, function: pattern, functionOptions: { notMatch: "^array$" } }
union-needs-discriminator:
description: A oneOf over object schemas must declare a discriminator.
severity: error
given: "$.components.schemas[?(@.oneOf)]"
then: { field: discriminator, function: truthy }
// packages/api-style/package.json — versioned so upgrades are deliberate
{
"name": "@acme/api-style",
"version": "3.4.0",
"files": ["rulesets"],
"exports": { "./base": "./rulesets/base.yaml" }
}
# services/billing/.spectral.yaml — a service extends, then overrides narrowly
extends: ["@acme/api-style/base"]
rules:
# Documented, dated exception — not a silent fork.
collections-are-enveloped: off # legacy /v1/exports returns NDJSON; removed 2026-12-31
Step-by-step
Step 1 — Baseline what already fails
# scripts/lint-baseline.sh — record current violations per service
set -euo pipefail
mkdir -p .lint-baselines
for spec in services/*/openapi.yaml; do
service="$(basename "$(dirname "$spec")")"
count=$(npx spectral lint "$spec" --format json 2>/dev/null \
| jq '[.[] | select(.severity == 0)] | length')
echo "$count" > ".lint-baselines/${service}.count"
printf '%-14s %s error(s)\n' "$service" "$count"
done
# CI: fail when a service's count RISES above its baseline
current=$(npx spectral lint "$spec" --format json | jq '[.[] | select(.severity == 0)] | length')
baseline=$(cat ".lint-baselines/${service}.count")
if [ "$current" -gt "$baseline" ]; then
echo "::error::${service}: lint errors rose from ${baseline} to ${current}"
exit 1
elif [ "$current" -lt "$baseline" ]; then
echo "$current" > ".lint-baselines/${service}.count" # ratchet down, commit it
echo "::notice::${service}: lint errors reduced to ${current} — baseline updated"
fi
The ratchet is what makes adoption possible. A team with 400 existing violations will never schedule a cleanup sprint, but they will happily accept “do not add more”, and every touched file gets fixed opportunistically until the count reaches zero.
Step 2 — Lint only what changed, plus its dependents
// scripts/affected-specs.mjs — which specifications does this change affect?
import { execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const base = process.env.BASE_REF ?? "origin/main";
const changed = execSync(`git diff --name-only ${base}...HEAD`, { encoding: "utf8" })
.split("\n").filter(Boolean);
// Build the reference graph: which root documents include which files?
const roots = execSync("ls services/*/openapi.yaml", { encoding: "utf8" }).split("\n").filter(Boolean);
const dependsOn = new Map();
for (const root of roots) {
const seen = new Set();
const queue = [root];
while (queue.length) {
const file = queue.pop();
if (seen.has(file)) continue;
seen.add(file);
const text = fs.readFileSync(file, "utf8");
for (const m of text.matchAll(/\$ref:\s*["']?(\.\.?\/[^"'#\s]+)/g)) {
queue.push(path.normalize(path.join(path.dirname(file), m[1])));
}
}
dependsOn.set(root, seen);
}
const affected = roots.filter(root =>
changed.some(file => dependsOn.get(root).has(path.normalize(file))),
);
console.log(affected.join("\n"));
Following $ref edges is the part naive implementations skip. A change to shared/components/errors.yaml touches no service directory, yet it can break every service that references it — and linting only the “changed service” would miss all of them.
Step 3 — Wire it into CI as a matrix
# .github/workflows/api-lint.yml
name: API lint
on: [pull_request]
jobs:
discover:
runs-on: ubuntu-latest
outputs:
specs: ${{ steps.affected.outputs.specs }}
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- id: affected
run: |
list=$(node scripts/affected-specs.mjs | jq -R -s -c 'split("\n") | map(select(length > 0))')
echo "specs=$list" >> "$GITHUB_OUTPUT"
lint:
needs: discover
if: needs.discover.outputs.specs != '[]'
runs-on: ubuntu-latest
strategy:
fail-fast: false # report every service, not just the first
matrix:
spec: ${{ fromJson(needs.discover.outputs.specs) }}
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: ./scripts/lint-with-baseline.sh "${{ matrix.spec }}"
fail-fast: false matters for developer experience: a change to a shared component that breaks four services should report all four in one run, not one per push.
Step 4 — Make overrides visible and temporary
# scripts/check-overrides.yaml — every `off` needs a reason and a date
# services/*/.spectral.yaml is scanned for disabled rules; each must appear here.
- service: billing
rule: collections-are-enveloped
reason: /v1/exports streams NDJSON; migration tracked in API-2870.
expires: 2026-12-31
- service: catalogue
rule: path-kebab-case
reason: Legacy /productSearch path retained until v2 sunset.
expires: 2027-03-31
Run the same expiry check used for breaking-change suppressions in Detecting Breaking Changes with oasdiff — an override with no expiry becomes a permanent fork of the style guide by accident.
The adoption path matters as much as the rules: a guide introduced as a wall fails, while one introduced as a ratchet succeeds quietly.
Standard compliance
| Concern | Standard | Note |
|---|---|---|
| Document validity | OpenAPI 3.0 / 3.1 | spectral:oas covers structural rules |
| JSONPath expressions in rules | JSONPath (RFC 9535) | given selectors |
| Schema keyword rules | JSON Schema 2020-12 | Composition and validation rules |
| Error contract rule | RFC 9457 | The problem+json requirement |
| Ruleset distribution | package registry | Versioning is what makes upgrades reviewable |
SDK / codegen downstream effect
The rules that matter most in a monorepo are the cross-cutting ones, because they are what make many services feel like one API to a consumer:
// Without cross-cutting rules — three services, three error shapes
- billing.createInvoice() → { error: { code: "INVALID" } }
- orders.createOrder() → { message: "invalid", fields: [...] }
- identity.createUser() → { errors: [{ detail: "..." }] }
+ // With errors-use-problem-json enforced everywhere
+ every service → application/problem+json { type, title, status, detail }
+ // one shared error handler in the client, not three
That consolidation is the practical payoff for the governance effort: a consumer writes one error-handling path rather than one per service, exactly as described in Standardizing Error Responses Across Microservices.
Common mistakes
| Mistake | Correct approach |
|---|---|
| Copying the ruleset into each service | Publish it as a versioned package and extend |
| Requiring zero violations before enabling | Baseline and ratchet down |
| Linting every specification on every pull request | Lint affected roots via the $ref graph |
| Ignoring dependents of shared components | Resolve the reference graph when selecting |
| Permanent rule overrides | Require a reason and an expiry; check both in CI |
fail-fast: true on the lint matrix |
Report every failing service in one run |
| Only structural rules, no cross-cutting ones | Enforce the error, envelope and naming contracts |
FAQ
How do I introduce a style guide to specs that already violate it?
Baseline first, then ratchet. Record each service’s current error count, and make CI fail only when the count rises; when it falls, commit the lower number as the new baseline. This converts an impossible ask — stop everything and fix 400 findings — into an easy one: do not add new violations, and fix what you touch. In practice counts fall steadily because most rules are trivially satisfiable once someone is already editing the file, and the trend is visible enough that teams start clearing the remainder for the satisfaction of reaching zero.
Should each service be able to override shared rules?
Yes, but with friction proportional to the cost. A service with a genuinely different constraint — a streaming export endpoint that cannot use the standard envelope, a legacy path that cannot be renamed before a version sunset — needs an escape hatch, or it will fork the ruleset entirely and you lose all visibility. Make the override local to the service config, require a written reason and an expiry date, and check those in CI. The exception then stays visible in review, and it disappears on schedule rather than becoming permanent by neglect.
How do I keep linting fast in a repository with many specifications?
Lint what the change affects, not everything. Resolve each root document’s $ref graph, and select the roots whose transitive file set intersects the pull request’s changed files — that way a change to a shared error component correctly lints every dependent service, while a change to one service’s paths lints only that service. Run the exhaustive sweep on a nightly schedule so drift from ruleset upgrades is still caught, and keep the pull-request job as a matrix with fail-fast disabled so contributors see every affected service in a single run.
Related
- Contract Linting & Governance — up-link: the section covering rule design and governance
- API Design Fundamentals & Architecture — up-link: the parent reference for contract-first design
- Writing Custom Spectral Rules — authoring the rules this page distributes
- Spectral vs Redocly Linting Rule Coverage — choosing the linter the shared ruleset targets
- Reusable Schema Components and $ref Cycles — the reference graph this workflow walks