Step-by-Step Guide to Schema-First API Development
Starting a new API project and reaching for the framework first is the most common way to end up with an undocumented, untestable contract. The schema-first approach inverts that: you write the OpenAPI specification before writing any handler code, then derive implementation artefacts — types, stubs, mocks — directly from the spec. This guide is a companion to Schema-First vs Code-First Workflows and walks you through the exact sequence from an empty directory to a CI-gated contract.
Step 1: Design openapi.yaml Before Writing Any Code
Create a openapi.yaml at the project root. Start with the minimum viable contract: the info block, one path, and the schemas it touches. Use OpenAPI 3.1 — its alignment with JSON Schema draft 2020-12 gives you richer validation keywords and first-class null support via type arrays.
# openapi.yaml — OpenAPI 3.1
openapi: "3.1.0"
info:
title: Orders API
version: "1.0.0"
description: >
Contract-first REST API for order management.
This file is the source of truth; implementation is derived from it.
paths:
/orders:
post:
operationId: createOrder
summary: Submit a new order
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateOrderRequest"
example:
customerId: "cust_42"
items:
- sku: "widget-a"
qty: 3
responses:
"201":
description: Order accepted
content:
application/json:
schema:
$ref: "#/components/schemas/Order"
"422":
description: Validation failure
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
components:
schemas:
CreateOrderRequest:
type: object
required: [customerId, items]
properties:
customerId:
type: string
minLength: 1
items:
type: array
minItems: 1
items:
$ref: "#/components/schemas/OrderItem"
OrderItem:
type: object
required: [sku, qty]
properties:
sku:
type: string
qty:
type: integer
minimum: 1
Order:
type: object
required: [id, customerId, status]
properties:
id:
type: string
format: uuid
customerId:
type: string
status:
type: string
enum: [pending, confirmed, shipped, cancelled]
ProblemDetail:
type: object
required: [type, title, status]
properties:
type:
type: string
format: uri
title:
type: string
status:
type: integer
detail:
type: string
Why this works: Defining schemas in components/schemas and referencing them via $ref avoids inline duplication — every consumer of OrderItem references the same definition, so changes propagate automatically. The ProblemDetail schema matches RFC 7807 and gives your error contract the same rigour as your success responses. For a deeper treatment of the OpenAPI Specification, including $ref composition and component reuse patterns, see the dedicated guide.
Step 2: Lint with Spectral 6 Before Any Code Is Merged
Install Spectral 6 and configure a project ruleset. The default OAS ruleset catches structural errors; add @stoplight/spectral-owasp-rules for security hygiene.
npm install --save-dev @stoplight/spectral-cli@6 @stoplight/spectral-owasp-rules
# .spectral.yaml
extends:
- "@stoplight/spectral-oas"
- "@stoplight/spectral-owasp-rules"
rules:
# Require examples on every request/response schema
oas3-valid-media-example: error
# All operations must declare at least one 4xx response
operation-4xx-response:
message: "Every operation must define at least one 4xx response."
given: "$.paths[*][get,post,put,patch,delete]"
severity: warn
then:
function: schema
functionOptions:
schema:
type: object
properties:
responses:
type: object
patternProperties:
"^4[0-9]{2}$":
type: object
required: [responses]
Run the linter:
npx spectral lint openapi.yaml --ruleset .spectral.yaml --fail-severity warn
Why this works: Running Spectral at severity warn as an error-equivalent during the PR stage catches missing examples, undeclared error responses, and security header omissions before any code ships. Linting the spec is orders of magnitude cheaper than finding contract violations in integration tests. You can extend the ruleset with custom rules that encode team conventions — for example, requiring that all response schemas declare additionalProperties: false.
Step 3: Mock the Contract with Prism 5
With a valid, linted spec, front-end engineers and integration tests can immediately work against a real HTTP mock server without waiting for server implementation.
npm install --save-dev @stoplight/prism-cli@5
Start the mock server in dynamic mode so Prism generates realistic response values from the schema:
npx prism mock openapi.yaml --dynamic
The server starts on http://localhost:4010. Test it immediately:
curl -s -X POST http://localhost:4010/orders \
-H "Content-Type: application/json" \
-d '{"customerId":"cust_42","items":[{"sku":"widget-a","qty":3}]}' \
| jq .
Expected output — Prism dynamically generates a schema-valid response:
{
"id": "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
"customerId": "cust_42",
"status": "pending"
}
Prism also validates requests. Send a bad payload to verify:
curl -s -X POST http://localhost:4010/orders \
-H "Content-Type: application/json" \
-d '{"customerId":"","items":[]}' \
| jq .
Prism returns a 422 with a validation message rather than forwarding the request. This is a critical capability: front-end teams develop against a spec-accurate server without needing server code, and integration test suites can run in isolation. For a broader comparison of mock server options including Microcks and WireMock, see the Mock Server Strategies guide.
Step 4: Generate TypeScript Types and Server Stubs
Derive implementation artefacts from the spec so the contract is never hand-transcribed into code.
Generate TypeScript types with openapi-typescript 7
npm install --save-dev openapi-typescript@7
npx openapi-typescript openapi.yaml --output src/generated/api.d.ts
The generated file contains exact TypeScript interfaces for every schema:
// src/generated/api.d.ts — auto-generated, do not edit
export interface CreateOrderRequest {
customerId: string;
items: OrderItem[];
}
export interface OrderItem {
sku: string;
qty: number;
}
export interface Order {
id: string;
customerId: string;
status: "pending" | "confirmed" | "shipped" | "cancelled";
}
Import these types in your handler code. Never duplicate them by hand — if the spec changes, re-run the generator.
Generate server route stubs with openapi-generator
For typed route registration, generate a server stub. The nodejs-express-server generator produces Express route skeletons:
npx @openapitools/openapi-generator-cli generate \
-i openapi.yaml \
-g nodejs-express-server \
-o src/generated/server \
--additional-properties=npmName=orders-api,npmVersion=1.0.0
Implement only the handler bodies — the route wiring, request parsing, and response serialisation are derived from the contract. If you prefer an alternative code-first path where decorators annotate existing classes to emit the spec, see Generating OpenAPI from Code with Decorators for the tradeoffs.
Why this works: Generating types and stubs from the spec creates a compile-time link between the contract and the implementation. A schema change that you forget to propagate to the code becomes a TypeScript type error, not a runtime failure in production.
Step 5: Enforce a CI Sync Gate
The spec and implementation will drift without a machine-enforced gate. Add a GitHub Actions job that runs on every pull request and fails when a breaking change is detected.
# .github/workflows/contract.yml
name: Contract Sync Gate
on:
pull_request:
paths:
- "openapi.yaml"
- "src/**"
jobs:
contract-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node 20
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- run: npm ci
- name: Lint OpenAPI spec
run: npx spectral lint openapi.yaml --ruleset .spectral.yaml --fail-severity warn
- name: Detect breaking changes against main
run: |
# Fetch the base spec from the target branch
git show origin/${{ github.base_ref }}:openapi.yaml > /tmp/openapi-base.yaml || true
if [ -f /tmp/openapi-base.yaml ]; then
npx openapi-diff /tmp/openapi-base.yaml openapi.yaml --fail-on-incompatible
else
echo "No base spec found — skipping diff (initial commit)"
fi
- name: Validate spec integrity
run: npx @openapitools/openapi-generator-cli validate -i openapi.yaml
- name: Regenerate types and confirm no drift
run: |
npx openapi-typescript openapi.yaml --output src/generated/api.d.ts
git diff --exit-code src/generated/api.d.ts || \
(echo "ERROR: Generated types are out of sync with openapi.yaml — run the generator and commit the result." && exit 1)
Why this works: The gate has three layers. First, Spectral catches style and structural violations in the modified spec. Second, openapi-diff compares the PR’s spec against the base branch and blocks any breaking change (removed field, narrowed type, changed required property). Third, the regeneration check ensures the committed generated types match the current spec — a developer cannot modify only the spec without updating the generated artefacts, and cannot modify only the artefacts without updating the spec.
Verification
With this pipeline in place, a clean PR looks like this in CI output:
✔ spectral lint passed (0 errors, 0 warnings)
✔ openapi-diff: no breaking changes detected
✔ openapi-generator validate: spec is valid OAS 3.1
✔ src/generated/api.d.ts matches openapi.yaml
A breaking change — for example, removing the qty field from OrderItem — produces:
✘ openapi-diff: Breaking change detected
GET /orders/items > response > 200 > body > #/OrderItem > qty
This is a breaking change!
Error: Process completed with exit code 1.
The PR is blocked until the spec change is reverted or the team deliberately bumps the API major version and coordinates consumer migration.
Edge Cases and Caveats
-
Generated file commits vs gitignore. Some teams gitignore generated files and regenerate them in CI. Others commit them to catch drift during code review. The CI
git diff --exit-codeapproach above works only if generated files are committed; if you gitignore them, remove that step and rely on TypeScript compilation in the build instead. -
Circular
$refchains. OpenAPI 3.1 permits circular references (e.g., aCategorythat contains childCategoryobjects), but not all generators handle them gracefully. Ifopenapi-typescriptoropenapi-generatorerrors on a circular schema, introduce a depth-limited intermediate schema or useoneOfwith a nullable leaf type to break the cycle. -
Multiple spec files. Large APIs sometimes split the contract across multiple YAML files using relative
$refpaths. Spectral handles multi-file linting natively. For Prism and openapi-generator, bundle first withnpx @redocly/cli bundle openapi.yaml -o dist/openapi.bundled.yamlbefore passing the spec to those tools.
Frequently Asked Questions
Do I need to write the entire OpenAPI file before writing any code?
No. Start with the endpoints you plan to build first and iterate. The contract just needs to be the authoritative source — implementation follows it, not the other way around. You can stub out future paths with x-draft: true extension properties to signal that they are not yet stable.
Can Prism mock endpoints that are not yet implemented?
Yes. Prism generates responses purely from the OpenAPI schema using example values or dynamic generation. You can mock an entire API surface before a single handler is written, which lets front-end development and integration test authoring proceed in parallel with server implementation.
What happens when a code change breaks the contract in CI?
The sync gate step runs openapi-diff in CI and exits non-zero when a breaking change is detected. The PR is blocked until the spec and implementation are reconciled — either by reverting the breaking change, updating the spec to reflect a deliberate new contract version, or adding a migration path for consumers.
Should the openapi.yaml file live in the same repo as the server code?
For most teams, yes — colocating the spec with the implementation makes it easy to update both atomically and keeps the CI gate simple. Large platform teams sometimes maintain a dedicated schema registry repo, but that adds merge coordination overhead and requires cross-repo CI dependencies.
Which version of OpenAPI should I use for new projects?
Use OpenAPI 3.1. It aligns with JSON Schema draft 2020-12, which gives you richer validation keywords (e.g. unevaluatedProperties, prefixItems) and first-class nullable support via type arrays (type: [string, null]) instead of the nullable: true extension that OAS 3.0 required.