Software Engineering
API Versioning Without Breaking Your Customers
A version number does not create compatibility. Customers remain safe only when behaviour evolves deliberately, old and new contracts overlap, and deprecation is managed with evidence rather than hope.
Most breaking changes do not look like versioning changes
Teams usually discuss API versioning when they want to rename a property or redesign an endpoint. Customers experience breakage more broadly. A new enum value reaches a generated client with an exhaustive switch. Results arrive in a different order. A previously optional field becomes required. Validation is tightened. A timeout becomes a 400 response. None of these changes requires a new route, yet each can stop a working integration.
Compatibility is observable behaviour from the consumer's perspective. It includes representation shape, defaults, ordering, status codes, error codes, idempotency, rate limits, authentication and timing assumptions. A provider can maintain identical OpenAPI schemas while making an incompatible behavioural release.
Versioning is therefore a containment mechanism for intentional incompatibility, not a substitute for compatibility discipline. If every release creates a new version, the provider multiplies support cost. If no release ever creates one, breaking changes are smuggled into an apparently stable contract.
Classify the change before choosing a version
| Change | Often safe when | Hidden risk |
|---|---|---|
| Add an optional response property | Readers ignore unknown fields | Strict deserialisers reject it |
| Add an enum member | Clients preserve an unknown fallback | Generated exhaustive switches fail |
| Add an optional request property | Its absence preserves old behaviour | A new default changes the operation |
| Tighten validation | Previously accepted inputs were never used | Real clients rely on tolerated values |
| Change collection ordering | Ordering was explicitly unspecified | Clients display or paginate by old order |
| Improve an error message | Clients use stable codes | Clients parse message text |
| Remove or rename a field | No supported consumer reads it | Usage cannot be inferred from server logs |
| Change command semantics | A new contract is selected explicitly | Same payload produces a different business outcome |
“Additive” describes syntax, not safety. The practical test is whether every supported old client can continue making the same decisions. When that cannot be established, isolate the change or release a versioned behaviour.
Tolerant readers are a two-sided contract
Providers often assume adding a response property is safe because JSON allows unknown members. Some generated models, validation layers and hand-written deserialisers reject unknown data. Establish tolerant-reader behaviour in client guidance and SDKs rather than discovering strictness during a release.
Providers need corresponding tolerance. New request fields should be optional until old clients have moved, and absence must preserve the previous behaviour. Do not distinguish old and new clients by guessing from which fields happen to be present if the distinction affects important business semantics; use an explicit version or capability.
Enums are particularly deceptive. Adding a value feels additive but can break code generated into a closed language enum or a switch without a default case. For externally evolving taxonomies, document unknown-value handling, consider an extensible string representation, and make SDKs preserve rather than discard values they do not understand.
Nullability has three states in many update contracts: absent, explicitly null and present with a value. Collapsing them can turn an additive field into accidental data deletion. Test wire behaviour rather than relying on C# nullable annotations alone.
Version at the smallest boundary that truly diverges
When only one operation needs incompatible behaviour, versioning the entire API duplicates far more than the changed contract. Keep shared application behaviour behind adapters for each public version. Version-specific DTOs and mapping code are cheaper than parallel copies of controllers, services and persistence logic that drift independently.
Do not force the new model back into a lowest-common-denominator domain. Sometimes a new contract represents a genuine product change and needs new application behaviour. The seam should make divergence visible without making every internal type carry V1 and V2.
A version is usually major compatibility, not deployment identity. Build numbers and dates belong in diagnostics; customers should not select a new API contract every time the provider deploys. Calendar versions can work when they represent clear support cohorts, but a date alone does not explain compatibility.
URL, header and media-type versions solve different usability problems
URI path versions such as /v2/orders are visible, easy to route, easy to test in a browser and clear in logs. They imply that the version is part of resource identity and can encourage teams to clone the whole surface. For public APIs, that operational clarity often outweighs theoretical objections.
A custom request header keeps resource URIs stable but makes version choice less visible to people, caches and simple tooling. Query-string versions are similarly easy to implement but can be omitted accidentally and must be included correctly in cache keys.
Media-type versioning expresses representation negotiation through Accept, which is conceptually precise when representations diverge. In practice it is harder for consumers to discover and for support teams to reproduce. If the organisation cannot explain or observe it reliably, elegance on a standards diagram is not an advantage.
Choose one primary mechanism and enforce it consistently. Avoid accepting several readers indefinitely unless the operational benefit justifies ambiguous requests. A missing version needs an explicit policy: reject it, or assign a documented default. Silently defaulting to “latest” turns a provider deployment into an unrequested client upgrade.
A version number does not solve database evolution
Old and new API contracts often share one underlying model. Renaming a public field does not require renaming the database column on the same day. Map both representations during the overlap. Expand storage first, write compatible data, migrate existing records, move readers, and contract only after every supported path has changed.
Defaults are a common trap. Suppose v2 adds an explicit fulfilment strategy while v1 knows nothing about it. The server needs a stable v1 default that remains valid even as v2 gains more options. Deriving the default from current global configuration can change old-client behaviour without changing v1.
Events and queued commands extend the compatibility window. A message produced before deployment may be consumed afterward. Version or make message schemas tolerant independently of HTTP, and retain handlers until old messages can no longer exist. API retirement is not complete while its asynchronous effects still require legacy interpretation.
Contract tests must cover behaviour, not just schema
OpenAPI diffing is valuable for removed operations, changed types and required properties. It cannot prove that ordering, validation, defaults, permission rules or idempotency stayed compatible. Add executable compatibility cases for behaviours customers depend on.
Consumer-driven contracts help when a known set of consumers can publish their expectations. They are not proof that an open ecosystem is safe: the provider sees only participating clients. Maintain representative old SDKs and requests in an acceptance suite, and run them against the new implementation.
Test both directions during migration. Old clients must work with new servers, and new clients may briefly reach old instances during a rolling deployment. A green test of v2 against one newly built server does not establish fleet compatibility.
Capture real request shapes with privacy-conscious telemetry, but do not assume a field is unused because the server cannot observe reads from its response. Removing a response property requires consumer evidence, explicit communication or a new version.
Deprecation starts a managed period; sunset ends it
Deprecation means the contract still works but is no longer recommended. Sunset is the planned point at which it stops being served. Treating them as the same date gives customers no migration window.
The standard Deprecation response header can signal that the requested resource is deprecated, with a Link relation pointing to migration documentation. The Sunset header communicates when service is scheduled to cease. Headers help instrumented clients and tooling, but they do not replace direct communication; many integrations never surface response warnings to a person.
A credible deprecation notice includes the replacement, behavioural differences, code examples, SDK availability, test environment, start date, earliest shutdown date and support route. Publish it before turning on warnings so the link leads somewhere useful.
After retirement, decide whether requests return a clear 410-style response for a period or disappear through routing. A deliberate terminal response can point stragglers to migration guidance, but it must not become an unofficial permanent implementation of the retired version.
Usage evidence needs consumer identity
Request counts alone cannot tell you whether retirement is safe. Ten requests may be a critical month-end process; ten million may be synthetic monitoring. Measure active consumer organisations, credentials or application registrations by operation and version, while respecting privacy and avoiding secrets in telemetry.
Contact named owners rather than sending only a broadcast email. Large customers may need procurement, testing and scheduled releases. Internal consumers deserve the same treatment: an undocumented spreadsheet macro is still a dependency, even if it belongs to the same company.
Watch migration quality, not just declining v1 traffic. A customer may try v2, encounter errors and silently fall back. Track v2 success by migrating consumer, deprecated error codes, support cases and use of compatibility shims.
Parallel versions create compounding operational cost
Every live version expands security review, documentation, SDK generation, test combinations, monitoring and incident response. A vulnerability may require patches across contracts whose code has drifted. This cost should influence how readily versions are created and how seriously retirement dates are managed.
Keep one core implementation where semantics are shared, then adapt requests and responses at version boundaries. Record metrics with the selected version and operation. During incidents, operators must be able to answer whether only one contract is affected and whether routing or content negotiation selected the wrong one.
Feature flags are not API versions. They can stage provider behaviour, but a flag that changes a contract for arbitrary customers creates undocumented cohorts. If consumers must build against both behaviours, expose an explicit capability or version and give it a support policy.
A migration sequence that respects customers
- Prove a version is necessary. Identify the incompatible behaviour and why an additive or internal change cannot solve it.
- Define compatibility. Document shapes, defaults, errors, ordering, security and retry behaviour for old and new contracts.
- Build the overlap. Support both through explicit boundary adapters and compatible storage changes.
- Test both directions. Run old clients against new servers and account for mixed server versions during deployment.
- Release migration tooling. Provide documentation, examples, SDKs and a realistic test environment before deprecation.
- Signal and contact. Emit standard deprecation and sunset metadata and contact identifiable consumer owners directly.
- Measure completed migration. Track consumer-level success rather than aggregate request decline.
- Retire deliberately. Remove the contract, its code, documentation, alerts, credentials and compatibility data after the agreed evidence is met.
Do not announce a fixed shutdown date the organisation is unwilling to enforce. Repeatedly extending it teaches customers that future dates are fictional. If commercial reality requires an exception, make its cost and ownership explicit rather than leaving the old version generally available.
The production compatibility checklist
- Compatibility includes behaviour, defaults, errors and ordering-not only schemas.
- Unknown response fields and enum values have documented client behaviour.
- Missing new request fields preserve the old contract.
- The smallest genuinely incompatible boundary is versioned.
- A missing version never silently means “latest.”
- Database and message changes tolerate the full overlap window.
- Schema diffing is supplemented by behavioural and old-client tests.
- Deprecation documentation and replacement tooling exist before warnings begin.
- Deprecation and sunset are signalled separately.
- Usage can be attributed to consumer owners and business importance.
- Retirement removes operational and security obligations, not only routes.
The objective is not zero change. It is controlled change in which customers decide when to adopt a new contract within a credible support window, and the provider can prove when the old one is safe to remove.
Related C3 Software guides
Frequently asked questions
Is adding a response field always backwards compatible?
No. It is usually safe only when supported clients tolerate unknown fields. Strict deserialisers and generated models can make an additive provider change breaking.
Should API versions be placed in the URL?
URL versions are often the clearest public choice for routing, logs and support. Headers and media types can be appropriate, but consistency and operational visibility matter more than theoretical purity.
What is the difference between deprecation and sunset?
A deprecated API still operates but is no longer recommended. Sunset communicates when it is scheduled to stop operating. Customers need a meaningful interval between the two.
How do you know an old API version can be removed?
Use consumer-level traffic, named ownership, support evidence and successful replacement usage. Aggregate request counts alone do not reveal business criticality or response-field dependencies.
Evolve APIs without transferring risk to customers
C3 Software Limited has built and supported business software in the UK since 2001. If an API contract must change across independently deployed consumers, talk to C3 Software about a versioning review or migration plan.