Stage 7 · Master
API Versioning & Contracts
Documenting Contracts with OpenAPI
Fieldwork generates its spec from the same typed handlers that serve traffic, so docs drift only when code drifts.
Why We Stopped Writing Specs by Hand
The first draft of Fieldwork's API documentation lived in a separate YAML file. It looked disciplined because it was explicit, reviewable, and detached from framework details. It also drifted almost immediately. A handler in api-gateway would gain a new optional query parameter for status filtering, the Go request type would update, tests would pass, and nobody would remember to fix the spec in the same pull request. The problem was not laziness. The problem was that we had created two sources of truth for the same contract: one used by the compiler, and one used by humans trying to remember to keep up.
We replaced that with a handler-first approach. The typed request and response structs already existed because they were needed to decode JSON and write responses. The route registration already existed because the gateway had to serve traffic. So those same definitions became the source for OpenAPI generation. That was the actual decision: not "use OpenAPI," but "make the public contract fall out of executable code paths." Once the spec is a build artifact of real handlers, drift becomes visible in code review because changing behavior necessarily changes the generated output.
| Approach | Advantage | Concrete cost in Fieldwork |
|---|---|---|
| Hand-authored openapi.yaml | Maximum editorial control over the document | Drifted from real handlers because the compiler never forced updates. |
| Runtime-only generated docs with no committed artifact | Always reflects the running binary | Harder to diff in pull requests and harder to publish for consumers before deployment. |
| Generated spec from typed handlers, committed in repo | Reviewable contract diffs tied directly to code changes | Chosen. Slightly more ceremony in handler definitions, but no silent divergence. |
If an engineer can change request validation or response fields without touching the published contract, the contract is not part of the build. Fieldwork's fix was to make OpenAPI generation something CI could verify rather than something a teammate had to remember.
The Handler-First Approach
In api-gateway we chose Huma for public HTTP operations because it made the contract explicit in the same place the route was registered. Each operation declared its method, path, tags, summaries, path parameters, query parameters, and response shape through real Go types. That mattered more than framework fashion. We did not need the most fashionable router; we needed a place where changing the TaskListResponse type or adding an include_archived query flag would automatically surface in the generated schema. The framework was chosen for that property.
Notice what this avoided. We did not copy field descriptions into a separate docs package. We did not duplicate path params in a YAML document. We put the explanatory text close to the handler because the engineer changing business behavior is the one most able to keep the contract honest. The docs were still human-readable, but their structure came from executable registration code rather than from parallel prose maintenance.
How the Spec Was Published
Generation alone was not enough. We also wanted the spec to be reviewable before deployment and easy for consumers to fetch by version. So the build produced versioned artifacts such as specs/openapi/v1.json and specs/openapi/v1.yaml, committed them in the repository, and served the same files from the gateway at /docs/openapi/v1.json. That gave us two useful properties at once: pull requests showed contract diffs as normal file diffs, and consumers never had to guess which spec matched which path version.
We deliberately did not generate the spec only at startup and leave it hidden inside the running container. That would have been technically accurate but operationally weak. Product engineers, SDK generation jobs, and reviewers need access to the contract before something is merged, not only after it is already live. Committing the generated artifact felt redundant at first, but it made API review a normal part of code review instead of a deployment-time surprise.
name: publish-openapi
on:
push:
branches: [main]
paths:
- services/api-gateway/**
- .github/workflows/publish-openapi.yml
jobs:
openapi:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.work
- run: go generate ./services/api-gateway/...
- run: git diff --exit-code specs/openapi/v1.json specs/openapi/v1.yaml
- uses: actions/upload-artifact@v4
with:
name: api-contracts
path: specs/openapi/
- run: |
cp specs/openapi/v1.json services/api-gateway/static/docs/openapi/v1.json
cp specs/openapi/v1.yaml services/api-gateway/static/docs/openapi/v1.yamlGeneration happened before deployment, drift failed the workflow, and the published static files matched the exact artifact reviewed in the pull request.
Some teams prefer not to commit generated files. That is reasonable for assets nobody reviews. Fieldwork kept OpenAPI artifacts in git because contract changes are product changes. A rendered diff is part of the review, not noise.
What OpenAPI Could and Could Not Prove
OpenAPI is excellent at describing shape: paths, methods, parameters, schemas, status codes, and examples. It is not enough to prove semantics. A spec can tell you that GET /v1/workspaces/{workspaceID}/tasks accepts status=done. It cannot tell you whether archived tasks are included by default, whether tenant scoping is enforced correctly, or whether pagination remains stable under concurrent writes. We used the spec as a contract for shape, then layered integration tests and compatibility review on top for meaning.
That distinction mattered during review. Engineers occasionally wanted to encode every behavior note into schema descriptions. Some of that belongs there, but not everything. If a rule was critical to runtime correctness, we preferred executable tests. For example, tenant isolation for workspace membership lookups was verified through contract tests that seeded two tenants and asserted 404 versus 200 behavior. The generated spec documented the endpoint; the tests proved the system kept its promise.
- Use OpenAPI to lock down path shapes, required fields, status codes, and examples.
- Use request/response fixtures to review representative payload changes over time.
- Use integration tests for tenant isolation, authorization, pagination stability, and behavioral guarantees.
- Use SDK generation only from the committed, versioned spec artifact, never from ad hoc local output.
You can perfectly document an endpoint that leaks cross-tenant task counts or silently changes filtering defaults. Schema generation reduces drift; it does not replace judgment about behavior.
What We Actually Shipped
Fieldwork shipped handler-first OpenAPI generation from the public gateway. Real Go types and real route registrations produced the versioned contract artifacts. CI regenerated the spec, failed on drift, and published the same files consumers used for SDK generation and debugging. That gave us a documentation workflow with one source of truth: the code that actually handled the request.
The rejected alternative was not OpenAPI itself. It was documentation as a sidecar project. Once docs stop being a build artifact, they become a best-effort narrative, and best effort is how contracts rot. Fieldwork's rule was simpler: if a handler changes, the generated spec changes; if the spec changes, reviewers see it; if reviewers see it, compatibility becomes a deliberate decision instead of an accidental diff in production.
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.