Skip to main content

Schema-First vs Code-First API Workflows

Every API team eventually answers one question that shapes its entire contract strategy: does the specification drive the code, or does the code generate the specification? In a schema-first workflow an openapi.yaml is the authoritative artifact and servers, clients, and types are generated from it. In a code-first workflow the implementation — annotated controllers and DTOs — is authoritative, and the spec is produced from it at build time. Both can ship correct APIs; they fail in different ways, fit different team shapes, and demand different CI discipline. This guide extends API Contract Fundamentals & Tool Selection and shows how to choose between the two paradigms and, crucially, how to wire a sync gate so neither one drifts.

The stakes are concrete. A code-first spec that silently omits a nullable constraint produces clients that crash on a real null. A schema-first spec that nobody regenerated against produces a server whose handlers no longer match the published contract. The paradigm you pick changes where drift originates; only a CI gate changes whether it reaches production.

Schema-first vs code-first workflow comparison Schema-first flows from an authoritative openapi.yaml down into generated server, client, and types. Code-first flows from authoritative annotated code up into a generated spec. Both converge on a single CI sync gate that fails on drift. Schema-First openapi.yaml source of truth generate server stubs client SDK TypeScript types Code-First annotated code decorators / JSDoc source of truth emit openapi.yaml generated artifact CI Sync Gate regenerate + git diff --exit-code

When to Use Each Approach

Treat the choice as a property of the service, not the organization. The same company will run both, and that is correct.

Choose schema-first when:

  • The API is public or consumed by teams you do not control, so the contract must be reviewable and frozen before any implementation exists.
  • Frontend and backend are built in parallel and need a shared mock to work against on day one.
  • You generate multiple clients (TypeScript, Go, Kotlin) from one spec and want them all to track a single source.
  • Governance requires the contract to pass design review and linting before code is written.

