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
One versioned ruleset, many extending services A published ruleset package is extended by the billing, orders, catalogue and identity service configurations. Each may add a narrow local override, but none forks the base rules. @acme/api-style versioned, reviewed, singlesource billing 1 dated override orders no overrides catalogue baseline: 12 legacy identity no overrides upgrading the package is one reviewed pull request, not forty every override is visible in the service config, with a removal date

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.

Ratcheting violation counts down Four services start with different violation counts. Over eight weeks each count falls in steps as touched files are fixed. The gate never allows a count to rise, so the trend is monotonically downward. weeks after the style guide was enabled violations catalogue billing the gate only forbids increases — every decrease is committed as the new baseline

The adoption path matters as much as the rules: a guide introduced as a wall fails, while one introduced as a ratchet succeeds quietly.

Introducing a style guide to an existing repository If the repository already violates the guide, baseline the counts and fail only on increases. If it is clean, enable the rules as hard errors immediately. do the specs already violate the guide? yes no baseline and ratchet record counts per service, fail only when a count rises, commitevery decrease enable as errors now a clean repository never needs the ratchet — hard-fail from thefirst commit the ratchet converts an impossible cleanup into an easy rule: do not add more

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.