What spec-first OpenAPI code generation is and who it suits
In api first design, the OpenAPI document is written, reviewed, and merged before any handler exists. Server interfaces and client libraries are then derived from it with openapi code generation, not written by hand. The order matters. A pull request that adds an endpoint changes the YAML first. Reviewers argue about resource names, status codes, pagination, and error shapes while the change is still cheap to make. Once that PR merges, the implementation only has to satisfy an interface the compiler already knows about.
Teams adopt this for three reasons:
- One contract. Producers and consumers read the same document instead of each keeping their own notes on what the API returns.
- Shared types. A field rename shows up as a compile error in the stub and in every generated client, not as a runtime
nullweeks later. - Design review before code. API decisions get reviewed by people who will consume the API, not just by whoever wrote the handler.
The approach pays off most for platform teams exposing internal APIs to many services, for any service with several independent consumers, and for polyglot organizations, where generating clients in each language is far cheaper than hand-maintaining them. A single service with one frontend owned by the same team gains much less.
The rest of this article follows one rule: the spec is the single source of truth, and generated code is never hand-edited. Developer discipline does not hold that line for long, so CI enforces it. CI regenerates the code, fails the build when the output differs from what is committed, and treats any mismatch as contract drift.
How the pipeline works end to end
The pipeline has seven stages. Each runs on every pull request, and each exists to stop one specific failure.
-
Bundle. Resolve every
$refacross the split spec files into one document. All later steps read this single artifact, so the linter, the diff tool, and the generator see the same input. Without bundling, two tools can resolve references differently. That gives you inconsistent generator input. -
Lint. Check the bundled spec against style and correctness rules: every operation has an
operationId, schemas have names, and error responses are declared. The generator turns ambiguity straight into code. A missingoperationIdbecomes a meaningless method name, and an inline schema becomes a class called something likeInlineObject3. Linting prevents poor-quality output from a sloppy spec. -
Breaking-change check. Diff the bundled spec against the version on the main branch. Fail the build if a field is removed, a parameter becomes required, or an enum value disappears. This stops silent breaking changes from reaching consumers.
-
Generate. Run a pinned OpenAPI Generator with checked-in configuration to produce server interfaces and clients.
-
Clean-tree check. If the working tree differs from what is committed after generation, fail the build. This catches hand edits to generated files and stale generated code that someone forgot to regenerate.
-
Compile and test. Build the handwritten implementation against the regenerated interfaces. If a handler returns the wrong type or ignores a new required field, compilation or contract tests fail. This catches shape drift between the spec and the running service.
-
Publish clients. Release the generated clients as versioned packages. Consumers then depend on an artifact instead of copying client sources into their own repositories, where those copies would go stale without anyone noticing.
split spec files ($ref across multiple files)
|
v
+-------------------------------+ FAILURE IT PREVENTS
| 1. Bundle: resolve all $ref |----> inconsistent generator input
+---------------v---------------+
| 2. Lint bundled spec |----> poor output from a sloppy spec
+---------------v---------------+
| 3. Breaking-change check |----> silent breaking changes
| diff vs spec on main |
+---------------v---------------+
| 4. Generate (pinned generator |----> (produces stubs + clients that
| + checked-in config) | steps 5-7 verify)
+---------------v---------------+
| 5. Clean-tree check |----> hand edits, stale generated code
+---------------v---------------+
| 6. Compile and test handlers |----> shape drift, spec vs service
| against new interfaces |
+---------------v---------------+
| 7. Publish versioned clients |----> copied client sources
+---------------+---------------+
|
v
consumers depend on versioned client packages
The seven pipeline stages run in order on every pull request, and each stage blocks one specific failure before the generated clients are published.
Organizing and linting the spec
Every service owns exactly one canonical spec, stored next to its code. Small specs can stay as a single file. Once a spec passes a few hundred lines, split it so reviewers can read a change without scrolling through unrelated endpoints. A workable layout:
api/openapi.yaml: root document withinfo,servers, andpathsentries that$refinto the files belowapi/paths/: one file per resource, such asorders.yamlandcustomers.yamlapi/components/schemas/,api/components/responses/,api/components/parameters/: reusable definitionsapi/dist/openapi.bundled.yaml: generated output, never edited by hand
Generators resolve external $refs inconsistently, and some handle relative paths poorly. A bundling step that inlines everything into one file means each generator, mock server, and docs tool reads identical input.
Make linting the first CI stage, ahead of bundling and generation. It should fail the build when:
- the document is structurally invalid against the OpenAPI schema
- an operation lacks an
operationId operationIds break the house convention, for example lowerCamelCase verb-noun such aslistOrders- schema or property names mix casing styles
- an operation documents no 4xx response, or its error responses skip the shared error schema
These rules matter because generators turn the spec into code mechanically. A missing operationId becomes a synthesized method name like ordersIdGet. An inline anonymous schema becomes a class called InlineResponse2001. An undocumented error response becomes an untyped exception path in every client. Linting is the cheapest point to fix these problems, before they spread into generated code in several languages.
#!/usr/bin/env python3
"""House-rule lint for split OpenAPI path files. Runs as the first CI stage."""
import re, sys
from pathlib import Path
import yaml # PyYAML
METHODS = {"get", "put", "post", "delete", "patch", "head", "options", "trace"}
# lowerCamelCase verb-noun, e.g. listOrders, getOrder, createCustomer
OPERATION_ID = re.compile(r"^[a-z]+(?:[A-Z][a-z0-9]*)+$")
def lint(path_file):
errors = []
doc = yaml.safe_load(path_file.read_text()) or {}
# A file holds either one path item or a map of path template -> path item
items = {"": doc} if METHODS & doc.keys() else doc
for route, item in items.items():
for method, op in item.items():
if method not in METHODS:
continue # skip path-level parameters, summary, etc.
where = f"{path_file}: {method.upper()} {route}".rstrip()
op_id = op.get("operationId")
if not op_id:
errors.append(f"{where}: missing operationId")
elif not OPERATION_ID.match(op_id):
errors.append(f"{where}: operationId '{op_id}' is not lowerCamelCase verb-noun")
responses = op.get("responses", {})
client_errors = {c: r for c, r in responses.items() if str(c).startswith("4")}
if not client_errors:
errors.append(f"{where}: no 4xx response documented")
for code, resp in client_errors.items():
# Shared responses in components/responses wrap the common Error schema
if "components/responses/" not in resp.get("$ref", ""):
errors.append(f"{where}: {code} does not use a shared error response")
return errors
problems = [e for f in sorted(Path("api/paths").glob("*.yaml")) for e in lint(f)]
print("\n".join(problems) or "house rules: ok")
sys.exit(1 if problems else 0) # non-zero exit fails the CI stage before bundling
A minimal house-rule linter, run alongside a structural OpenAPI schema validator, that fails CI when operations in api/paths/ lack an operationId, break the lowerCamelCase verb-noun convention, document no 4xx response, or skip the shared error responses.
Running the generator reproducibly and keeping stubs regeneration-safe
Generation is only useful in CI if the same spec produces the same output on every machine. Pin the generator to an exact version, because generator releases change templates, and an unpinned upgrade shows up as a large diff that has nothing to do with your API. There are two common ways to pin it. One is to run the official container image by a specific tag or digest. The other is to install the generator through a lockfile-managed tool, such as a project-local npm or Gradle dependency, so developers and CI resolve the same build.
Put every generator option in a checked-in config file: generator name, package names, model naming, date library, and flags such as interface-only output. If options live in shell history or a README snippet, two engineers will eventually generate with different settings, and the drift check in the next section will flag noise instead of real contract changes. The example below shows such a file.
Keep generated and hand-written code apart
Generate interfaces or abstract handlers into their own package, and never edit that package. Your hand-owned code lives elsewhere and implements those interfaces, as the short example below shows. When an operation is added or a parameter changes, regeneration rewrites the interface, and the compiler points to every implementation that no longer matches. You never lose hand-written code to an overwrite.
Use the generator's ignore file only for narrow exceptions, such as a build file you maintain yourself. If you ignore whole directories, that code stops tracking the spec without any warning.
# openapi/generator-config.yaml (checked in; the only place generator options live)
# Run with a pinned image so local and CI output match, e.g.:
# docker run --rm -v "$PWD:/local" \
# openapitools/openapi-generator-cli:<pinned-tag-or-digest> \
# generate -c /local/openapi/generator-config.yaml
generatorName: spring
inputSpec: /local/openapi/orders-api.yaml
# Generated code goes to its own source root. Nobody edits files here.
outputDir: /local/build/generated/openapi
# Separate packages keep generated types away from hand-owned code
# (for example, com.example.orders.web implements these interfaces).
apiPackage: com.example.orders.generated.api
modelPackage: com.example.orders.generated.model
additionalProperties:
# Emit interfaces only. Hand-written controllers implement them, so
# regeneration never overwrites business logic and the compiler flags
# every implementation that no longer matches the spec.
interfaceOnly: true
# No default method bodies, so a new operation is a compile error
# until someone implements it.
skipDefaultInterface: true
useSpringBoot3: true
dateLibrary: java8
# Avoid timestamps or similar noise that would trip the drift check.
hideGenerationTimestamp: true
A checked-in OpenAPI Generator config, run through a pinned container image, that generates Spring interfaces into a dedicated package so hand-written controllers implement them and survive regeneration.
Committing generated code, clean-tree checks, and catching contract drift
Whether generated code lives in the repository or only in build output is a team decision. Neither choice is clearly better.
| Concern | Commit generated code | Generate at build time |
|---|---|---|
| Reviewable diffs | Spec changes show their effect on code in the PR | Reviewers see only the spec change |
| Build simplicity | Builds need no generator | Every build, including local ones, runs the generator |
| Stale copies | Possible if someone edits the spec and skips regeneration | Not possible |
| Merge conflicts | Frequent in large generated files | None in generated output |
| IDE setup | Works on checkout | Needs a generate step before navigation and autocompletion work |
Both approaches need the same CI guard, a clean-tree check. CI regenerates from the spec, then runs git diff --exit-code and fails on any change. In the committed model, this catches stale copies and hand edits. In the build-time model, it catches generator output that leaked into tracked files and nondeterministic output, such as timestamps.
Catching contract drift
Drift can go either way: code can drift from the spec, or the spec can drift from what clients expect.
- Shape drift: Generated interfaces turn a renamed field or a changed type into a compile failure.
- Behavioral drift: The compiler does not check status codes, error bodies, or response content. Contract tests that validate real responses against the spec schema catch handlers that return 200 where the spec says 201.
- Breaking changes: A diff of the spec against
mainblocks removed fields, narrowed enums, and new required parameters.
Breaking-change detection is heuristic. For example, adding an enum value can break strict clients, and the diff tool may not flag it. Write down an explicit compatibility policy and a documented override path, such as an approved label that records who accepted the break.
#!/usr/bin/env bash
# CI clean-tree check: regenerate from the spec, then fail if the working tree changed.
# This works whether generated code is committed or only produced at build time.
set -euo pipefail
# Run the same generation entry point developers use locally, so CI and
# laptops cannot disagree. 'generate' is a placeholder Makefile target that
# wraps your generator and pinned generator version.
make generate
# Modified tracked files mean one of these happened:
# - Committed model: stale copies or hand edits to generated code.
# - Build-time model: output leaked into tracked files.
# - Either model: nondeterministic output, such as timestamps.
if ! git diff --exit-code --stat; then
echo "ERROR: regeneration changed tracked files." >&2
echo "Run 'make generate' locally and commit the result." >&2
exit 1
fi
# git diff ignores untracked files. A new schema can produce new files,
# so check for untracked files separately. Ignored build output is excluded.
untracked="$(git ls-files --others --exclude-standard)"
if [ -n "$untracked" ]; then
echo "ERROR: regeneration created untracked files:" >&2
echo "$untracked" >&2
exit 1
fi
echo "Generated code matches the spec."
A CI script that regenerates code from the OpenAPI spec and fails if any tracked file changes or any new untracked file appears, catching stale, hand-edited, or nondeterministic generated output.
Publishing generated clients
Once the server stubs are pinned to the spec, the client side should follow the same rule: consumers install a versioned package from your registry and never copy generated sources into their own repositories. A copied client is a fork. It stops receiving fixes, and it slowly diverges from the contract with nobody noticing.
Deriving the version
Choose a single source of truth for the version. The two common options are info.version in the spec and the release tag on the repository.
info.versionkeeps the version next to the contract. That makes it visible in code review, and the drift check from the previous section can require a major bump whenever it detects a breaking change.- The release tag suits teams that already cut releases from tags.
If you use the tag, add a CI step that fails when the tag and info.version disagree. Two version numbers that can drift apart are just another form of contract drift.
The publish job
Run publishing only on tags or on merges to the main branch, never on pull requests. A typical job does four things in order:
- It checks out the tagged commit.
- It regenerates the client with the same pinned generator image used elsewhere in the pipeline.
- It writes the derived version into the package manifest.
- It builds the package and publishes it with a registry token that is scoped to that one package.
What this saves
Nobody maintains a hand-written SDK, and client types cannot fall out of step with the server because both come from the same spec. Consumers upgrade by changing a version number, and the version itself tells them whether the release contains a breaking change.
Trade-offs, cost, when to avoid it, and alternatives
None of this is free. Generated code is often verbose and unidiomatic, and quality varies widely by target language. Custom templates can fix style, but they become a fork you maintain across generator releases. Support for oneOf/anyOf, discriminators, nullable fields, and complex parameter serialization is inconsistent. The worst case is silent: the generator falls back to a loose type, and the mismatch only surfaces at runtime. Generator upgrades rewrite large swaths of output, so schedule them as standalone pull requests rather than mixing them into feature work. Stubs can also fight your framework by duplicating routing, bypassing its validation, or constructing handlers outside your dependency injection container.
The cost is up-front friction and ongoing tooling upkeep, paid back through SDKs you don't hand-write, smaller reviews, and fewer contract incidents.
Illustrative estimate (assumed numbers, not a benchmark): net hours per year = (client languages × hours saved per client) − (setup + upgrades × hours per upgrade). With 3 languages × 30 h saved, minus (40 h setup + 4 upgrades × 8 h), the result is 90 − 72 = 18 h. That is a thin margin. With a single client, the result is negative.
Skip this approach for small or exploratory APIs, where the spec churns faster than generation pays off, and for internal APIs with one consumer.
| Approach | Source of truth | Drift protection | Best for |
|---|---|---|---|
| Spec-first with generation | Spec | Strong, enforced at build | Many consumers or languages |
| Code-first, spec emitted from annotations | Code | Good if the emitted spec is diffed in CI | Single-framework teams |
| Hand-written clients | Code | Weak | One or two idiomatic SDKs |
| Spec plus contract tests, no generation | Spec | Strong, checked at test time | Teams that want idiomatic code without generators |
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 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.