One File, Three Jobs: Driving Validation, Clients, and Docs from a Single OpenAPI Spec
Write one OpenAPI file and let it drive request validation, generated clients, and docs. A worked POST /orders example, the operationId trap, and the 3.0 vs 3.1 pitfalls.
28 Mar 2026, 19:43 UTC

Most API teams end up with three artifacts that are supposed to agree and rarely do: the code that validates incoming requests, the client libraries consumers integrate against, and the documentation. Each drifts on its own schedule. The contract-first pitch for OpenAPI is that you write one specification file and let it drive all three — and the part that makes this more than a documentation exercise is request validation, where the spec becomes executable.
This post walks through that workflow with a concrete endpoint, shows the one field that determines whether your generated code is usable, and covers where the approach breaks down.
Pick your version deliberately: 3.0 vs 3.1
OpenAPI 3.0 and 3.1 look nearly identical but differ in one way that bites people: the schema model. OpenAPI 3.0 uses a modified subset of JSON Schema, so a nullable string is written as type: string plus nullable: true. OpenAPI 3.1 aligns fully with JSON Schema 2020-12, where you write type: ["string", "null"] and nullable is gone. Mixing the two silently breaks validation or generation — a 3.1-style type array fed to a 3.0-only toolchain may be ignored rather than rejected.
Tooling support for 3.1 still lags 3.0 in some generators and gateways, so check the compatibility notes of the specific generator and validation library you plan to use before adopting 3.1-only constructs. The example below targets 3.0, which remains the safest default for broad toolchain support.
A worked example: POST /orders
Here is a minimal but realistic spec for an order-creation endpoint. Save it as openapi.yaml:
openapi: 3.0.3
info:
title: Orders API
version: 1.0.0
paths:
/orders:
post:
operationId: createOrder
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/NewOrder'
responses:
'201':
description: Order created
content:
application/json:
schema:
$ref: '#/components/schemas/Order'
'400':
$ref: '#/components/responses/ValidationError'
components:
schemas:
NewOrder:
type: object
required: [customerId, items]
properties:
customerId:
type: string
format: uuid
items:
type: array
minItems: 1
items:
type: object
required: [sku, quantity]
properties:
sku:
type: string
pattern: '^[A-Z0-9-]{3,20}$'
quantity:
type: integer
minimum: 1
maximum: 100
note:
type: string
nullable: true
maxLength: 500
Order:
type: object
properties:
id:
type: string
format: uuid
status:
type: string
enum: [pending, confirmed]
responses:
ValidationError:
description: Request failed schema validation
content:
application/json:
schema:
type: object
properties:
errors:
type: array
items:
type: stringTwo things are doing quiet work here. The $ref pointers into components keep schemas reusable — ValidationError can be referenced by every endpoint instead of being copy-pasted. And operationId: createOrder is not decoration: it is the hook virtually every code generator uses for method names. Omit it and you get names derived from the path (ordersPost); duplicate it across operations and generated methods collide. Treat operationId as a required, globally unique field and lint for it.
Making the spec executable
With the spec written, three consumers can hang off it:
- Validation middleware. Libraries in the Node, Java, and Python ecosystems load the spec at startup and reject requests that violate the schemas before your handler runs. A payload missing
customerId, or withquantity: 0, gets a 400 with the validation errors — matching theValidationErrorresponse you declared — without a line of hand-written checks. - Generated clients and server stubs. OpenAPI Generator (or a language-specific alternative) produces a client SDK whose method is literally
createOrder, because of theoperationId. Server stubs give you typed handlers to fill in. - Documentation. Swagger UI or similar renders the same file into interactive docs that cannot drift from the contract, because they are the contract.
Before wiring any of this up, validate the spec itself. Run a linter such as Spectral against openapi.yaml from your project root (no special permissions needed — it reads the file and applies rules):
npx @stoplight/spectral-cli lint openapi.yamlExpect it to catch structural errors and, with a ruleset enabled, missing or duplicated operationIds. Then generate a client and confirm the method names match your declared operation IDs. Finally, send a deliberately invalid request — drop customerId or set quantity to a string — and confirm the middleware returns 400 with a useful message rather than a 500 from deep in your handler.
The honest trade-offs
Contract-first is not free. In a contract-last setup (spec generated from annotations), the spec is a second source of truth you must keep in sync; contract-first inverts that, but now the spec is a file humans edit, and a sloppy edit breaks every consumer at once. Generated code quality also varies widely by generator and target language — treat stubs as scaffolding, not finished production code, and expect naming conventions that may not be idiomatic.
The sharpest limitation: schema validation enforces structure, not business rules. It will catch a missing customerId and a negative quantity. It will not catch that the customer is suspended, the SKU is discontinued, or the order exceeds a credit limit. Well-formed but semantically invalid input still reaches your business logic, so validation middleware replaces boilerplate, not domain checks.
Where to start
Pick one endpoint, write its spec by hand, lint it, generate a client, and point validation middleware at it. If the 400 responses are useful and the generated client method names read well, you have a working contract loop — extend it endpoint by endpoint rather than attempting a big-bang rewrite of an existing API.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.