Skip to main content

The Perfect API Contract — OpenAPI/Swagger Best Practices, Anti-Patterns & Checklist

A synthesized reference for designing, generating, and operating API contracts in an AI-driven workflow. Source: deep read of internal research summaries (59, 83, 84).


The One Principle Everything Else Follows From​

The OpenAPI 3.1 spec is the single source of truth. Everything downstream is generated from it, never hand-written — and the generation only stays honest if the spec is validated on every change.

"Swagger" today is an ecosystem, not a format. The format is OpenAPI 3.1 (full JSON Schema 2020-12 alignment), and one specs/openapi.yaml should drive the entire stack:

specs/openapi.yaml  →  types (openapi-typescript / Orval)
→ typed hooks + Zod validators (Orval / @hey-api)
→ mock servers (Prism standalone, MSW in-app/Storybook)
→ interactive docs (Scalar)
→ contract tests (Schemathesis, Prism proxy, Dredd)

Two hard truths flow from this:

  1. Drift is the enemy. The moment a type, mock, or client is hand-written instead of generated, it diverges from the contract. Hand-writing types when a spec exists is the single most common drift risk.
  2. AI generates predictable, detectable errors. LLMs produce broken $ref paths, missing required description fields, hallucinated status codes, and OAS 2/3 syntax mixing. 62% of 900,000+ analyzed OpenAPI documents have no security documentation at all. Without a validate-after-generate loop, these ship silently.

So the golden rule is: Author the spec (with human review of the data model), then generate everything else and gate every change behind lint + validation. The spec is the contract; the contract is the system.

For every artifact, apply this filter:

"Can this be generated from the spec?" If yes → generate it, never hand-write it. "Did the spec change?" If yes → lint, validate, and regenerate before moving on.


What to Include vs. Exclude​

✅ Do (spec-first discipline)❌ Don't (drift & risk)
OpenAPI 3.1 as the format (type: ["string","null"])OpenAPI 3.0 nullable: true (removed, not deprecated, in 3.1)
Flat components/schemas library referenced via $refDeeply nested inline schemas (unreadable, bad codegen names)
Generate types/clients/mocks/docs from the specHand-written types, clients, or mocks alongside a spec
RFC 9457 Problem Details for every 4xx/5xxAd-hoc, per-endpoint error shapes
Request/response examples in every schemaSchemas with no examples (breaks mocks + "Try it out")
Human-reviewed data modelTrusting AI-drafted entity relationships unverified
Scalar (modern renderer) + VS Code spec toolingSwagger Codegen, swagger-cli, Swagger Editor (legacy/abandoned)
Docs served at a same-domain path (/reference)Production docs exposed without auth (full recon map)

Split anything reused; keep the root spec as an index. The OpenAPI Initiative's rule: "If the same piece of YAML appears more than once, move it to components."

specs/
├── openapi.yaml # index — references domain files via $ref
├── paths/
│ ├── tasks.yaml
│ └── users.yaml
└── components/
├── schemas/ # flat library: User, TaskCreateRequest, …
├── responses/ # NotFoundResponse, ValidationError (RFC 9457)
├── parameters/ # LimitParam, CursorParam (shared pagination)
└── examples/ # named scenario examples, $ref'd into operations

Four component categories (not just schemas): schemas, responses, parameters, examples. Bundle multi-file specs at build time with redocly bundle.

Naming conventions (consistency lets AI and codegen predict identifiers):

ElementConventionExample
operationIdcamelCasegetUsers, createTask
TagsPascalCase pluralUsers, Tasks
Schema namesPascalCase singularUser, TaskCreateRequest
Pathskebab-case, no verbs/api/v1/task-lists

Best Practices​

Schema design that scales​

  1. Flat component library + $ref everywhere. Descriptive names, no deep inline nesting. Generators infer names from parent nodes — nesting produces ugly identifiers.
  2. Composition by intent: allOf to compose/extend, oneOf for mutually exclusive, anyOf for overlapping unions. The discriminator is "generally redundant and confusing" — JSON Schema's const is an inherent discriminator; reserve discriminator for codegen optimization.
  3. Null handling = type: ["string", "null"]. Never nullable: true (gone in 3.1). Maps cleanly to string | null, Optional[str], *string.
  4. Cursor-based pagination is the default (opaque cursor + limit → has_more, next_cursor). Offset-based only for admin/small datasets. Model both as reusable components.

