What the OpenAPI contract is and which spec version to use

An OpenAPI Description (OAD) is a document that describes an HTTP API in a standard, programming language-agnostic form. The OpenAPI Specification states the goal directly: humans and computers should be able to understand a service's capabilities "without requiring access to source code, additional documentation, or inspection of network traffic." That property makes an OAD usable as a contract. Client teams, documentation tools, code generators and test tools all read the same file, and none of them needs your implementation.

In contract-first work the OAD is the source of truth. The server conforms to the spec, and the spec is never regenerated from the server. If the two disagree, the code is wrong until someone changes the contract on purpose through review. If nothing enforces that rule, the spec drifts away from the running service and stops being a contract. A spec written first and then left to drift is only early documentation.

Spec line Schema model Adoption note
3.0.x Extended subset of JSON Schema. Uses nullable for null values Widest tool support. Common in older pipelines
3.1.x Schema Objects aligned with JSON Schema. Null is expressed through type Lets you reuse standard JSON Schema validators
3.2.x Builds on 3.1. 3.2.1 is the latest published version Newest line, so tool support may lag behind the spec

Choosing a version depends on your tools more than on spec features. Before you adopt a version, confirm that every generator, validator, linter and API gateway in your pipeline supports it. A single tool that only understands 3.0 can quietly force your whole contract back to 3.0.

When contract-first is worth it, when to avoid it, and the alternatives

Contract-first means the OpenAPI document is written, reviewed and agreed on before anyone implements an endpoint. The main benefit is that the argument about field names, error shapes and pagination happens while changing them costs a pull-request comment. After the API ships, the same change costs a migration. An agreed spec also lets work run in parallel. Frontend and partner teams can build against mock servers and generated clients while the backend team implements the server, without waiting on each other.

The approach pays off most when consumers are numerous or outside your control. Public APIs need it because you cannot coordinate a breaking change with unknown callers. So do internal APIs with several consuming teams, where the spec replaces a stream of Slack questions. Platform teams that publish standards for pagination, auth and errors also benefit, because they can only enforce those standards against an artifact that exists before the code.

Skip it for small internal services with one consumer that sits next to the producer, and for exploratory prototypes whose shape changes daily. There, writing the spec first adds ceremony to a design that is still unknown. Code-first with a generated spec still gives you documentation and client generation.

Approach Drift risk Upfront effort Consumer trust
Contract-first Low, if CI validates the implementation against the spec High: design and review before coding High: consumers can rely on the spec
Code-first, generated spec Low for structure; descriptions and examples still drift Low Medium: the spec reflects what was built, not what was agreed
No formal spec Not measurable, because there is nothing to drift from None Low: consumers learn the API from source code or traffic

Designing the contract before writing code

Start from resources rather than endpoints. Name the nouns (Order, Customer, Refund), decide which ones are collections and which are single items, and only then attach operations to paths. Every reusable shape goes under components/schemas, with responses under components/responses. If two operations return an order, they should both reference the same $ref. Otherwise the two copies will drift apart as people edit them.

Specify errors as carefully as success responses. A single shared error schema, referenced from every 4xx and 5xx response, lets consumers write one error handler. It also forces the team to agree on field names and error codes before each handler invents its own. The fragment below defines that error schema alongside a paginated list response.

Some decisions are expensive to change once clients depend on them, so settle them in the contract first:

  • Pagination: offset pagination is simple, but pages skip or repeat items when rows are inserted between requests. With cursor pagination, the server can change how it stores data without breaking clients.
  • Identifiers: opaque strings let you change how IDs are generated later. Integer IDs leak row counts and are hard to migrate.
  • Nullability: decide whether "absent", null and "empty" mean different things. In OpenAPI 3.1 and later, write nullable fields as type: [string, "null"], not nullable: true.
  • Enums: adding a value breaks clients that reject values they don't recognise. Document that clients must tolerate unknown values, or treat every new value as a breaking change.

An OpenAPI description (OAD) cannot express everything. Cross-field validation, authorization rules, rate limits, ordering guarantees and side effects all fall outside it. Write these down as prose in description fields and enforce them with tests.

