to navigate Enter to open "…" all these words ANDOR to combine

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 set DefaultApiVersion: 1.0 is already the Asp.Versioning library default, and the API explorer inherits both it and AssumeDefaultVersionWhenUnspecified from the versioning options, so restating either one trips AV0011/AV0024. The code comment recording that omission is at WebApplicationBuilderExtensions.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 an api-version request 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 the v1 OpenAPI document AddCommonOpenApi registers (WebApplicationBuilderExtensions.cs:404), which MapCommonOpenApi serves at /openapi/v1.json outside Production only (Source/Presentation/MMCA.Common.API/Startup/Endpoints/OpenApiEndpointExtensions.cs:34, guarded at OpenApiEndpointExtensions.cs:36 and mapped at OpenApiEndpointExtensions.cs:38).
  • A shipped exemplar proves two versions coexist. ServiceInfoControllerBase (Source/Presentation/MMCA.Common.API/Controllers/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, 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's ServiceInfoController.cs:17, Store's ServiceInfoController.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 ServiceInfoController declares [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.0 is 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) sends api-version: 1.0 and 2.0 over the real host and asserts the v1.0 minimal shape carries an api-deprecated-versions header (ServiceInfoVersioningContractTestsBase.cs:39) while the v2.0 evolved shape carries api-supported-versions (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 (for example ADC's ApiVersioningTests, MMCA.ADC/Tests/Integration/MMCA.ADC.Conference.IntegrationTests/Contract/ApiVersioningTests.cs:14, and Store's equivalent at MMCA.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.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. Both AddCommonApiVersioning and AddCommonOpenApi now register ApiParameterDescriptorBackfillProvider (Source/Presentation/MMCA.Common.API/OpenApi/ApiParameterDescriptorBackfillProvider.cs), an IApiDescriptionProvider that 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 AddCommonApiVersioning in 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. AssumeDefaultVersionWhenUnspecified means a client that never sends the header keeps getting 1.0, so introducing versioning was not a breaking change for any existing caller.
  • Report-and-deprecate makes evolution visible. ReportApiVersions plus a deprecated 1.0 means 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 ServiceInfo with a real, deprecated 1.0 alongside 2.0 gives 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 AddCommonApiVersioning and the exemplar behind ServiceInfoControllerBase means 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 sealed OAuthController); the shared behavior lives in the base, but the attributes do not.
  • Adoption is per host. A new REST host that forgets AddCommonApiVersioning gets 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. MapCommonOpenApi serves one v1 document and is a no-op in Production (OpenApiEndpointExtensions.cs:36), so the machine-readable contract does not yet enumerate the 2.0 discovery shape and is not a public production surface.

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).