Architecture Decision Record
ADR-046: HTTP API Versioning Strategy
Status
Accepted (2026-07-15). Revised 2026-08-01 (anonymity is granted by each per-service subclass, not by
ServiceInfoControllerBase; corrected the ADR-034 cross-reference, which puts the class-level
attributes on its own generic base rather than noting a non-inheritance caveat; refreshed the
AddCommonApiVersioning call-site lines in the ADC Conference/Identity and Store Catalog hosts).
Amended 2026-08-12 (v1.146.0): the registration also guards OpenAPI generation against an unbound route
token, which a URL-segment-versioned host would otherwise trip on.
Revised 2026-08-14 (the registration never sets DefaultApiVersion: 1.0 comes from the
Asp.Versioning library default and the omission is deliberate; re-anchored the registration,
explorer, OpenAPI, host call-site and EntityControllerBase citations to their current lines).
Context
The framework's REST surface is served by controllers hosted in extracted service processes behind a
YARP gateway. As those services evolve, a response shape has to be able to change without breaking a
client still coded against the old shape. HTTP APIs need a versioning axis of their own: one that a
caller selects per request and that the service can advertise and deprecate over time. This is a
different concern from how asynchronous integration events evolve on the wire (ADR-010): that axis is
resolved by consumers from a SchemaVersion carried in the serialized event, never chosen by a
caller. The HTTP axis is request-time, client-selected, and reported back in response headers.
Without a shared decision, each host would wire Asp.Versioning differently (URL-segment vs. query
vs. header, different default-version behavior, inconsistent deprecation reporting), and there would
be no proof that the machinery actually works past a single version. A "we support versioning"
claim that only ever ships v1.0 is untestable and erodes silently.
Decision
Standardize one header-based API-versioning setup in MMCA.Common.API, adopt it in every service
host through a single registration call, and keep it exercised by a shared fitness contract that
proves two live versions coexist.
- One registration wires the whole policy.
AddCommonApiVersioning(Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:245) deliberately does not setDefaultApiVersion:1.0is already theAsp.Versioninglibrary default, and the API explorer inherits both it andAssumeDefaultVersionWhenUnspecifiedfrom the versioning options, so restating either one trips AV0011/AV0024. The code comment recording that omission is atWebApplicationBuilderExtensions.cs:247-WebApplicationBuilderExtensions.cs:249. What the registration does set: it assumes the default version when a caller sends no header (AssumeDefaultVersionWhenUnspecified = true,WebApplicationBuilderExtensions.cs:252), reports the supported/deprecated versions on every response (ReportApiVersions = true,WebApplicationBuilderExtensions.cs:253), and selects the version from anapi-versionrequest header (new HeaderApiVersionReader("api-version"),WebApplicationBuilderExtensions.cs:254). The reader is header-based deliberately: routes and query strings stay version-free, so a caller opts into a newer shape by adding one header rather than changing the URL. - The API explorer is wired for versioned OpenAPI. The same call chains
.AddMvc()then.AddApiExplorer(WebApplicationBuilderExtensions.cs:255,WebApplicationBuilderExtensions.cs:256), formatting version groups as'v'VVV(WebApplicationBuilderExtensions.cs:258) and substituting the version into the URL where a host routes one (SubstituteApiVersionInUrl = true,WebApplicationBuilderExtensions.cs:259). The explorer's default-version behavior is inherited rather than configured, exactly as the comment above it records (WebApplicationBuilderExtensions.cs:247-WebApplicationBuilderExtensions.cs:249). That group format feeds thev1OpenAPI documentAddCommonOpenApiregisters (WebApplicationBuilderExtensions.cs:404), whichMapCommonOpenApiserves at/openapi/v1.jsonoutside Production only (Source/Presentation/MMCA.Common.API/Startup/Endpoints/OpenApiEndpointExtensions.cs:34, guarded atOpenApiEndpointExtensions.cs:36and mapped atOpenApiEndpointExtensions.cs:38). - A shipped exemplar proves two versions coexist.
ServiceInfoControllerBase(Source/Presentation/MMCA.Common.API/Controllers/ServiceInfoControllerBase.cs:30) serves the same/ServiceInforoute under two versions selected by the header:GetV1is mapped to1.0([MapToApiVersion("1.0")],ServiceInfoControllerBase.cs:40) and returns the minimalServiceInfoResponseshape (ServiceInfoControllerBase.cs:51);GetV2is mapped to2.0([MapToApiVersion("2.0")],ServiceInfoControllerBase.cs:46) and returns the evolvedServiceInfoV2Response, a superset that also advertises the supported and deprecated version lists (ServiceInfoControllerBase.cs:54). The base is read-only: both actions are[HttpGet](ServiceInfoControllerBase.cs:39,ServiceInfoControllerBase.cs:45) and it declares no write verb. Anonymity is not carried by the base; each sealed subclass grants it with[AllowAnonymous](ADC'sServiceInfoController.cs:17, Store'sServiceInfoController.cs:17). - The class-level version attributes live on the per-service subclass. Class-level
routing/versioning attributes are not reliably inherited, so each host supplies a sealed subclass
carrying them. ADC's
ServiceInfoControllerdeclares[ApiVersion("1.0", Deprecated = true)]and[ApiVersion("2.0")]and sets the service name to"Conference"(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/ServiceInfoController.cs:18,ServiceInfoController.cs:19,ServiceInfoController.cs:23); Store's mirror sets"Catalog"(MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.API/Controllers/ServiceInfoController.cs:18,ServiceInfoController.cs:23).1.0is declared deprecated so the deprecation-reporting path is live rather than theoretical. - A shared fitness contract keeps the machinery exercised.
ServiceInfoVersioningContractTestsBase<TFixture>(Source/Hosting/MMCA.Common.Testing/Conformance/ServiceInfoVersioningContractTestsBase.cs:20) sendsapi-version: 1.0and2.0over the real host and asserts the v1.0 minimal shape carries anapi-deprecated-versionsheader (ServiceInfoVersioningContractTestsBase.cs:39) while the v2.0 evolved shape carriesapi-supported-versions(ServiceInfoVersioningContractTestsBase.cs:55). Because the controller ships inMMCA.Common.API, the whole test body is identical across repos: each consumer's subclass supplies only its fixture (for example ADC'sApiVersioningTests,MMCA.ADC/Tests/Integration/MMCA.ADC.Conference.IntegrationTests/Contract/ApiVersioningTests.cs:14, and Store's equivalent atMMCA.Store/Tests/Integration/MMCA.Store.Catalog.IntegrationTests/Contract/ApiVersioningTests.cs). This is the rubric SS9 fitness check: without a second working version, everything above would be asserted rather than proven. - The registration also backfills missing API-parameter descriptors (added in v1.146.0). MVC leaves
ApiParameterDescription.ParameterDescriptornull for a route token with no matching action parameter, andAsp.Versioning.OpenApidereferences it without a null check, so a host that routesapi/v{version:apiVersion}/...returned a 500 fromGET /openapi/{documentName}.json. BothAddCommonApiVersioningandAddCommonOpenApinow registerApiParameterDescriptorBackfillProvider(Source/Presentation/MMCA.Common.API/OpenApi/ApiParameterDescriptorBackfillProvider.cs), anIApiDescriptionProviderthat runs last and fills a placeholder wherever one is missing, never replacing an existing descriptor. The guard is deliberately general, because it is the token being unbound rather than the versioning that produces the null: an unbound{tenant}or{region}fails identically. It goes inert once the upstream null check lands. The header-based reader above means no current host was affected either way, which is precisely why the failure could ship unnoticed. - Every REST host adopts it the same way. The extracted services call
AddCommonApiVersioningin their startup: ADC's Conference (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:168) and Identity (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:150) hosts, Store's Catalog host (MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:138), and the same call is made by the other extracted hosts and by the monolith reference host (MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:34).
Application controllers beyond ServiceInfo declare [ApiVersion("1.0")] today: the second version
exists on the discovery endpoint to keep the versioning path honest, not because any business
resource has yet needed to evolve its shape.
Rationale
- Header selection keeps URLs stable. Routing stays version-free, so gateway route maps, client URL builders, and OpenAPI paths do not fork per version; a caller opts into a newer shape with one header.
- Assume-default keeps existing callers working.
AssumeDefaultVersionWhenUnspecifiedmeans a client that never sends the header keeps getting1.0, so introducing versioning was not a breaking change for any existing caller. - Report-and-deprecate makes evolution visible.
ReportApiVersionsplus a deprecated1.0means a client can see, from response headers alone, which versions a service still supports and which are on the way out, without reading a changelog. - A living exemplar beats a claim. A single-version API cannot demonstrate that its versioning
works. Shipping
ServiceInfowith a real, deprecated1.0alongside2.0gives the fitness contract something concrete to assert, the same invariant-over-discipline posture the framework prefers (ADR-015). - Define once, adopt everywhere. Putting the whole policy behind
AddCommonApiVersioningand the exemplar behindServiceInfoControllerBasemeans every host is versioned identically by one call and one subclass, so the reader/default/reporting choices cannot drift apart between services.
Trade-offs
- The class-level version attributes are not inherited. Each per-service subclass must repeat the
[ApiVersion(...)]and routing attributes (the same inheritance caveat ADR-036 records for ADC's sealedOAuthController); the shared behavior lives in the base, but the attributes do not. - Adoption is per host. A new REST host that forgets
AddCommonApiVersioninggets no versioning and no reported versions, the same audit-the-inventory caveat other opt-in framework registrations carry; the shared fitness contract only guards a host once its subclass is added. - Header versioning is less discoverable than a URL segment. 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/response headers.
- The OpenAPI document is single-version and dev/CI only.
MapCommonOpenApiserves onev1document and is a no-op in Production (OpenApiEndpointExtensions.cs:36), so the machine-readable contract does not yet enumerate the2.0discovery shape and is not a public production surface.
Related
ADR-010 (integration-event schema versioning: the asynchronous, SchemaVersion-carried,
consumer-resolved axis this deliberately contrasts with; HTTP versioning here is request-time and
client-selected), ADR-015 (the fitness-function approach that
ServiceInfoVersioningContractTestsBase embodies, keeping the versioning machinery exercised rather
than asserted), ADR-036 (the other controller-convention decision that records the same class-level
[ApiVersion] non-inheritance, handled there by ADC's sealed OAuthController subclass), ADR-034
(the generic entity controller bases, which take the opposite shape: [ApiController] /
[Route("[controller]")] / [ApiVersion("1.0")] sit on the generic base itself,
EntityControllerBase.cs:33-35).