openapi: 3.1.0
info: {title: Orders API, version: 1.0.0}
paths:
  /orders:
    get:
      parameters:
        # Cursor pagination: the cursor is opaque, so storage can change without breaking clients
        - {name: cursor, in: query, schema: {type: string}}
        - {name: limit, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}}
      responses:
        '200':
          description: One page of orders
          content:
            application/json:
              schema:
                type: object
                required: [items, next_cursor]
                properties:
                  items: {type: array, items: {$ref: '#/components/schemas/Order'}}
                  # 3.1 nullability: always present, null on the last page
                  next_cursor: {type: [string, 'null']}
        '4XX': {$ref: '#/components/responses/Error'}
        '5XX': {$ref: '#/components/responses/Error'}
components:
  schemas:
    Order:
      type: object
      required: [id, status]
      properties:
        id: {type: string, description: Opaque identifier; clients must not parse it}
        status:
          type: string
          enum: [pending, paid, shipped]
          description: Clients must tolerate values not listed here
    Error:  # one shape for every failure, so consumers write one handler
      type: object
      required: [code, message]
      properties:
        code: {type: string, examples: [invalid_cursor]}
        message: {type: string}
  responses:
    Error:
      description: Any client or server error
      content: {application/json: {schema: {$ref: '#/components/schemas/Error'}}}

An OpenAPI 3.1 fragment for a cursor-paginated order list. Every 4XX and 5XX response references one shared error schema. It also shows opaque IDs, a nullable next_cursor and an enum that clients are told to treat as open.

Making the spec reviewable and governed

Keep the OpenAPI Description in the same repository as the service, committed before or alongside the handler that implements it. A spec that lives in a separate wiki drifts, because nobody's pull request is blocked when it goes stale.

Once a single file passes a few thousand lines, split it with $ref. A layout that scales reasonably:

  • openapi.yaml: info, servers, security schemes, and paths entries that only reference other files
  • paths/orders.yaml, paths/orders-{id}.yaml: one file per path item
  • components/schemas/Order.yaml, components/schemas/Money.yaml
  • components/responses/Problem.yaml: the shared error shape every operation references

The split has a cost. Many code generators and documentation tools expect one document, so CI needs a bundling step, and you have to decide whether the bundled output is committed or built. Long YAML lists such as enum values and required arrays produce merge conflicts when two branches append to the same spot.

Review spec changes in pull requests and require an owner's approval, as you would for code. Lint in CI for house style: casing of operation IDs and properties, a non-empty description on every operation and schema, and every 4xx/5xx response pointing at the shared error component. A linter catches what tired reviewers skip, and reviewers do skip large diffs. Keep spec PRs small so a reviewer can actually read them.

Someone has to own the rule set and the breaking-change policy. When several teams share an API style, a rule change means updating every service that now fails the lint. Plan for that ongoing work and give it a named owner.

# openapi.yaml: the root holds metadata and security; paths only reference other files
openapi: 3.1.0
info:
  title: Orders API
  version: 1.4.0
servers:
  - url:
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
security:
  - bearerAuth: []
paths:
  # The path template keeps {id}. The file name avoids braces because they would
  # need percent-encoding inside a $ref URI.
  /orders:
    $ref: ./paths/orders.yaml
  /orders/{id}:
    $ref: ./paths/orders-by-id.yaml

# --- paths/orders-by-id.yaml: one path item per file, so it can be reviewed on its own ---
# Relative refs resolve from this file's location, not from the root.
parameters:
  - name: id
    in: path
    required: true
    schema: { type: string }
get:
  operationId: getOrder        # house style: camelCase, checked by the linter
  description: Fetch a single order by its ID.
  responses:
    '200':
      description: The order.
      content:
        application/json:
          schema: { $ref: ../components/schemas/Order.yaml }
    '404': { $ref: ../components/responses/Problem.yaml }  # shared error shape
    '500': { $ref: ../components/responses/Problem.yaml }

This shows a root OpenAPI document whose paths point to separate path-item files, and those files use relative $ref to reach shared schemas and one common error response.

Code generation as a consumer of the contract

Once the contract is governed, code generation turns it into types that the compiler checks. From one OpenAPI Description you can produce client SDKs for consumers, plus request/response models and a server interface for the implementing team.