Choose code-first when:

  • The service is internal, single-team, and iterates faster than a design-review loop can keep up.
  • The team is small and the overhead of hand-authoring and maintaining a YAML file outweighs its governance benefit.
  • The implementation language already has rich, expressive types (TypeScript, C#, Kotlin) that map cleanly onto the spec.
  • You are documenting an existing API where the code is already the de facto truth.

Choose either, but gate both: if you cannot answer “what fails when an annotation is omitted or a spec goes unregenerated,” you are not ready to ship without the CI sync gate described below — regardless of paradigm.

Prerequisites

Pin tool versions so examples stay reproducible. This guide uses Node.js 20 LTS and the following:

# Schema-first generation (Java-based CLI, run via the npm wrapper)
npm install --save-dev @openapitools/openapi-generator-cli@2.13   # openapi-generator 7.x

# Code-first generation, TypeScript-native
npm install --save-dev tsoa@6.4                                   # decorator-driven OpenAPI emit
npm install --save-dev swagger-jsdoc@6.2                          # JSDoc-comment-driven emit

# Shared governance + drift tooling
npm install --save-dev @stoplight/spectral-cli@6.11              # spec linting
npm install --save-dev openapi-diff@0.23                          # breaking-change detection

You also need a repository where a single openapi.yaml is committed and treated as the reviewable contract — in schema-first it is hand-authored, in code-first it is checked in as a generated artifact. Both rely on that file existing in version control so the gate has something to diff against.

Step 1: Designate the Single Source of Truth

The first decision is not a tool — it is which artifact wins a conflict. In schema-first, openapi.yaml wins and code regenerates to match. In code-first, the annotated code wins and the spec regenerates to match. Write this down in the repo so nobody “fixes” the wrong file.

For a schema-first service, author the contract directly. The full design walkthrough lives in the step-by-step guide to schema-first API development; the minimal authoritative shape is:

# openapi.yaml — AUTHORITATIVE in schema-first
openapi: 3.1.0
info:
  title: Orders API
  version: 1.4.0
paths:
  /orders/{orderId}:
    get:
      operationId: getOrder
      parameters:
        - name: orderId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: The order
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Order' }
components:
  schemas:
    Order:
      type: object
      additionalProperties: false        # reject undocumented fields
      required: [id, status, total]
      properties:
        id:     { type: string, format: uuid }
        status: { type: string, enum: [pending, shipped, cancelled] }
        total:  { type: number, format: double }
        note:   { type: string, nullable: true }   # explicit nullability

The two annotations that prevent the most drift are additionalProperties: false (clients cannot send fields you never documented) and explicit nullable: true (consumers know null is legal). Both are trivially expressed in YAML and trivially forgotten in code — which is the central asymmetry between the paradigms.

Step 2a: Wire Schema-First Generation With openapi-generator

With the spec authoritative, generate everything downstream from it. openapi-generator 7.x emits server stubs and typed clients across dozens of targets. For a Node/TypeScript server consuming the spec, generate the client and types and never hand-edit the output.

# Generate a typed TypeScript fetch client from the authoritative spec
npx openapi-generator-cli generate \
  -i openapi.yaml \
  -g typescript-fetch \
  -o src/generated/client \
  --additional-properties=supportsES6=true,typescriptThreePlus=true

Keep the src/generated/ directory committed but treated as read-only — the sync gate in Step 3 depends on it being a deterministic function of openapi.yaml. For compile-time-only types without a runtime client, pair this with compile-time type generation from OpenAPI, which produces a single zero-runtime .ts artifact via openapi-typescript. The deeper modeling rules — discriminators, $ref reuse, composition — are covered in the OpenAPI Specification Deep Dive.

Step 2b: Wire Code-First Generation With Decorators or JSDoc

In a code-first service the spec is derived. There are two dominant TypeScript routes. With tsoa (and the same model applies to NestJS’s @nestjs/swagger), decorators on controllers and interfaces become the spec. The full treatment is in generating OpenAPI from code with decorators; the core idea:

// orders.controller.ts — AUTHORITATIVE in code-first (tsoa 6.4)
import { Controller, Get, Path, Route, SuccessResponse } from "tsoa";

interface Order {
  id: string;                       // becomes a string schema
  status: "pending" | "shipped" | "cancelled";  // becomes an enum
  total: number;
  note: string | null;             // tsoa emits nullable: true — only because the union includes null
}

@Route("orders")
export class OrdersController extends Controller {
  @Get("{orderId}")
  @SuccessResponse("200", "The order")
  public async getOrder(@Path() orderId: string): Promise<Order> {
    return getOrderById(orderId);   // real implementation
  }
}
# Emit the spec from the compiled types — this writes openapi.yaml
npx tsoa spec

The danger is invisible: if a developer types note: string instead of string | null, the emitted spec drops nullable, the build still passes, and clients break only when a real null arrives. The swagger-jsdoc route makes this even more explicit because the schema lives in comments, fully decoupled from the runtime type:

// orders.routes.js — swagger-jsdoc 6.2: the JSDoc IS the contract
/**
 * @openapi
 * /orders/{orderId}:
 *   get:
 *     responses:
 *       '200':
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               additionalProperties: false
 *               required: [id, status, total]
 *               properties:
 *                 note: { type: string, nullable: true }
 */
app.get("/orders/:orderId", getOrderHandler);

With swagger-jsdoc nothing connects the comment to the handler’s actual response, so the comment can lie indefinitely. This is the worst drift profile of the three and is why a code-first gate must compare the emitted spec against runtime behavior, not merely against itself.

Step 3: Add the CI Sync Gate

This is the step that makes either paradigm safe. The gate regenerates the derived artifact in CI and fails if it differs from what was committed. In schema-first the derived artifact is the generated code; in code-first it is the spec.

# .github/workflows/contract-sync.yml
name: Contract Sync Gate
on: [pull_request]
jobs:
  sync-gate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }       # full history for the diff baseline
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm ci

      # --- SCHEMA-FIRST: spec is truth, regenerate code and diff ---
      - name: Regenerate client from spec
        run: |
          npx openapi-generator-cli generate \
            -i openapi.yaml -g typescript-fetch -o src/generated/client
      - name: Fail on generated-code drift
        run: git diff --exit-code src/generated/ \
          || (echo "::error::Generated code is stale. Run codegen and commit." && exit 1)

      # --- CODE-FIRST (alternative): code is truth, regenerate spec and diff ---
      # - name: Regenerate spec from decorators
      #   run: npx tsoa spec
      # - name: Fail on spec drift
      #   run: git diff --exit-code openapi.yaml \
      #     || (echo "::error::openapi.yaml is stale. Run 'tsoa spec' and commit." && exit 1)

git diff --exit-code returns non-zero when the working tree differs from the commit, which is exactly the drift signal you want — a PR that changes one side but forgets the other now fails before review. Run the same gate in a pre-commit hook to shorten the feedback loop, but never only in a hook: hooks are skippable with --no-verify, CI is not.

Step 4: Detect Breaking Changes Against the Baseline

The sync gate proves the two artifacts agree with each other. It does not prove the new contract is backward compatible with what consumers already depend on. Add openapi-diff to compare the PR’s spec against the main baseline and block backward-incompatible changes.

      - name: Export baseline spec
        run: git show origin/main:openapi.yaml > /tmp/baseline.yaml
      - name: Block breaking changes
        run: npx openapi-diff /tmp/baseline.yaml openapi.yaml --fail-on-incompatible

Removing a required field, deleting an operation, narrowing an enum, or tightening a type all fail the job; adding optional fields or new operations passes. This is the same discipline whether the spec was hand-authored or emitted from code — by Step 4 both paradigms have converged on a single committed openapi.yaml, and everything downstream is paradigm-agnostic. For services that exchange these contracts across boundaries, fold this gate into your broader contract testing for microservices strategy.

Schema-First vs Code-First: Tradeoff Reference

