Stage 7 · Master
API Versioning & Contracts
Versioning & Backward-Compatible Changes
Fieldwork versions at the gateway, then treats additive schema evolution as a discipline instead of a surprise.
Why Fieldwork Versioned at the Edge
Fieldwork has multiple Go services behind one public API surface: api-gateway receives client traffic, then fans out to auth-service, tasks-service, and the rest of the workspace stack. That architecture made one versioning decision easy. We versioned at the gateway path boundary with /v1, not independently inside every backend service. Clients integrate with the gateway contract, not with the internal hop between gateway and tasks-service. If we had let every service publish its own public version independently, the cost would not have been flexibility. The cost would have been a compatibility matrix: gateway v2 calling tasks-service v1, mobile clients still pinned to old response shapes, and no single place to say what the public API even is.
We rejected header-based versioning for the same reason. In a greenfield API it looks elegant: keep URLs stable, negotiate versions through Accept or a custom header, and pretend the resource stays pure. In practice it made Fieldwork harder to reason about. CDN caches and request logs became less legible, support engineers could not see a version from the path alone, and reproducing a client bug required remembering hidden headers. A boring URL prefix won because operations matter more than theoretical elegance when the system has to be debugged at 2am.
| Decision | Why it looked attractive | Why Fieldwork rejected or kept it |
|---|---|---|
| Path versioning at /v1 | Obvious in logs, curl commands, ingress rules, and cache keys | Kept. The public contract is visible everywhere the request travels. |
| Header or media-type versioning | Cleaner URLs and finer-grained negotiation | Rejected. Hidden state in headers made observability and incident response worse. |
| Per-service public versions | Lets teams change services independently | Rejected. Clients do not talk to tasks-service directly, so it would only create drift behind the gateway. |
tasks-service can change its SQL, package layout, or gRPC method names without creating v2. A public version exists to protect clients of the gateway contract. Treating every internal refactor as a versioning event would train the team to fear useful cleanup.
The Changes That Did Not Need v2
Most changes Fieldwork made after launching v1 were additive, and additive changes are exactly why you should define compatibility rules early. We added task.priority after initial launch. We later added archived_at to workspace responses so clients could distinguish hidden workspaces from deleted ones. Neither change required v2 because existing clients could keep decoding the old subset of fields and keep sending the same request payloads they sent before. Compatibility is not about whether the response is identical. It is about whether old clients still function correctly without being recompiled and redeployed that day.
That principle also covered optional request fields. When recurring tasks were introduced, create-task accepted recurrence as an optional object. Old clients omitted it and still created one-off tasks. New clients used it when they were ready. The practical test we used was simple: if an old request body still validates, and an old client can safely ignore the newly returned field, the change is backward-compatible. If either side breaks, it is not.
- Adding a new optional response field, such as task.priority or workspace.archived_at.
- Adding a new optional request field with server-side defaults, such as recurrence or task.description.
- Adding a new endpoint under /v1 without changing existing ones, such as /v1/workspaces/{workspaceID}/memberships.
- Broadening an enum only when unknown values are documented as possible and clients are expected to fall back safely.
- Making a nullable field less frequently null because more data is now populated server-side.
Adding a new task status like blocked is theoretically additive, but only if downstream code treats unknown values as data instead of impossible states. If a mobile client switches over exact strings and crashes on the default branch, the server-side change was not harmless in reality.
The Changes That Do Force a Version
The easiest way to create accidental breaking changes is to treat schema cleanliness as more important than compatibility. We considered renaming workspace_id to space_id in task payloads once the product language shifted internally from workspaces to spaces. We did not do it in v1. Renaming a field is not a refactor from the client's perspective; it is a removal plus an addition. The same rule applies to turning an optional field into a required one, changing a field's type from string to object, or changing endpoint semantics while keeping the same path. Those are version-boundary changes, no matter how justified the new design looks inside the repo.
| Change | Why it breaks old clients | Fieldwork response |
|---|---|---|
| Rename workspace_id to space_id | Existing decoders stop finding the field they rely on | Keep workspace_id in v1; only rename in a future v2 |
| Make assignee_id required on create | Old clients send bodies that now fail validation | Keep it optional and enforce assignment through workflow rules elsewhere |
| Change due_at from RFC3339 string to nested object | Type mismatch breaks generated clients and JSON decoding | Add a new field instead; deprecate later |
| Reuse GET /tasks but change filtering defaults | Same request suddenly means something else | Introduce explicit query params and leave old semantics alone |
There is another category that teams underestimate: behavioral breaks. Suppose /v1/workspaces/{workspaceID}/tasks originally returned all visible tasks and later started excluding archived tasks by default. The JSON schema could stay byte-for-byte identical and the endpoint would still be breaking because clients built reports and dashboards on the old semantics. Versioning is about observable behavior, not only field names. That is why every compatibility review in Fieldwork included both payload examples and a sentence describing what the handler promised to mean.
Marking a field deprecated in docs is useful, but it does not make client dependencies disappear. We only remove deprecated v1 fields when we have telemetry showing they are unused and a replacement exists in the next versioned contract.
How We Policed Compatibility
Good intentions were not enough, so we made compatibility review explicit at the gateway boundary. The OpenAPI spec for v1 lived beside handler code, example fixtures were kept for key endpoints, and pull requests that touched request or response types had to answer one question: can an unchanged v1 client still send what it used to send, and understand the subset of fields it already used? That phrasing mattered. It kept the conversation on runtime behavior instead of devolving into aesthetic arguments about whether the new shape felt cleaner.
name: api-contract-check
on:
pull_request:
paths:
- services/api-gateway/**
- specs/openapi/v1.json
jobs:
contract:
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
- run: go test ./services/api-gateway/internal/contract/...The generated contract had to be committed intentionally, and contract tests guarded representative payloads. That did not prevent all mistakes, but it made silent breaking changes much harder to merge casually.
What We Actually Shipped
Fieldwork shipped v1 as a path-versioned gateway API and treated backward compatibility as the default operating mode. We allowed additive fields, additive endpoints, and optional request expansion inside v1. We did not rename, repurpose, or hard-require existing fields just because the internal model evolved. That sounds conservative because it is. Conservative contracts keep product velocity high: backend teams can add capability without forcing every client to stop and adapt in lockstep.
The deeper lesson is that versioning discipline starts before v2 exists. If every mildly inconvenient field becomes an excuse to break clients, version numbers become an emotional crutch instead of an engineering tool. Fieldwork's rule was narrower and more useful: version only when old clients would fail or observe meaningfully different behavior. Everything else belongs to careful, additive evolution inside the same contract.
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.