Client SDK Generation Pipelines

Part of the API Design Fundamentals & Architecture reference. A generated SDK is the point where every contract decision becomes visible to the people who consume your API — good operationIds become readable methods, composed schemas become narrowable types, and a missing required becomes an optional field nobody expects. This section covers building the pipeline that turns an OpenAPI document into published clients, and keeping that pipeline deterministic enough that its output is reviewable.

Problem framing

The failure mode is not that generation is hard; it is that generation is occasional. A team generates a client once, commits it, then hand-edits it for eighteen months because regenerating would blow away the fixes. By then the SDK and the API have diverged in ways nobody can enumerate, and the spec — the thing that was supposed to be the source of truth — describes an API that only partially exists.

A pipeline solves that by making generation cheap, deterministic, and continuous: one bundled input document, one pinned generator version, a clean output directory, and a CI check that fails when the committed SDK no longer matches what the spec produces. Once regeneration is a non-event, hand-edits stop being tempting, and the spec goes back to being authoritative. The inputs that make this work — stable component names and explicit operation IDs — come from OpenAPI Schema Composition and the naming rules enforced by Contract Linting & Governance.

Pipeline overview

Five stages, each with a single responsibility and a failure mode worth naming.

SDK generation pipeline stages A bundled OpenAPI document flows through lint, then fans out to TypeScript, Python and Go generators, each of which is verified by contract tests before publishing to npm, PyPI and a Go module proxy. bundle one document lint naming + rules generate TS typescript-fetch generate Python pydantic models generate Go module + client verify verify verify npm PyPI Go proxy

Spec definition: the fields the generator actually reads

Most of an OpenAPI document describes runtime behaviour. A generator cares about a much smaller set of fields, and those fields deserve deliberate values because they become the SDK’s public surface.

# openapi.yaml (OpenAPI 3.1.0) — the generation-relevant surface
openapi: 3.1.0
info:
  title: Billing API           # → package description
  version: "4.1.0"             # → default SDK version input
servers:
  - url: https://api.example.com/v4     # → default base URL constant
tags:
  - name: invoices             # → method grouping (InvoicesApi / invoices.*)
paths:
  /invoices/{invoiceId}:
    get:
      operationId: getInvoice  # → method name: invoices.getInvoice()
      tags: [invoices]
      summary: Retrieve one invoice.       # → method doc comment
      parameters:
        - name: invoiceId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: expand
          in: query
          required: false
          schema:
            type: array
            items: { type: string, enum: [customer, line_items] }
          style: form
          explode: false       # → serialised as ?expand=customer,line_items
      responses:
        "200":
          description: The invoice.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Invoice" }
        "404":
          description: No invoice with that id.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
components:
  schemas:
    Invoice:                   # → generated type name
      type: object
      required: [id, total_minor, currency, status]
      properties:
        id:          { type: string, format: uuid }
        total_minor: { type: integer }
        currency:    { type: string, pattern: "^[A-Z]{3}$" }
        status:      { type: string, enum: [draft, open, paid, void] }

Field by field: operationId becomes the method name and must be unique across the document; tags group methods into API classes or namespaces; summary becomes the doc comment a developer reads on hover; required decides whether a property is optional in the generated type; enum becomes a union type or a language enum; and style/explode decide how array parameters are serialised — the difference between ?expand=a,b and ?expand=a&expand=b, which is a wire-level contract detail that generators implement literally.

Core pattern 1: deterministic generation

Non-deterministic output turns every regeneration into an unreviewable diff. Three settings fix it: pin the generator, wipe the output directory, and disable timestamp injection.

#!/usr/bin/env bash
# scripts/generate-sdks.sh — deterministic, reproducible generation
set -euo pipefail

GENERATOR_VERSION="7.14.0"          # pinned: an unpinned generator is not a pipeline
SPEC="dist/openapi.yaml"

npx @redocly/cli bundle openapi.yaml --output "$SPEC" --dereferenced=false

generate() {
  local lang="$1" out="$2"; shift 2
  rm -rf "$out"                     # deleted operations must disappear
  npx "@openapitools/openapi-generator-cli@$GENERATOR_VERSION" generate \
    -i "$SPEC" -g "$lang" -o "$out" \
    --global-property=skipFormModel=false \
    --additional-properties=hideGenerationTimestamp=true,"$@"
}

generate typescript-fetch sdk/typescript npmName=@example/billing,supportsES6=true
generate python           sdk/python     packageName=example_billing,library=asyncio
generate go               sdk/go         packageName=billing,isGoSubmodule=true

hideGenerationTimestamp=true is the setting teams miss: without it every file header carries the generation time, so every regeneration produces a diff touching every file and the drift check becomes useless.

