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.
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.
# .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.
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.
Related
- API Design Fundamentals & Architecture — up-link: the parent reference for contract-first API design
- OpenAPI Generator vs Kiota vs Speakeasy — choosing the generator this pipeline pins
- Publishing Generated SDKs from GitHub Actions — the release half of the pipeline, end to end
- Naming Operation IDs for Readable SDK Methods — the field that decides every method name
- OpenAPI Schema Composition — the sibling section that shapes generated model types
- SDK Version Pinning — how consumers depend on what this pipeline publishes