Software Architecture · API Design
Versioning Strategies and What a Version Number Promises
How path, header, and media-type versioning differ in what they can express, why a version is a support commitment rather than a name, and when a new version beats a compatible change.
Two services expose the same underlying data. One puts the version in the path, /v2/invoices. The other keeps one path and negotiates a media type, application/vnd.acme.invoice.v2+json. The first team says versioning is easy: bump the segment. The second says the path is part of a resource’s identity and should not carry protocol metadata. Both are describing the same mechanism with different trade-offs, and neither has said anything yet about the part that actually costs money, which is how long the old version keeps working.
A version number is a promise of parallel operation. Publishing /v2 commits the provider to running /v1 alongside it for as long as any caller still uses it. That commitment, not the syntax of the identifier, is what makes versioning expensive.
Three mechanisms, three kinds of expressiveness
Path segments. /v1/orders is visible in logs, trivially routable, and impossible to miss in a browser. It also makes every version a distinct URI, so caches and intermediaries treat the versions as unrelated resources, which is usually what is wanted. The cost is that the version sits inside the identifier space, so a client cannot express “the same order, represented under a newer contract”; the two are simply different resources.
Custom request headers. X-API-Version: 2 keeps the URI stable but sits outside HTTP’s content negotiation machinery. A cache that does not know about the header will serve one version’s response for another version’s request, and Vary handling depends on every intermediary being told about the header. The convention is widely used and consistently fragile at the edges.
Media types. Accept: application/vnd.acme.v2+json uses the mechanism HTTP already defines for selecting a representation. RFC 9110 specifies how Accept is parsed, how quality values rank candidates, and how 406 Not Acceptable reports that nothing suitable exists. A version expressed this way composes with the rest of the protocol: a proxy that honours Vary: Accept behaves correctly, and the version is genuinely a property of the representation rather than of the resource.
Media-type versioning is the most semantically precise and the least friendly to humans. It is invisible in a URL, awkward in shell commands, and often unsupported by client tooling produced from a schema. Fielding’s dissertation supplies the underlying argument: a URI identifies a resource, and its representational variants are selected by negotiation. That argument is coherent and rarely decisive in practice.
What the number is counting
Semantic Versioning applies to software artifacts with a declared public API, and its three components describe compatibility relative to the previous release: a major bump for incompatible changes, a minor bump for backward-compatible additions, and a patch bump for backward-compatible fixes. Applying it to a network API requires translation, because the “artifact” is a live service and the consumer did not install it.
A common convention is a single integer for the major number and nothing else. v1, v2. The minor and patch components are dropped because the service is not distributed to consumers, so there is no installed version to correlate; every caller always sees the current build of the version they address. This is the important asymmetry. A library consumer can pin 1.4.2 and be sure of its behaviour. An API consumer who addresses v1 gets whatever v1 means today, which means the provider has taken on the obligation to keep all v1 callers working with continuous deployment.
The stricter alternative is date-based versions or media-type versions with a full three-part identifier, and both exist. What matters is that the identifier’s granularity matches the compatibility policy. If v1 is a moving target that only ever changes compatibly, then a two-part identifier communicates a distinction the provider does not intend to honour.
Additive changes that are not safe
The premise of long-lived major versions is that additions are safe and modifications are not. That premise holds only for callers who are tolerant readers, and it fails in several specific ways.
Adding a field to an object is generally safe. Adding a field to an enum is not, for any client that switches exhaustively over the known values and crashes or falls through on an unknown one; the migration to a tolerated representation is the subject of tolerant readers and unknown fields. Making an optional request field required is breaking even though the schema change looks small. Changing a default value for an omitted field is breaking, because callers that relied on the default never sent it. Adding a new status code to an existing endpoint is breaking for clients that switch on codes rather than classes. Tightening validation, for instance rejecting a value that used to be accepted, is breaking regardless of what the documentation said the valid range was.
The category that is easiest to miss is behaviour: the same request, unchanged, now means something different because a computation changed, a sort order was adjusted, or a limit was tightened. No schema diff sees it. Comparing API descriptions is discussed in breaking change detection, and the gap between schema-level and behaviour-level change is why a description diff cannot certify compatibility.
The support window is the real policy
Every published version is a branch. It has its own tests, its own bugs, and its own dependency upgrades. The cost is not in creating it but in carrying it.
A defensible policy states three things: how long a version is supported after its successor ships, what happens at the end of that window, and how much notice precedes removal. The number itself matters less than its being published and enforced. Common windows are expressed in months from the successor’s general availability, with the deprecation mechanics described in deprecation windows and removal gates.
The policy has to account for the slowest consumer class. Server-to-server integrations controlled by another engineering team can be migrated in a quarter. Mobile applications live on devices that are updated at the owner’s discretion, and a client written against v1 may execute for years after the store listing shows v3. Desktop software installed on machines that are never patched extends the tail further. An API with a mobile client cannot use the same window as an internal RPC surface, and an API with both is governed by the slower one.
The compatibility matrix nobody writes down
Beyond the version identifier, an API can also have implicit per-consumer contracts: field-level guarantees that one integration depends on which no other consumer touches. The matrix of consumer to relied-upon behaviour is rarely documented, and it determines what can actually be removed.
Two techniques make the matrix observable. The first is per-consumer instrumentation: recording which fields each credential actually reads, not merely which endpoints it calls. A field never read by anyone is a candidate for removal regardless of its age. The second is contract tests owned by consumers, which turn an implicit dependency into an explicit, executable assertion that fails when the provider changes; the practice is covered in consumer-driven contract testing.
Neither is free. Per-field telemetry multiplies the cardinality of metrics, and consumer-owned tests require coordination across organisational boundaries that an external API may not have.
When a new version is the cheaper option
A new major version is the right move when the resource model itself is wrong, when several incompatible changes would have to be sequenced through a long deprecation cycle, or when the security posture of the old interface cannot be repaired in place. It is the wrong move when the change is a rename, a field addition, or a new optional parameter, because each version multiplies the test surface and the support window without improving the contract.
There is also an option between the two that is often overlooked: a new endpoint under the same version. Adding /v1/invoices/{id}/lines alongside a legacy /v1/invoice-lines?invoice= is compatible, requires no versioning machinery, and leaves the old shape to be retired on its own schedule. The version number is a coarse instrument; the URI space is finer-grained and does not carry the same support commitment.
The final constraint is honesty about what the number encodes. A version that changes incompatibly without a bump teaches every consumer to treat all versions as unstable, at which point clients pin to builds, add their own validation, and the version scheme has stopped doing any work at all.
References
- RFC 9110: HTTP Semantics — IETF
- Semantic Versioning 2.0.0 — Tom Preston-Werner
- Architectural Styles and the Design of Network-based Software Architectures — Roy Fielding, doctoral dissertation, UC Irvine, 2000