On the server side, generate interfaces and leave the implementation to hand-written code. A generator that emits complete handlers seems faster on the first day. The problem appears when someone edits that output: from then on, regenerating either overwrites their changes or forces a manual merge, and the spec stops being the source of truth. A better structure is to generate a PaymentsApi interface with one method per operation, then write a PaymentsService class that implements it. When the spec changes, you regenerate the interface, and any method whose signature no longer matches fails to compile. That compile error points you to the code that needs updating.

Check the generator's output against your actual schemas, because generators often handle these cases poorly:

  • oneOf/anyOf and discriminators may become a loose map, a base class that loses variant fields, or a union that ignores the discriminator mapping.
  • Nullable fields are written as nullable: true in 3.0 and as type: [string, "null"] in 3.1. Generators frequently confuse "nullable" with "optional."
  • Recursive schemas can cause infinite expansion or force everything into an untyped object.
  • Unidiomatic output, such as awkward names, getter-heavy models or blocking calls in async codebases, makes engineers wrap the generated code or avoid using it.
  • Support for new OAS versions lags behind the spec. Before you adopt 3.1 or 3.2 features, confirm that your generator supports them.
// --- generated/payments-api.ts (regenerated from openapi.yaml; never edit by hand) ---
export interface Payment {
  id: string;
  amount: number;            // minor units, e.g. cents
  currency: string;
  description: string | null; // required but nullable: always present, may be null
  reference?: string;         // optional: may be absent entirely
}

export interface CreatePaymentRequest {
  amount: number;
  currency: string;
  description: string | null;
}

// One method per operationId in the spec.
export interface PaymentsApi {
  createPayment(body: CreatePaymentRequest): Promise<Payment>;
  getPayment(paymentId: string): Promise<Payment | undefined>;
}

// --- src/payments-service.ts (hand-written; survives every regeneration) ---
import { randomUUID } from "node:crypto";

export class PaymentsService implements PaymentsApi {
  private readonly store = new Map<string, Payment>();

  async createPayment(body: CreatePaymentRequest): Promise<Payment> {
    const payment: Payment = { id: randomUUID(), ...body };
    this.store.set(payment.id, payment);
    return payment;
  }

  // If the spec changes this operation's signature, regenerating the
  // interface breaks compilation here instead of silently drifting.
  async getPayment(paymentId: string): Promise<Payment | undefined> {
    return this.store.get(paymentId);
  }
}

A generated PaymentsApi interface (with nullable and optional fields kept distinct) implemented by a hand-written PaymentsService, so regenerating the interface surfaces contract changes as compile errors instead of overwriting business logic.

Closing the loop: CI validation, contract tests and safe evolution

A spec that nothing checks drifts from the server within weeks. The pipeline below keeps every change tied to the contract:

openapi.yaml ──► lint ──► diff vs. main ──► codegen ──► mock server
                  │            │                            │
               fail on      fail on                  consumers build
               style rules  breaking change          against it
                                                            │
deployed test env ◄── contract tests (real responses vs. schemas)

Lint and validate in CI. Do this first because it is the cheapest check. A spec that fails validation breaks every downstream generator.

Conformance tests call a running server and check each response against the declared schema. This is the only stage that proves the implementation matches the document. You can also validate requests and responses at runtime in staging to catch paths the tests miss. Keep this out of production, where it adds latency and can reject live traffic.

Mock servers built from schemas and examples let consumers start before the backend exists. Mocks return instant, valid responses. They hide real latency, real error behaviour and data constraints, such as uniqueness or referential integrity, that the schema cannot express.

Diffing compares the proposed spec against the version on the main branch. It fails the build on changes like these:

Change Breaking? Why
Response field removed Yes Clients reading it fail
Validation tightened (e.g. maxLength lowered) Yes Previously valid requests rejected
Field type changed Yes Deserialisation breaks
Optional request field made required Yes Existing callers omit it
Optional response field added Usually no Unless clients reject unknown fields

Before you remove anything, mark it deprecated: true and keep it for at least one release cycle. Then remove it in a deliberate, versioned change.

