Spec-First API Development with OpenAPI 3.1: Design the Contract Before the Code

If your OpenAPI document is generated from code annotations after the fact, it is documentation, not a contract. Spec-first inverts that: you write the contract by hand, machines check it, and the build is gated on it.

The common pattern I inherit on client estates is code-first: developers decorate controllers with annotations, a library emits an OpenAPI file at build time, and that file is treated as the API’s description. It works until two teams need to agree on an interface before either has written a line of it, or until a supervisor asks you to show that the interface a partner integrated against is the one you actually shipped. At that point a generated artefact that trails the code by a sprint is worth very little. Spec-first fixes the ordering. You author the OpenAPI 3.1 document first, you lint it, you mock it, and only then do you generate stubs and clients from it. This is the same discipline as designing the process before you buy the tool — the contract precedes the implementation, not the reverse.

What spec-first actually changes

The mechanical difference is small; the governance difference is not. When the spec is the source of truth, the frontend team can build against a mock the day the interface is agreed, the backend can be generated from stubs that already match, and any drift between them is a lint failure rather than a production incident found by a partner. OpenAPI 3.1 matters here specifically because it aligns the schema language with JSON Schema 2020-12, so the same schema you validate against in the pipeline is the one your runtime validators and code generators understand. The older 3.0 dialect was a near-miss of JSON Schema; 3.1 closes the gap, which is what makes the generate-from-spec step trustworthy.

Free · 4 minutes

Is your engineering team shipping safely, or quietly accumulating risk?

Fourteen questions on how work gets from idea to production — cadence, testing, rollback, and the key-person risk in your delivery. Banded finding on screen, full sheet by email.

Write the OpenAPI 3.1 document

Author it by hand, or in Stoplight Studio if you want a visual editor over the same YAML. The two things that make a spec reusable rather than a wall of copy-paste are components you reference with $ref and concrete examples on every response. Note the 3.1 idioms: nullability is expressed as a type array (type: [string, "null"]) because the nullable keyword was removed, and errors use the application/problem+json media type from RFC 9457.

openapi: 3.1.0
info:
  title: Payments API
  version: 1.0.0
paths:
  /payments/{id}:
    get:
      operationId: getPayment
      summary: Retrieve a payment
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The requested payment
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Payment"
              examples:
                settled:
                  value:
                    id: 6f1a9c2e-3b7d-4e9a-8c11-2f0d5a7b9e44
                    amount: 4200
                    currency: EUR
                    status: settled
        "404":
          $ref: "#/components/responses/NotFound"
components:
  schemas:
    Payment:
      type: object
      required: [id, amount, currency, status]
      properties:
        id:
          type: string
          format: uuid
        amount:
          type: integer
          description: Amount in minor units (e.g. cents)
        currency:
          type: string
          pattern: "^[A-Z]{3}$"
        status:
          type: [string, "null"]
          enum: [pending, settled, failed, null]
    Problem:
      type: object
      properties:
        type: { type: string, format: uri }
        title: { type: string }
        status: { type: integer }
  responses:
    NotFound:
      description: Resource not found
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"

Lint the spec so it can gate the build

A spec that only has to be valid OpenAPI is not enough to build on. You want house rules: every operation has an operationId (code generators need it to name functions), paths are kebab-case, error responses use the problem media type. Spectral enforces this. Extend the built-in spectral:oas ruleset and add your own on top.

# .spectral.yaml
extends: ["spectral:oas"]
rules:
  require-operation-id:
    description: Every operation needs an operationId for code generation.
    given: $.paths[*][get,post,put,patch,delete]
    severity: error
    then:
      field: operationId
      function: truthy
  kebab-case-paths:
    description: Path segments should be lower-case and hyphenated.
    given: $.paths[*]~
    severity: warn
    then:
      function: pattern
      functionOptions:
        match: "^(/[a-z0-9-{}]+)+$"
  no-http-basic:
    description: Prefer a stronger scheme than HTTP Basic.
    given: $.components.securitySchemes[*]
    severity: error
    then:
      field: scheme
      function: pattern
      functionOptions:
        notMatch: basic

Run it locally and, more importantly, in CI so a non-conforming spec cannot merge:

npx @stoplight/spectral-cli lint openapi.yaml --ruleset .spectral.yaml

The given field is a JSONPath expression; the trailing ~ in the path rule selects the property key (the path string itself) rather than its value. Errors set a non-zero exit code, so wiring this into a pull-request check is the whole point: the spec now gates the build.

Mock with Prism so the frontend starts today

Once the spec lints clean, Prism turns it into a running mock server without a line of backend code. It serves the examples you defined, and it validates incoming requests against the schema, so the frontend is coding against the real contract from the first day.

# Serve the examples from the spec on http://127.0.0.1:4010
npx @stoplight/prism-cli mock openapi.yaml

# Or generate varied data that conforms to each schema
npx @stoplight/prism-cli mock -d openapi.yaml

The -d (dynamic) flag generates fresh values that satisfy the schema instead of returning the static example, which is useful for shaking out UI code that assumed one particular payload. Prism also runs as a validation proxy in front of a real backend, which is how you later catch the implementation drifting from the contract.

Generate the typed client from the spec

The last step is what makes the whole workflow pay off: the client is generated, not written. For a TypeScript frontend, openapi-typescript reads the spec and emits types that mirror it exactly.

# Emit a typed schema from the contract
npx openapi-typescript openapi.yaml -o src/api/schema.d.ts

Regenerate this in CI and commit the result, or fail the build if it changes without the spec changing. Now a breaking edit to a schema surfaces as a compile error in the consumer, not as a runtime 500 in production. The same principle applies when the API is a governed external product — a data-sharing interface, for instance, where the contract and the API have to move together and the specification is the artefact both sides sign off on. For internal service-to-service calls the same spec feeds both the client and the transport hardening you put underneath it.

Put together, the pipeline is short: lint the spec, block the merge if it fails, mock it for the frontend, generate the client, and regenerate on every change. The moment the specification stops being a build output and becomes a build input, the arguments about what the API does get settled in review, in YAML, before anyone has shipped the wrong thing.

Free interactive tool

Website compliance checklist

What your site has to do, based on what it actually does

Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.

Most technology problems are not technology problems. They are control problems.

The systems exist. The investment has been made. The question is whether leadership can understand, direct, evidence, and sustain what those systems produce. Find out where control exists — and where it only appears to.