Core pattern 2: drift detection in CI

A committed SDK is only trustworthy if something proves it still matches the spec. Regenerate on every pull request and fail when the tree changes:

# .github/workflows/sdk-drift.yml
name: SDK drift
on: [pull_request]

jobs:
  drift:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: "22" }

      - name: Regenerate all SDKs
        run: ./scripts/generate-sdks.sh

      - name: Fail if the committed SDK is stale
        run: |
          if ! git diff --quiet -- sdk/; then
            echo "::error::Generated SDKs are out of date. Run ./scripts/generate-sdks.sh and commit."
            git --no-pager diff --stat -- sdk/
            exit 1
          fi

The error message matters as much as the check: it tells the contributor exactly which command reconciles the tree, so the fix takes thirty seconds instead of an afternoon of guessing.

Core pattern 3: customising without hand-editing

Every generator supports template overrides, which is the supported way to change output. Hand-editing generated files is not.

# Extract the templates you want to change, then override only those.
npx @openapitools/openapi-generator-cli author template \
  -g typescript-fetch -o templates/typescript

# Edit templates/typescript/runtime.mustache to add a default retry policy,
# then point generation at the override directory:
npx @openapitools/openapi-generator-cli generate \
  -i dist/openapi.yaml -g typescript-fetch -o sdk/typescript \
  -t templates/typescript

Anything that cannot be expressed in a template belongs in a thin hand-written wrapper around the generated client — a separate file the generator never touches:

// sdk/typescript/index.ts — hand-written surface over generated internals
import { Configuration, InvoicesApi } from "./generated";
import { withRetries } from "./retry";

export interface BillingClientOptions {
  apiKey: string;
  baseUrl?: string;
  /** Retries apply to idempotent operations only. */
  maxRetries?: number;
}

export function createBillingClient(opts: BillingClientOptions) {
  const config = new Configuration({
    basePath: opts.baseUrl ?? "https://api.example.com/v4",
    headers: { Authorization: `Bearer ${opts.apiKey}` },
    fetchApi: withRetries(fetch, { maxRetries: opts.maxRetries ?? 2 }),
  });
  return { invoices: new InvoicesApi(config) };
}

The retry policy this wrapper installs must respect the server’s own signals — Retry-After and the retryable/non-retryable split covered in Retryable vs Non-Retryable Errors — rather than blindly retrying every failure.

CI/CD enforcement

Generation is one job in a chain that starts with linting the contract and ends with a published package. The gates in between are what stop a bad spec from becoming a bad SDK.

CI gates around SDK generation A pull request triggers spectral lint, then a breaking-change diff, then generation, then contract tests. Each gate lists what makes it fail. A tagged merge to the main branch publishes the packages. 1 · lint spectral rules fails on: no opId 2 · diff oasdiff vs main fails on: breaking 3 · generate pinned version fails on: drift 4 · verify contract tests fails on: mismatch 5 · publish on tag only provenance on pull request → main any gate failing blocks the merge — no partial publishes
# .spectral.yaml — the rules that keep generated SDKs readable
extends: ["spectral:oas"]
rules:
  operation-operationId: error          # every operation names its SDK method
  operation-operationId-unique: error
  operation-tag-defined: error          # every operation lands in a namespace
  operation-operationId-valid-in-url: off

  operationid-camel-case:
    description: operationId must be lowerCamelCase so method names are idiomatic.
    severity: error
    given: "$.paths[*][*].operationId"
    then:
      function: pattern
      functionOptions:
        match: "^[a-z][a-zA-Z0-9]+$"

  response-has-schema:
    description: A 2xx response without a schema generates an untyped return value.
    severity: error
    given: "$.paths[*][*].responses[?(@property.match(/^2/))].content[*]"
    then:
      field: schema
      function: truthy

SDK and client impact

The same contract produces materially different ergonomics per language, and knowing the mapping lets you write the spec for the SDK you want:

Spec field TypeScript Python Go
operationId: getInvoice invoices.getInvoice() invoices.get_invoice() InvoicesApi.GetInvoice()
tags: [invoices] InvoicesApi class InvoicesApi class InvoicesApiService
required: [id] non-optional property non-default field non-pointer field
optional property id?: string Optional[str] = None *string
enum string-literal union enum.Enum typed const block
format: date-time Date datetime time.Time
application/problem+json typed error class raised ApiException returned error value

Two rows carry the most weight. Optional-versus-required is the difference between a compiler catching a missing field and a nil dereference in production, so an over-generous required list is not a kindness. And a typed error class only appears when error responses declare a schema — a 404 documented with a description but no content generates an untyped throw, which is why the shared Problem schema from RFC 7807 Problem+JSON Implementation belongs on every documented failure.

