Code-first vs spec-first: what changes when the OpenAPI document is the source of truth
In a code-first NestJS service, the controllers are authoritative. @nestjs/swagger reads decorators such as @ApiProperty() and @ApiResponse(), plus DTO metadata, and emits an OpenAPI document at runtime or build time. The spec describes whatever the code does, so it can only be reviewed after the implementation exists.
Spec-first reverses that dependency. A hand-authored openapi.yaml lives in version control and is the contract. Server types, request validation, client SDKs and CI checks are all generated from or verified against it. A change to the API starts as a diff to the spec. That diff can be reviewed by consumers, linted, and checked for breaking changes before anyone writes a handler.
Teams usually adopt spec-first for three reasons:
- The API has several consumers (web, mobile, partner integrations) that need a stable contract they can build against in parallel.
- Contract review crosses team boundaries. A YAML diff is easier for another team to evaluate than decorator changes spread across controllers.
- Generated clients remove hand-written fetch code, along with the type mismatches that come with it.
This approach suits platform teams and any API with external or multi-team consumers. It suits a single team that owns both the backend and its only frontend less well.
| Approach | Source of truth | Fits best when |
|---|---|---|
Code-first (@nestjs/swagger) |
Controllers and DTOs | One team, fast iteration, docs as a by-product |
| Spec-first | openapi.yaml |
Multiple consumers, contract review, generated SDKs |
| Schema-in-code | Shared runtime schemas | TypeScript monorepo, client and server in one repo |
| RPC with an IDL | Interface definition files | Internal service-to-service calls, HTTP/REST not required |
How the pipeline fits together
The spec-first pipeline has one input: the OpenAPI document committed to the repository. Every other artifact is derived from it. Nothing generated is edited by hand, and nothing flows back into the spec automatically. That one-way rule is what makes drift detectable. If generated files could be patched, you would never know which side was wrong.
From the committed spec, two branches fan out after linting. The server branch produces TypeScript types for request bodies, parameters and responses. NestJS controllers and DTOs are written against those types, so a mismatch shows up as a compile error, not a production bug. The consumer branch produces a typed client that is packaged and versioned for other teams.
The NestJS app is not trusted to agree with the contract just because it compiles. Types say nothing about decorators, status codes or route paths. So in CI the app boots, SwaggerModule.createDocument emits the spec it actually exposes, and that output is diffed against the committed contract.
Recommended CI order
- Lint the spec. This is the cheapest failure, and generators produce confusing output from invalid input.
- Generate server types and the client. Later steps depend on these files.
- Typecheck the server. This catches handlers that no longer match the contract.
- Run contract tests. These verify runtime behavior that types cannot express.
- Diff the emitted spec. This catches decorator and routing drift.
- Publish the client. It runs last, so consumers never receive a client for a server that failed any earlier check.
+---------------------------------+
| openapi.yaml (committed spec) |
| single input; never hand-edited |
+----------------+----------------+
v
+-----------------+
| 1. Lint spec |
+--------+--------+
|
+--------------+--------------+
v v
+--------------------+ +--------------------+
| 2. Server types | | 2. Typed client |
+---------+----------+ +---------+----------+
v |
+--------------------+ |
| NestJS controllers | |
| 3. Typecheck | |
| 4. Contract tests | |
+---------+----------+ |
v app boots |
+--------------------+ |
| 5. createDocument | |
| diff vs spec | |
+---------+----------+ |
| no drift v
+-----------------> +--------------------+
| 6. Publish client |
+--------------------+
The committed spec flows one way: it is linted, then used to generate server types and a typed client; NestJS controllers are typechecked and contract-tested against those types, the booted app's emitted spec is diffed against the contract, and the client is published only if every check passes.
Structuring and linting the spec in the repo
Start with one openapi.yaml at a predictable path such as spec/openapi.yaml. When it grows past a few hundred lines, split it into files for paths, schemas, parameters and responses, and connect them with relative $refs. Bundle them into a single document as the first build step. Generators, validators and the drift diff in CI should all read that bundled artifact, never the fragments. Resolving references differently in different tools is an easy way to get two "versions" of the same contract.
Put anything used more than once under components. Shared entities belong in components/schemas. Pagination and ID parameters belong in components/parameters. A single error envelope belongs in components/responses. Named components become named types in the generated code. Inline schemas usually become anonymous or auto-named types that change whenever the spec is edited.
Treat operationId as a public API name. Pick one convention, such as camelCase verb plus noun (listOrders, getOrder, createOrder), and use it everywhere. That string becomes the generated client function and the NestJS controller method name, so the same word appears in the spec, the server and the consumer. Renaming an operationId breaks client callers even when the HTTP contract is unchanged.
Lint the bundled spec before any generation runs, and fail the build on errors. Useful rules include:
operationIdis present, unique and matches your naming pattern.- Every operation declares its error responses.
- Request bodies reference named schemas.
Linting first means a malformed spec fails with a clear message instead of producing confusing generated code.
Choose your OpenAPI version deliberately:
- 3.0 uses an extended subset of JSON Schema and marks nullability with
nullable: true. - 3.1 aligns with JSON Schema 2020-12. It expresses null as
type: [string, "null"]and changes keywords such asexclusiveMinimum.
Confirm that your bundler, linter, generators and runtime validator all support the version you author in. Tools that don't may ignore 3.1 constructs without reporting an error.
# spec/openapi.yaml: single source of truth, linted before any codegen runs
openapi: 3.0.3 # pin the version; confirm every tool in the chain supports it
info:
title: Orders API
version: 1.0.0
paths:
/orders/{orderId}:
get:
operationId: getOrder # camelCase verb+noun -> client fn + controller method name
parameters:
- $ref: '#/components/parameters/OrderId'
responses:
'200':
description: The order
content:
application/json:
schema:
$ref: '#/components/schemas/Order' # named -> stable generated type
'404':
$ref: '#/components/responses/Error' # every operation declares errors
components:
parameters:
OrderId:
name: orderId
in: path
required: true
schema: { type: string, format: uuid }
schemas:
Order:
type: object
required: [id, status]
properties:
id: { type: string, format: uuid }
status: { type: string, enum: [pending, paid, shipped] }
note: { type: string, nullable: true } # 3.0 style; 3.1 uses type: [string, 'null']
responses:
Error:
description: Shared error envelope
content:
application/json:
schema: { type: object, required: [message], properties: { message: { type: string } } }
A minimal OpenAPI 3.0 spec with reusable components, a shared error response, and a camelCase operationId that becomes both the generated client function and the NestJS controller method name.
Generating server types and enforcing them at runtime in NestJS
Run openapi-typescript against the bundled spec, not the split source files, so every consumer sees the same resolved document. It emits a single module exporting paths, components and operations types. To keep regeneration deterministic, pin the generator version in your lockfile, run it through a package script rather than an ad hoc command, and format the output with the repo's formatter. Two engineers on different machines should get byte-identical files.
Then choose a policy and enforce it. Committing the generated file makes diffs reviewable and keeps builds independent of the generator. If you commit it, CI must regenerate the file and fail on any difference. Regenerating at build time avoids stale files, but every build and editor session then depends on the generator having run first. If you regenerate, gitignore the output so nobody hand-edits it.
In controllers and services, alias the shapes you need, such as a request body from paths['/orders']['post']['requestBody'], or a schema from components['schemas']['Order']. Annotate handler parameters and return types with these aliases so the compiler rejects responses that drift from the contract.
These types are erased at compile time, so incoming requests are still unchecked. You have two ways to validate them:
- Keep class-validator DTOs and assert that each DTO is assignable to its generated type, using
satisfiesor a type-level check. A DTO that no longer matches the spec then breaks the build. - Write a validation pipe that compiles the spec's schemas with a JSON Schema validator such as Ajv, so the spec alone defines the validation rules.
Avoid generating controller stubs. Generators group handlers by tag or path, while Nest groups code by module, provider and injection scope. Regenerated stubs overwrite those boundaries, so teams end up working against the generator instead of with Nest's structure.
// orders.controller.ts: generated types give compile-time checks; the DTO gives runtime checks
import { Body, Controller, Post, ValidationPipe } from '@nestjs/common';
import { IsInt, IsString, Min } from 'class-validator';
import type { paths, components } from '../generated/openapi'; // output of openapi-typescript
import { OrdersService } from './orders.service';
// Alias contract shapes once so handlers read cleanly.
type CreateOrderBody =
paths['/orders']['post']['requestBody']['content']['application/json'];
type Order = components['schemas']['Order'];
// Runtime validation: class-validator decorators run on each request.
export class CreateOrderDto {
@IsString()
sku!: string;
@IsInt()
@Min(1)
quantity!: number;
}
// Build-time guard: if the spec changes and the DTO no longer fits, this line fails to compile.
type Extends<A, B> = [A] extends [B] ? true : false;
const _dtoMatchesSpec: Extends<CreateOrderDto, CreateOrderBody> = true;
@Controller('orders')
export class OrdersController {
constructor(private readonly orders: OrdersService) {}
@Post()
async create(
@Body(new ValidationPipe({ whitelist: true })) body: CreateOrderDto,
): Promise<Order> {
// The return type is the spec's Order, so a response that drifts from the contract is a type error.
return this.orders.create(body);
}
}
A NestJS controller that aliases openapi-typescript's generated paths and components types for compile-time checks, validates requests at runtime with a class-validator DTO, and fails the build if that DTO stops being assignable to the spec's request body.
Generating and packaging a typed OpenAPI TypeScript client
The client side has the opposite problem: consumers want a stable, typed surface and have no Nest-specific structure to protect. That leaves two realistic options.
OpenAPI Generator (typescript-fetch / typescript-axios) |
Thin wrapper over generated types | |
|---|---|---|
| What you get | API classes, models, runtime (de)serialisation | Type definitions plus a small hand-written fetch helper |
| Runtime code shipped | Substantial | Minimal |
| Customisation | Templates and generator options | Plain TypeScript you own |
| Auth, retries, tracing | Middleware hooks, generator-specific | Wherever you put them |
The generator saves effort when consumers expect a ready-made SDK. The wrapper suits internal callers, because path, method and response types come straight from the spec while the HTTP code stays readable and debuggable.
Publish either one as an internal package from the same pipeline that validates the spec. That turns the client into a versioned contract. Consumers pin a version and upgrade on their own schedule, so a breaking spec change needs a major release. Plan for coordinated upgrades or a deprecation period in which the server serves both the old and the new shape.
Check the generated output where generators are weakest:
oneOf/anyOf: may collapse to loose unions orany.- Discriminators: may not narrow as expected.
additionalProperties: often handled inconsistently.nullablevs. optional: the distinction can blur.- Recursive schemas: may produce broken or circular models.
Generated types leak into consumer code, so switching generators later means touching every call site. Choose deliberately.
// src/schema.d.ts is generated in CI from the validated spec:
// npx openapi-typescript openapi.yaml -o src/schema.d.ts
// The package ships these types plus this helper and nothing else.
import type { paths } from './schema';
// Pick the GET operation for a path, if the spec defines one.
type GetOp<P extends keyof paths> = paths[P] extends { get: infer Op } ? Op : never;
// Pull the 200 JSON body type and the path parameters out of the operation.
type OkJson<Op> = Op extends {
responses: { 200: { content: { 'application/json': infer T } } };
} ? T : never;
type PathParams<Op> = Op extends { parameters: { path: infer PP } } ? PP : never;
export class ApiError extends Error {
constructor(public readonly status: number, public readonly body: string) {
super(`API request failed with status ${status}`);
}
}
export async function apiGet<P extends keyof paths>(
baseUrl: string,
path: P,
params: PathParams<GetOp<P>>,
init: RequestInit = {},
): Promise<OkJson<GetOp<P>>> {
// Fill '/users/{id}' style templates from the typed params object.
const values = (params ?? {}) as Record<string, string | number>;
const url = String(path).replace(/\{(\w+)\}/g, (_, key: string) =>
encodeURIComponent(String(values[key])),
);
// Auth, retries and tracing go here: ordinary code that you own.
const headers = new Headers(init.headers);
headers.set('Accept', 'application/json');
const res = await fetch(baseUrl + url, { ...init, method: 'GET', headers });
if (!res.ok) throw new ApiError(res.status, await res.text());
return (await res.json()) as OkJson<GetOp<P>>;
}
// Usage: const user = await apiGet('', '/users/{id}', { id: '42' });
A thin typed fetch wrapper that takes path, parameter and response types from openapi-typescript output generated from the spec, so the only hand-written runtime code in the internal client package is a small helper you can read and debug.
Closing the loop: contract tests, drift detection and breaking-change checks in CI
Generation alone does not prove the running app honours the contract. Three CI checks close that gap.
Drift detection
Create the Nest application in a CI script without calling listen, pass it to SwaggerModule.createDocument, and write the result to a temporary file. This captures what the decorators actually describe, which can diverge from the committed spec when someone adds a route or changes a DTO by hand. Compare the two documents structurally, not textually. Check paths, HTTP methods, status codes per operation, and the resolved shape of every request and response schema. Before comparing, normalize both sides: dereference $refs so inlined and referenced schemas match, sort keys, and map nullable and type: [..., "null"] to one form. Ignore schema names and auto-added default responses, because these differ between the two documents without changing behaviour.
Contract tests
Boot the real app on an ephemeral port and call it through the generated client, not raw HTTP. A compile-time mismatch between the client and the handlers fails the type check, and a runtime mismatch fails response validation against the spec.
Breaking-change check
On pull requests, diff the spec against the base branch's version. Fail the build on removed fields or operations, newly required request properties, and narrowed enums, because each of these breaks existing consumers.
Expose each check as its own npm script, such as spec:emit, spec:drift, test:contract, and spec:breaking, and chain them in a single ci:contract script so local runs and CI behave the same way.
When to avoid it, and the impact on cost and engineering time
Spec-first pays off when the contract crosses a team boundary. It is the wrong choice when one team owns both server and client and ships them together, because a shared package of TypeScript DTOs gives the same type safety without the YAML step. It is also a poor fit for small internal APIs with a single consumer, and for prototypes whose shape changes daily, where reviewers cannot meaningfully read contract diffs that change that often.
| Added cost | Time saved |
|---|---|
| Every change starts as a YAML edit, then regeneration | No hand-written client to keep in sync |
| Runtime validation maintained alongside the spec | Fewer integration bugs reaching staging |
| Tuning drift checks to ignore cosmetic differences | Breaking changes caught in the pull request |
| Versioning and publishing the client package | Frontend builds against mocks while backend implements |
Illustrative estimate (assumed numbers, not a benchmark)
Net hours saved per month = (I × hᵢ) + (C × h꜀) − (E × hₑ) − M
Assumed inputs:
- I = 3 integration bugs avoided at hᵢ = 4 h each
- C = 10 client endpoint changes at h꜀ = 0.5 h of hand-writing each
- E = 20 spec edits at hₑ = 0.25 h of overhead each
- M = 3 h of check and release upkeep
Calculation: 12 + 5 − 5 − 3 = 9 h/month saved.
With a single consumer, assume I falls to 0.5. The result becomes 2 + 5 − 5 − 3 = −1 h/month, a net loss.
Adopt spec-first when most of these are true:
- More than one team or repository consumes the API.
- External or mobile clients cannot upgrade in lockstep with the server.
- Frontend and backend work need to start in parallel.
- The API surface is stable enough that reviewing contract diffs is worth the time.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to nestjs openapi code generation, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.