Dimension Schema-First Code-First
Source of truth openapi.yaml Annotated code (decorators / JSDoc)
Generation direction Spec → code, types, clients Code → spec
Primary drift origin Stale regeneration of code Omitted annotation or untyped change
Drift visibility without a gate Low (code silently diverges) Very low (spec silently lies)
Contract exists before code Yes No — only after build
Day-one mockability Yes (mock straight from spec) No
Authoring overhead Higher (write + maintain YAML) Lower (spec is a byproduct)
Multi-language client gen Strong (one spec, many targets) Possible but spec quality varies
Best team topology Cross-team, public, parallel FE/BE Internal, single-team, fast-moving
Representative tooling openapi-generator, openapi-typescript tsoa, @nestjs/swagger, swagger-jsdoc

Verification

A correctly wired pipeline produces a clear pass signal and an unambiguous failure. On a clean PR the sync gate is silent:

$ git diff --exit-code src/generated/
$ echo $?
0

Introduce drift deliberately — add a field to openapi.yaml without regenerating — and the gate fails with the diff and a sourced error:

Run git diff --exit-code src/generated/
diff --git a/src/generated/client/models/Order.ts b/src/generated/client/models/Order.ts
+  expeditedFee?: number;
::error::Generated code is stale. Run codegen and commit.
Error: Process completed with exit code 1.

For the breaking-change gate, a backward-incompatible edit yields:

$ npx openapi-diff /tmp/baseline.yaml openapi.yaml --fail-on-incompatible
BREAKING: required property 'total' removed from response schema 'Order'
Detected 1 breaking change. Failing build.

Two green checks — sync gate plus breaking-change gate — are the signal that the contract is internally consistent and compatible with existing consumers.

Troubleshooting

Sync gate fails locally but passes for a colleague (or vice versa)

Root cause: non-deterministic codegen. Different openapi-generator versions, locales, or file ordering produce byte-different output. Fix: pin the generator version exactly in package.json (@openapitools/openapi-generator-cli@2.13 resolving to generator 7.x) and commit an openapitools.json that locks the underlying JAR version. Run npx openapi-generator-cli version-manager set 7.6.0 so every machine resolves the same generator.

tsoa spec emits a schema with no nullable even though the field can be null

Root cause: the TypeScript type is string, not string | null. tsoa only emits nullable: true when null is part of the union; a runtime null from a non-null type is invisible to the generator. Fix: make nullability explicit in the type (note: string | null) and enable strictNullChecks in tsconfig.json so the compiler forces honest types throughout the DTOs.

openapi-diff reports a breaking change for a purely additive edit

Root cause: the schema uses additionalProperties: false, so adding a documented property tightens an otherwise-open object and the tool flags it as potentially breaking for permissive clients — or the field was added to a required array. Fix: add new fields as optional (omit them from required) during a deprecation window. Only move them to required in a new major version. Keep additionalProperties: false but version the schema with it.

Generated client compiles but throws on real responses

Root cause: the spec and the running server disagree — common in code-first when the JSDoc comment (the contract) drifted from the handler. The client matches the spec; the server does not. Fix: add a runtime contract test that hits the real server and validates responses against the committed spec (for example with Prism’s proxy/validation mode), so the comment can no longer lie undetected.

Cannot resolve $ref during codegen or linting in CI

Root cause: relative $ref paths resolve from the CI working directory, which differs from your local shell. Fix: bundle the spec into a single self-contained file before generation: npx @apidevtools/swagger-cli bundle openapi.yaml -o openapi.bundled.yaml, then point the generator and spectral at the bundled output.

Frequently Asked Questions

Is schema-first or code-first better for a new API?

For a public or cross-team API where the contract must be stable before code exists, schema-first is the safer default because the spec is authoritative and reviewable in isolation. For an internal, single-team service that changes rapidly, code-first removes authoring overhead and keeps the spec close to the implementation.

What causes contract drift in code-first workflows?

Drift happens when the generated spec stops matching real runtime behavior — usually because a decorator or JSDoc annotation was omitted, a DTO field changed without updating its type, or a nullable/format constraint was never expressed in code. The handler still serves traffic, so nothing fails until a client deserializes an unexpected shape.

Can I use both schema-first and code-first in the same organization?

Yes, and most large organizations do. Pick per service based on its blast radius: schema-first for shared platform APIs, code-first for leaf services. The key is that every service, regardless of paradigm, commits a checked-in openapi.yaml and runs the same CI sync gate so the contract is always reviewable.

What is a CI sync gate and why do I need one?

A CI sync gate is a pipeline job that fails the build when the committed spec and the source of truth diverge. In schema-first it regenerates code and runs git diff --exit-code; in code-first it regenerates the spec from annotations and diffs that. Without it, drift accumulates silently between merges.

Does tsoa or NestJS replace writing an OpenAPI file by hand?

Yes — both generate the OpenAPI document from TypeScript decorators at build time, so you never hand-author the spec. The trade-off is that the contract only exists after the code compiles, and any annotation you forget silently disappears from the published spec.

How do I migrate a code-first API to schema-first?

Export the current generated spec, freeze it as a baseline openapi.yaml, then invert the build so code is generated from that file instead of producing it. Run openapi-diff against the frozen baseline in CI during the transition so the inversion does not change the observable contract.