Auth & the edge · No. 44
HTTP API versioning, proven not just claimed

Most APIs "support versioning" by shipping v1.0 and never a second version. Here is a header-based setup that introduces versioning without breaking a single caller, and a fitness contract that proves two live versions coexist instead of asserting they could.
Here is a claim that sounds responsible and is almost always untestable:
"Our API supports versioning."
Open the code and you find Asp.Versioning wired up, a default of 1.0, and exactly one version
ever shipped. The machinery is configured. Whether it actually works past a single version has never
been exercised, because there is no second version to exercise it against. The first time someone
needs to evolve a response shape, they discover the reader was set to a URL segment nobody's client
builder expected, or that the default-version behavior quietly breaks every caller that never sent a
version, or that deprecation was never reported anywhere a client could see it.
A versioning story you cannot demonstrate is a versioning story that erodes silently. It reads as done in a design doc and fails in the first real evolution.
There is a second trap next to the first. If you reach for versioning by forking the route (/v1/...
and /v2/...), every URL, every gateway route map, every client URL builder, and every OpenAPI path
now has a version baked into it. The version is not a property of the request. It is a property of the
string, copied everywhere, and it forks the whole surface the moment you add a second one.
Why it matters
HTTP APIs need a versioning axis of their own: one the caller selects per request, that the service can advertise, and that it can deprecate over time. In a framework whose thesis is "monolith now, services later, no rewrite," that axis carries extra weight. A module served in-process today becomes a service behind a YARP gateway tomorrow, and as it evolves a response shape has to be able to change without breaking a client still coded against the old shape (ADR-046).
This is a different concern from how asynchronous integration events evolve on the wire. That axis
(ADR-010) is resolved by consumers from a SchemaVersion carried inside the serialized event, and it
is never chosen by a caller. The HTTP axis is the opposite: request-time, client-selected, and
reported back in response headers. Conflating the two is how teams end up with one mechanism doing
neither job well.
And without a shared decision, every host wires it differently: URL segment here, query string there, a different default-version behavior in each service, inconsistent deprecation reporting. The reader, the default, and the reporting choices drift apart between services that are supposed to look like one API behind a gateway.
The MMCA answer: one registration, one exemplar, one fitness contract
MMCA.Common standardizes a single header-based setup in MMCA.Common.API, adopts it in every service
host through one call, and keeps it exercised by a shared contract test that proves two live versions
coexist.
One registration wires the whole policy. AddCommonApiVersioning
(WebApplicationBuilderExtensions.cs:334) is the entire decision, in one place: the reader, the
default behavior, the reporting, and the one guard that keeps document generation from tripping over
any of it.
public IServiceCollection AddCommonApiVersioning()
{
// DefaultApiVersion is deliberately not set: 1.0 is already the framework default, and the API
// explorer inherits both it and the assume-default flag (restating either one trips AV0011/AV0024).
services.AddApiVersioning(options =>
{
options.AssumeDefaultVersionWhenUnspecified = true; // no header -> 1.0
options.ReportApiVersions = true; // advertise supported/deprecated
options.ApiVersionReader = new HeaderApiVersionReader("api-version");
}).AddMvc()
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV"; // feeds the versioned OpenAPI group
options.SubstituteApiVersionInUrl = true;
});
services.AddApiParameterDescriptorBackfill(); // guards OpenAPI generation
return services;
}
Three of those lines carry the design. The reader is a HeaderApiVersionReader("api-version")
(WebApplicationBuilderExtensions.cs:343), so routes and query strings stay version-free: a caller
opts into a newer shape by adding one header, not by rewriting the URL. AssumeDefaultVersionWhenUnspecified
(WebApplicationBuilderExtensions.cs:341) means a client that never sends the header keeps getting
1.0, so introducing versioning was not a breaking change for any existing caller. And
ReportApiVersions (WebApplicationBuilderExtensions.cs:342) means every response carries the
supported and deprecated version lists in its headers, so a client can see which versions a service
still honors and which are on the way out without reading a changelog.
Two more details in that block carry less design and more hard-won experience, and the first of them is
a line that is not there. 1.0 is the default because the Asp.Versioning library already defaults to
it, so the registration says nothing at all: setting DefaultApiVersion explicitly, in either the
versioning options or the explorer options that inherit them, trips the library's own analyzers
(AV0011/AV0024), and the comment standing in its place (WebApplicationBuilderExtensions.cs:336-338)
exists so the next reader does not "fix" the omission. And AddApiParameterDescriptorBackfill
(WebApplicationBuilderExtensions.cs:351) installs ApiParameterDescriptorBackfillProvider, added in
v1.146.0 after a real failure: MVC leaves ApiParameterDescription.ParameterDescriptor null for a route
token with no matching action parameter, and Asp.Versioning.OpenApi dereferences it without a null
check, so a host that routes api/v{version:apiVersion}/... returned a 500 from
GET /openapi/{documentName}.json. The guard fills a placeholder only where one is missing and never
replaces a descriptor MVC supplied (ApiParameterDescriptorBackfillProvider.cs:65). Because this
framework's reader is the header, no current host was hit either way, which is exactly why that failure
could ship unnoticed (ADR-046).
A shipped exemplar proves two versions coexist. This is the part most "we support versioning"
stories skip. ServiceInfoControllerBase (ServiceInfoControllerBase.cs:30) serves the same
/ServiceInfo route under two versions selected by the header. GetV1 is mapped to 1.0
([MapToApiVersion("1.0")], ServiceInfoControllerBase.cs:40) and returns the minimal
ServiceInfoResponse shape (ServiceInfoControllerBase.cs:51). GetV2 is mapped to 2.0
([MapToApiVersion("2.0")], ServiceInfoControllerBase.cs:46) and returns the evolved
ServiceInfoV2Response (ServiceInfoControllerBase.cs:54), a superset that also advertises the
supported and deprecated version lists in its body. Same route, two shapes, chosen by one header. The
base is read-only: both actions are [HttpGet] (ServiceInfoControllerBase.cs:39, :45) and it
declares no write verb. Anonymity is not carried by the base: each sealed subclass grants it with
[AllowAnonymous] (ADC's ServiceInfoController.cs:17, Store's :17).
The class-level version attributes live on the per-service subclass. Class-level routing and
versioning attributes are not reliably inherited, so each host supplies a sealed subclass that carries
them (the same inheritance caveat ADR-036 records for ADC's sealed OAuthController). ADC's
ServiceInfoController declares [ApiVersion("1.0", Deprecated = true)] and [ApiVersion("2.0")] and
sets the service name to "Conference"
(MMCA.ADC.Conference.API/Controllers/ServiceInfoController.cs:18, :19, :23); Store's mirror sets
"Catalog" (MMCA.Store.Catalog.API/Controllers/ServiceInfoController.cs:18, :23). The shared
behavior lives in the base; the attributes do not, so each host repeats them on a one-line subclass.
That 1.0 is declared deprecated on purpose, so the deprecation-reporting path is live rather than
theoretical.
A shared fitness contract keeps the machinery exercised. ServiceInfoVersioningContractTestsBase<TFixture>
(ServiceInfoVersioningContractTestsBase.cs:20) sends api-version: 1.0 and then 2.0 over the real
host. It asserts the v1.0 response returns the minimal shape and carries an api-deprecated-versions
header (ServiceInfoVersioningContractTestsBase.cs:39), and that the v2.0 response returns the evolved
shape and carries an api-supported-versions header (ServiceInfoVersioningContractTestsBase.cs:55).
Because the controller ships in MMCA.Common.API, the whole test body is identical across repos: each
consumer's subclass supplies only its fixture. It is one of the seven runtime conformance bases
MMCA.Common.Testing ships for a host to subclass (ADR-058), and today it is subclassed on one host
per repo: ADC's Conference service
(MMCA.ADC.Conference.IntegrationTests/Contract/ApiVersioningTests.cs:15) and Store's Catalog service
(MMCA.Store.Catalog.IntegrationTests/Contract/ApiVersioningTests.cs:16). This is the rubric §9 fitness
check, and the same invariant-over-discipline posture the framework prefers (ADR-015): without a second
working version, everything above would be asserted rather than proven.
Every REST host adopts it the same way. The extracted services call AddCommonApiVersioning in
their startup: ADC's Conference (MMCA.ADC.Conference.Service/Program.cs:203), Identity (:152),
Engagement (:148), and Notification (:140) hosts, Store's Catalog (:141), Sales (:148), and
Identity (:135) hosts, and the monolith reference host
(MMCA.Helpdesk.Web/Program.cs:35). One call per host, and the reader, default, and reporting choices
cannot drift apart between services. The discovery exemplar is narrower than the registration: only ADC's
Conference and Store's Catalog hosts ship a ServiceInfoController subclass today.
Trade-offs, honestly
ADR-046 is refreshingly candid about what is real today versus what the shape leaves room for. Four things are worth naming, and none of them is a bug.
- The class-level version attributes are not inherited. Each per-service subclass must repeat the
[ApiVersion(...)]and routing attributes. It is one line of duplication per host, and it is the same inheritance caveat ADR-036 already records for ADC's sealedOAuthController. The shared behavior lives in the base; the attributes are the deliberate exception. - Adoption is per host. A new REST host that forgets
AddCommonApiVersioninggets no versioning and no reported versions, and the shared fitness contract only guards a host once its subclass is added. This is the same audit-the-inventory caveat that every opt-in framework registration carries. - Only the discovery endpoint has evolved to 2.0. This is the honest one. Application controllers
beyond
ServiceInfodeclare only[ApiVersion("1.0")]today. The second version exists on the/ServiceInfodiscovery endpoint to keep the versioning path honest, not because any business resource has yet needed to evolve its shape. The point of the exemplar is that when a real resource does need a2.0, the machinery it plugs into has been proven to work, not merely configured. - Header versioning is less discoverable than a URL segment, and the OpenAPI documents are dev/CI
only. A version chosen by header does not show up in a copied URL or a browser address bar, so the
version in play is only visible to a caller that reads request and response headers. The generated
contract does follow the versioning axis:
MapCommonOpenApiappliesMapOpenApi().WithDocumentPerVersion()(Startup/Endpoints/OpenApiEndpointExtensions.cs:38), so the route resolves one document per discovered API version, named by the explorer'sGroupNameFormat(v1.0is thev1document). But the whole endpoint is a no-op in Production (Startup/Endpoints/OpenApiEndpointExtensions.cs:36), and the only document any host's contract test pins is/openapi/v1.json(Conformance/OpenApiContractTestsBase.cs:31), so the machine-readable surface is a development and CI artifact, not something a client can browse in production and not a place the2.0shape is currently asserted.
The trade the framework makes is clear: stable, version-free URLs and a non-breaking rollout, paid for with a version that lives in headers rather than the path, and a proof-of-life second version on the discovery endpoint rather than a business resource that has not needed one yet.
Apply this even without MMCA
The pattern ports to any HTTP stack that will have to evolve a response shape without a flag day:
- Pick one version reader and put it behind one registration. Header, query, or URL segment: pick one, wire it once, and adopt it from a single call so every host is versioned identically. A header reader keeps routes and URL builders version-free.
- Assume the default when the caller sends nothing.
AssumeDefaultVersionWhenUnspecified(or its equivalent) is what makes introducing versioning a non-event for existing callers. Without it, the day you turn versioning on is the day every un-versioned client breaks. - Report supported and deprecated versions on every response. Deprecation a client cannot see from headers is deprecation nobody acts on. Turn on version reporting and deprecate a real version so the reporting path is live, not theoretical.
- Ship a real second version and put it under a test. A single-version API cannot demonstrate that
its versioning works. A tiny discovery endpoint served under both a deprecated
1.0and a2.0, with an integration test that sends each header and asserts the shape and the reported-version headers, is the difference between "we support versioning" and versioning you can prove. - Keep the HTTP axis separate from your event-schema axis. Request-time and client-selected is a different problem from consumer-resolved event evolution. One mechanism per axis.
What we covered: why "we support versioning" is untestable when only 1.0 ever ships, how
AddCommonApiVersioning wires a header-based policy (default 1.0 inherited from the library,
assume-default-when-unspecified, report-versions, api-version reader, plus the parameter-descriptor
backfill that keeps OpenAPI generation from failing) in one call, how ServiceInfoControllerBase ships a
real deprecated 1.0 alongside a 2.0 so ServiceInfoVersioningContractTestsBase can prove two versions
coexist rather than assert it, and the honest current-reality caveats (only the discovery endpoint has
a 2.0, adoption is per host, the attributes are repeated per subclass, and the OpenAPI documents are
dev/CI only).
Next in the series: Article 45, "Feature Flags in the CQRS Pipeline: Gate Commands, Not Code," on gating a command in the pipeline instead of branching the code that runs it.
MMCA.Common is open source. Star the repo, read the 2-minute ADR-046 behind this pattern, or
dotnet add package MMCA.Common.API and try the header.
- Repo: https://github.com/ivanball/MMCA.Common
- This pattern's decision record: ADR-046: HTTP API Versioning Strategy
- The full 34-category scorecard, §9 included, lives in MMCA.Common: Architecture Scorecard in the docs site.
Previous: Article 43, "Managed file storage: uploads you don't have to trust." Next: Article 45, "Feature Flags in the CQRS Pipeline: Gate Commands, Not Code."
Tags: .NET, C Sharp, Software Architecture, API Design, REST APIs