Edge-case handling

Multiple content types per response. A response offering both application/json and text/csv produces either a union return type or, more often, a client that silently assumes JSON. Model the CSV variant as a separate operation unless clients genuinely negotiate.

Streaming and long-poll endpoints. Most generators model a streaming response as a single buffered body. Where the endpoint streams, document it and provide a hand-written method in the wrapper rather than shipping a generated method that loads gigabytes into memory.

Deprecated operations. deprecated: true becomes a language-level deprecation annotation, which is the cheapest possible migration signal for consumers — pair it with the runtime headers described in Sunset & Deprecation Headers.

Auth schemes generators cannot express. Signed-request schemes (HMAC over the body, mTLS) have no OpenAPI representation that generators implement. Put them in the wrapper’s fetchApi/transport hook, and document them in the spec description so the gap is intentional.

Anti-patterns quick-reference

Anti-pattern Recommended approach
Unpinned generator version Pin the CLI or image; treat an upgrade as a reviewed change
Hand-editing generated files Override templates, or wrap the generated client
Generating into a dirty directory rm -rf the output first so deletions propagate
Leaving generation timestamps on hideGenerationTimestamp=true for reviewable diffs
Missing operationIds Require them in lint; they name the SDK methods
Undocumented error schemas Attach the shared Problem schema to every failure response
One version number across all languages Derive per-language versions from one spec-diff classification
Publishing from a developer machine Publish from a tagged CI release with provenance

When to stop generating and start wrapping

Every pipeline eventually meets a requirement the generator cannot express: a signed-request auth scheme, a streaming endpoint that must not buffer, a pagination helper that hides cursors from callers, a retry policy that reads Retry-After. The instinct is to reach for template overrides, and for anything that is a rendering concern — a header comment, a base-class name, an import path — that is right.

The rule for everything else is simpler than it looks. If the change is about how the generated code is written, override the template. If it is about what a developer calls, put it in a hand-written wrapper module the generator never touches. The wrapper imports the generated client, exposes the surface you actually want, and can be reviewed, tested and documented like ordinary code — while regeneration remains a non-event because nothing generated was edited.

That split also protects you from the most expensive migration in this area. Swapping generators rewrites method names, type names and error classes, so every consumer that imported generated symbols directly must change. Consumers that imported your wrapper see nothing at all, and the swap becomes an internal refactor with a patch release rather than a major version and a migration guide.

Template overrides versus a hand-written wrapper The generated layer holds models, transport and operation methods. Template overrides handle rendering concerns such as headers and base classes. The wrapper layer holds auth schemes, retries, pagination helpers and the public surface consumers import. spec operationIds component names generated models + transport never hand-edited template overrides rendering only headers, base classes wrapper module auth, retries, paging what consumers import two layers, two kinds of change a generator swap changes the middle two columns and leaves the fourth untouched

FAQ

Should generated SDK code be committed to version control?

Commit it. A committed client turns a spec change into a reviewable code diff — reviewers see that adding an enum value narrowed a union, or that a field became optional — and lets contributors build without installing a generator toolchain. It also enables the drift check: regenerate in CI and fail if the tree moves. The usual objection is diff noise, and it is real, but pinning the generator and disabling timestamps removes nearly all of it. The alternative, generating at install time, hides breaking changes until a consumer’s build fails.

How do I stop a generator from renaming methods on every regeneration?

Name things explicitly in the spec. Every operation needs an operationId, and every reusable schema needs a key under components/schemas. When those are absent, generators synthesise names from the path, the method and an index — so adding an endpoint above another one renames the second one’s type, and the diff is full of renames that look like breaking changes. Enforce both with lint rules and the generated surface becomes as stable as the names you chose.

Should each language SDK have its own version number?

Yes, with a shared origin. Derive each SDK’s version bump from the same spec-diff classification — additive change means a minor bump everywhere, breaking change means major — but let the numbers diverge, because a packaging fix in the Python client should not force a republish of the Go module, and registries refuse to accept a re-used version. Record the API version each SDK release was generated from in the release notes so a consumer can map back.

Is a hand-written client ever better than a generated one?

For a small, public, ergonomics-critical API, the best answer is usually hybrid: generate the models and transport, hand-write the surface developers actually touch. The generated layer stays in sync with the contract automatically, while the hand-written layer can offer pagination helpers, sensible retry defaults and idiomatic naming that no generator will produce. Fully hand-writing a large client is a decision to accept drift, because the moment the spec changes, nothing forces the client to follow.