┌──────────────┐   ┌──────────┐   ┌──────────────┐   ┌───────────┐
│ openapi.yaml │──►│   lint   │──►│ diff vs main │──►│  codegen  │
│ (PR branch)  │   │ validate │   │ (breaking?)  │   │ SDK/stubs │
└──────────────┘   └────┬─────┘   └──────┬───────┘   └─────┬─────┘
                        │                │                 │
                        ▼                ▼                 │
                   FAIL: invalid   FAIL on break:          ▼
                   spec or style   - field removed  ┌─────────────┐
                   rule            - type changed   │ mock server │
                                   - tighter rules  │ (schemas +  │
                                   - now required   │  examples)  │
                                                    └──────┬──────┘
                                                           ▼
                                                    consumers build
                                                    (no real latency,
                                                     errors, data
                                                     constraints)

┌──────────────────┐     ┌────────────────────┐     ┌──────────────────┐
│ server build     │────►│ deployed test env  │◄────│ contract tests   │
│ (implementation) │     │ runtime req/resp   │     │ real responses   │
└──────────────────┘     │ validation         │     │ vs. schemas      │
                         │ (never production) │     └──────────────────┘
                         └────────────────────┘

Removal path: deprecated: true ──► consumers migrate ──► remove field

Each change to openapi.yaml is linted and diffed against main, which can fail the build. Codegen and mock servers let consumers start early, and contract tests check real responses from a deployed test environment against the same schemas.

Worked example: from spec to a failing contract test

The resource

Take an Order schema with four required properties: id (string), status (an enum of pending, paid and shipped), totalAmount (integer, minor currency units) and createdAt (string, format: date-time). The GET /orders/{orderId} operation declares a 200 response whose body references this schema.

Code generation produces an Order type from that schema. The handler is written by hand. It loads the order from storage, maps it onto the generated type and serializes it. The contract test starts the service, calls the endpoint with a known fixture, and validates the actual response body against the 200 response schema in the spec. It does not validate against the generated type, because the spec is the contract and the generated type is only one consumer of it.

Breaking it

Suppose a refactor changes the serialization mapping, so totalAmount is emitted as total. The compiler accepts this. The generated type is unchanged, and the rename happens in a field tag or a mapper rather than in the type. Unit tests that compare against the handler's own structs still pass. The contract test fails, because it checks the bytes on the wire.

A schema validator's failure output usually reports three facts:

  • Location: where the problem occurred, as a JSON Pointer to the response body root.
  • Keyword: which schema rule failed, in this case required.
  • Property: which name is missing, in this case totalAmount.

If the schema also sets additionalProperties: false, a second error names total as unexpected. Read together, the two errors show the field was renamed rather than dropped. That tells the engineer to fix the mapping, not the data source, before the change reaches a client.

Impact on development cost and engineering time

Most of the cost of contract-first is paid up front, and most of the return arrives later. Design and review take longer before any handler exists, and someone has to maintain lint rules and approve spec changes. The return comes from four places. Frontend and backend teams work in parallel against the same spec. Generated clients replace hand-written ones. Fewer field-name and type mismatches reach integration. Breaking changes are caught in CI rather than reported by a consumer in production.

Illustrative estimate (assumed numbers, not a benchmark)

The figures below are assumed hours for one typical endpoint. Replace them with your own.

Item (hours per endpoint) Code-first Contract-first
Design and review 1 3
Governance (lint rules, spec upkeep) 0 1
Hand-written client 2 0
Fixing integration mismatches 3 0.5
Frontend blocked waiting for backend 2 0
Total 8 4.5

Saving per period = N × (T_code-first − T_contract-first), where T is the sum of the rows and N is the number of endpoints shipped. With an assumed N of 10 endpoints per quarter, that gives 10 × 3.5 = 35 hours.

The contract-first column only holds if the running service is continuously tested against the spec. Without conformance testing, the mismatch row climbs back toward the code-first figure while the design and governance costs remain. A drifted spec is worse than having no spec. Generated clients, mocks and documentation then describe the wrong API with full confidence, and consumers build against it.

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 openapi contract first, 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.
  • Changing a route's URL or method automatically notifies the services that consume it — no hunting through repos for callers.

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.
  • Consumers learn about a breaking change at review time rather than from production errors.
  • 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.