Skip to main content

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.

Schema-First Development Lifecycle Five sequential stages: Design openapi.yaml, Lint with Spectral, Mock with Prism, Generate types/stubs, CI sync gate — connected by arrows. Design openapi.yaml Lint Spectral 6 Mock Prism 5 Generate Types / Stubs CI Sync Gate openapi-diff

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-code approach 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 $ref chains. OpenAPI 3.1 permits circular references (e.g., a Category that contains child Category objects), but not all generators handle them gracefully. If openapi-typescript or openapi-generator errors on a circular schema, introduce a depth-limited intermediate schema or use oneOf with a nullable leaf type to break the cycle.

  • Multiple spec files. Large APIs sometimes split the contract across multiple YAML files using relative $ref paths. Spectral handles multi-file linting natively. For Prism and openapi-generator, bundle first with npx @redocly/cli bundle openapi.yaml -o dist/openapi.bundled.yaml before 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.