Architecture Decision Record
ADR-046: HTTP API Versioning Strategy
Status
Accepted (2026-07-15).
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:76) sets the default version to1.0(WebApplicationBuilderExtensions.cs:80), assumes it when a caller sends no version (AssumeDefaultVersionWhenUnspecified = true,WebApplicationBuilderExtensions.cs:81), reports the supported/deprecated versions on every response (ReportApiVersions = true,WebApplicationBuilderExtensions.cs:82), and selects the version from anapi-versionrequest header (new HeaderApiVersionReader("api-version"),WebApplicationBuilderExtensions.cs:83). 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:84), formatting version groups as'v'VVV(WebApplicationBuilderExtensions.cs:87) and mirroring the default-version behavior for the explorer (WebApplicationBuilderExtensions.cs:89). That group format feeds thev1OpenAPI documentAddCommonOpenApiregisters (WebApplicationBuilderExtensions.cs:166), whichMapCommonOpenApiserves at/openapi/v1.jsonoutside Production only (Source/Presentation/MMCA.Common.API/Startup/OpenApiEndpointExtensions.cs:28,OpenApiEndpointExtensions.cs:30). - 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 anonymous and read-only. - 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/ServiceInfoVersioningContractTestsBase.cs:19) sendsapi-version: 1.0and2.0over the real host and asserts the v1.0 minimal shape carries anapi-deprecated-versionsheader (ServiceInfoVersioningContractTestsBase.cs:38) while the v2.0 evolved shape carriesapi-supported-versions(ServiceInfoVersioningContractTestsBase.cs:54). 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. - 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:134) and Identity (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:131) hosts, Store's Catalog host (MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:94), 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:31).
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-034 and ADR-036 note for controller convention attributes); 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:30), 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-034 and ADR-036 (controller-convention decisions that note the same class-level
[ApiVersion] non-inheritance handled by the per-service subclass).
</content>