Errors built on RFC 9457​

  1. RFC 9457 Problem Details for every 4xx/5xx — media type application/problem+json, fields type/title/status/detail/instance. It's not built into OpenAPI; define it as a base schema in components/responses and $ref it explicitly per operation.
  2. Validation errors extend the base via allOf + an errors array (detail + JSON Pointer pointer). Use 422 for validation (GitHub's convention), 401/403 share the generic schema, 500s expose minimal detail + a request ID.

Examples that drive docs and tests​

  1. Every property gets an example; every operation gets named examples (happy path, edge cases, polymorphic variants). They power mock data (Prism/Orval + Faker), "Try it out" prefill, and AI's understanding of payloads.
  2. Examples must validate against their own schemas — an invalid example (minimum: 50 with example: 10) breaks downstream tooling. Lint enforces this (oas3-valid-media-example).

Versioning & deprecation​

  1. URL path versioning (/api/v1/…) is the most AI-discoverable and testable. Stripe-style date versioning for major breaks is the hybrid alternative; prefer additive-only "API evolution" where possible.
  2. Model deprecation in the contract — deprecated: true (operation/parameter/property level), RFC 8594 Sunset header, x-sunset extensions. Gate breaking changes in CI with oasdiff breaking (or Optic).

AI generation guardrails (the validate-after-generate loop)​

  1. Pipeline: lint → validate → mock test → contract test. Never trust a generated spec unvalidated.
    • Spectral (spectral:oas + OWASP ruleset @stoplight/spectral-owasp-ruleset) — style/completeness/security lint
    • Redocly CLI (@redocly/cli lint) — fast structural validation, multi-file $ref resolution, no-unresolved-refs (critical for AI specs)
    • Prism — mock server (static from examples, -d dynamic) + validation proxy (live traffic vs. spec)
    • Schemathesis — property-based fuzzing; production schemas surface 5–15 issues on first run
  2. Generate paths and schemas separately, then assemble — reduces broken $ref paths. Provide 2–3 validated endpoints as few-shot examples and feed lint errors back into the prompt for iterative self-correction.
  3. Enrich with Overlays, don't overwrite. The OpenAPI Overlay Spec v1.0 (JSONPath update/remove, recursive merge preserving existing properties) is the safe way to layer AI enrichments onto a partial spec without clobbering manual edits.

Tooling (the modern stack — Swagger ≠ Swagger anymore)​

  1. Render with Scalar, not Swagger UI. @scalar/nextjs-api-reference (~66 kB vs. Swagger UI's ~1.5–2 MB), dark-mode default, full API client + multi-language snippets. Microsoft made it the .NET 9 default. Keep swagger-ui-express only for backends that need it.

  2. Author in VS Code, not Swagger Editor. 42Crunch (300+ security checks) + Spectral (lint-on-save) + Swagger Viewer (preview). When Claude authors the spec, the editor's job is validation/preview/refinement.

  3. Generate TypeScript with Orval or @hey-api, not Swagger Codegen/OpenAPI Generator. Swagger Codegen has no 3.1 support; both Java tools produce verbose, "Java-flavored" output. Orval gives TanStack Query hooks + MSW mocks + Zod from one config; @hey-api adds a dedicated Next.js client and is the highest-downloaded TS generator (pin versions — pre-v1.0).

    NeedTool
    Types only (zero runtime)openapi-typescript
    Type-safe fetch clientopenapi-fetch
    TanStack Query hooks + MSW mocksOrval
    Zod + multi-client plugin ecosystem@hey-api/openapi-ts
    Polyglot/enterprise SDKs (50+ langs)openapi-generator
    MCP server from a specopenapi-mcp-generator, Orval v8, FastMCP

Operations & the closed loop​

  1. Validate on every change via a hook. A PostToolUse hook running @redocly/cli lint + orval on spec edits keeps types/mocks regenerated automatically; a PreToolUse hook that blocks edits to generated/ enforces spec-first discipline.
  2. Docs-as-code. Keep specs/ in git next to the app — no hosted platform (SmartBear API Hub adds little for solo/small teams). Serve docs at a same-domain path; never expose them unauthenticated in production.
  3. The complete loop: spec → Orval (types + hooks + MSW mocks) → Scalar (interactive docs) → MSW intercepts "Try it out" → Playwright screenshots docs for visual regression. Zero backend required for frontend development.

Anti-Patterns​

Anti-patternWhy it hurtsFix
Hand-writing types when a spec existsDiverges the moment an endpoint changesGenerate via openapi-typescript/Orval; block generated-file edits with a hook
Editing generated filesOverwritten on next orval runUse Orval's mutator/override; never patch generated/
Postman collection as source of truthCollections are tests, not contracts; lack schema precisionSpec is the contract — import spec into Postman, not the reverse
Monolithic spec file (1000s of lines)Unreadable, slow tooling, bad codegenSplit via $ref; bundle with redocly bundle
Skipping lintingMissing descriptions, undocumented errors, naming driftSpectral + Redocly in CI and Claude hooks
No examples in schemasBreaks mock data + "Try it out" + AI comprehensionAdd example to every property; named examples per operation
Generating docs but never validating vs. implementationDocs drift from realityPrism proxy / Schemathesis contract tests in CI
Over-trusting AI data modelsWrong entities/constraints/business logic shipHuman review of the data model is the one non-skippable step
nullable: true in 3.1Removed from the spec; invalidtype: ["string", "null"]
Swagger Codegen / swagger-cli / Swagger EditorNo 3.1 support / abandoned / redundantOpenAPI Generator → Orval/@hey-api; @redocly/cli; VS Code
Docs exposed in prod without authFull endpoint/schema/auth recon map for attackersGate by env/auth; serve internal-only if needed
Too many MCP/codegen servers15+ servers, 200+ tools = half the context window goneKeep .mcp.json lean (~6); see Perfect MCP

The Checklist​

Contract & schema

  • Format is OpenAPI 3.1; nulls use type: [..., "null"], never nullable: true
  • Flat components/schemas library; everything reused is $ref'd, nothing deeply inlined
  • Shared responses, parameters, examples factored into components
  • Naming consistent (operationId camelCase, Tags PascalCase plural, Schemas PascalCase singular)
  • Cursor-based pagination modeled as reusable components

Errors, examples, versioning

  • RFC 9457 Problem Details for every 4xx/5xx; validation errors extend it with an errors array
  • Every property has an example; every operation has named examples that validate against their schemas
  • Versioning strategy chosen (URL path default); deprecated + Sunset modeled in-contract
  • Breaking changes gated in CI (oasdiff breaking)

Generation & validation (the loop)

  • Types/clients/mocks/docs are generated, never hand-written
  • Pipeline runs lint → validate → mock test → contract test (Spectral + Redocly + Prism + Schemathesis)
  • OWASP Spectral ruleset enabled; security schemes applied to all authenticated endpoints
  • Spec edits trigger a validation hook; edits to generated/ are blocked
  • Data model reviewed by a human before the spec becomes the contract

Tooling & ops

  • Rendering via Scalar; authoring via VS Code (42Crunch + Spectral), not Swagger Editor
  • TS codegen via Orval / @hey-api, not Swagger Codegen/OpenAPI Generator
  • specs/ committed to git (docs-as-code); no secrets in Postman collections
  • Docs served same-domain; never exposed unauthenticated in production

Tooling Reference​

JobUseAvoid
Render docsScalar (@scalar/nextjs-api-reference)Swagger UI as primary; Redoc OSS (no "Try it out")
Author/editVS Code: 42Crunch + Spectral + Swagger ViewerSwagger Editor (redundant for AI workflows)
Lint/validateSpectral + @redocly/cliswagger-cli (abandoned → Redocly)
TS codegenOrval (mocks) / @hey-api (Zod, Next.js)Swagger Codegen (no 3.1); OpenAPI Generator for TS-only
MockPrism (standalone), MSW (in-app/Storybook)Hand-written mocks
Contract testSchemathesis (fuzz), Prism proxy, Dredd"Try it out" as a test framework
Breaking changesoasdiff, OpticManual diff review
Spec → MCPopenapi-mcp-generator, FastMCP, Orval v8—

TL;DR​

A perfect API contract is one OpenAPI 3.1 spec that drives everything. Author it with a human reviewing the data model, factor reuse into a flat components library, standardize errors on RFC 9457, and put validating examples on every schema. Then generate types, clients, mocks, and docs — never hand-write what the spec can produce — and gate every change behind a lint → validate → mock → contract-test loop, with hooks that block edits to generated files. Render with Scalar, author in VS Code, generate with Orval/@hey-api, and keep specs/ in git. The spec is the contract; the contract is the system — so every change has to pass through it.