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).
Revised 2026-09-11 (re-anchored the registration, explorer, OpenAPI and host call-site citations,
which have since moved; corrected the OpenAPI trade-off: AddCommonOpenApi plus
MapCommonOpenApi().WithDocumentPerVersion() resolve one document per discovered API version rather
than a single v1 document).
Revised 2026-09-22 (MMCA.ADC commits a build-time OpenAPI document per service host, gated in CI; see the Revision below).
Revised 2026-09-25 (MMCA.Store adopts the same committed-document gate, so both consumers carry it;
re-anchored the host call-site citations; see the Revision (2026-09-25) below).
Revised 2026-10-01 for v1.217.0 (the host registers the single v1 OpenAPI document itself and
AddCommonOpenApi only configures it; per-version documents are gone; see the Revision (2026-10-01,
v1.217.0) below).
Revised 2026-10-06: the registration also adds ApiVersionReportingResultFilter as a global MVC filter so output-cached responses keep the version headers; re-anchored the citations.
Revised 2026-10-07: anchors re-verified against current source after the v1.233.0 release, no decision change.
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:39) 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:41-WebApplicationBuilderExtensions.cs:43. What the registration does set: it assumes the default version when a caller sends no header (AssumeDefaultVersionWhenUnspecified = true,WebApplicationBuilderExtensions.cs:46), reports the supported/deprecated versions on every response (ReportApiVersions = true,WebApplicationBuilderExtensions.cs:47), and selects the version from anapi-versionrequest header (new HeaderApiVersionReader("api-version"),WebApplicationBuilderExtensions.cs:48). 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 same call also addsApiVersionReportingResultFilteras a global MVC filter (WebApplicationBuilderExtensions.cs:60, class atSource/Presentation/MMCA.Common.API/Caching/ApiVersionReportingResultFilter.cs:21), which reports the version headers ahead of the response body so an output-cache entry stores them and a cache hit still carriesapi-supported-versions(comment atWebApplicationBuilderExtensions.cs:58-:59). - The API explorer is wired for versioned OpenAPI. The same call chains
.AddMvc()then.AddApiExplorer(WebApplicationBuilderExtensions.cs:49,WebApplicationBuilderExtensions.cs:50), formatting version groups as'v'VVV(WebApplicationBuilderExtensions.cs:52) and substituting the version into the URL where a host routes one (SubstituteApiVersionInUrl = true,WebApplicationBuilderExtensions.cs:53). The explorer's default-version behavior is inherited rather than configured, exactly as the comment above it records (WebApplicationBuilderExtensions.cs:41-WebApplicationBuilderExtensions.cs:43). The explorer feeds the controllers' API descriptions to the OpenAPI generator; it does not name the documents. Since v1.217.0 each host registers the singlev1document itself with ASP.NET Core'sservices.AddOpenApi(), andAddCommonOpenApi(WebApplicationBuilderExtensions.cs:101, contract in the doc comment at:87-:100) registers no document: it configures every document the host registered.MapCommonOpenApiserves it at/openapi/v1.jsonoutside Production only (Source/Presentation/MMCA.Common.API/Startup/Endpoints/OpenApiEndpointExtensions.cs:60, guarded atOpenApiEndpointExtensions.cs:62, mapped.AllowAnonymous()atOpenApiEndpointExtensions.cs:74), and fails at startup when the host registered nov1document (OpenApiEndpointExtensions.cs:64-:72). Why the host must keep the call: see the Revision (2026-10-01, v1.217.0). - 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. Since v1.217.0 MMCA.Common no longer referencesAsp.Versioning.OpenApi; the guard stays because it costs nothing and still protects any other consumer of the API descriptions from the same null. - 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:219) and Identity (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:154) hosts, Store's Catalog host (MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:140), and the same call is made by the other extracted hosts (ADC EngagementProgram.cs:150, ADC NotificationProgram.cs:139, Store SalesProgram.cs:147, Store IdentityProgram.cs:134) and by the monolith reference host (MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:35).
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 dev/CI only, and there is one:
v1.MapCommonOpenApiis a no-op in Production (OpenApiEndpointExtensions.cs:62), so the machine-readable contract is an internal dev/CI artifact rather than a public production surface. Since v1.217.0 each host registers only thev1document, so a version a controller adds beyond1.0(today only the2.0ServiceInfoaction) is served and exercised by the fitness contract above but appears in no generated document. The framework baseline and each consumer's contract test fetch/openapi/v1.json(MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/OpenApi/OpenApiBaselineTests.cs,Source/Hosting/MMCA.Common.Testing/Conformance/OpenApiContractTestsBase.cs:31). A host that later needs a second documented version registers a second document itself; the framework no longer generates one per version.
Revision (2026-09-22): the consumer contract is a committed artifact
The trade-off above described the OpenAPI document as a dev/CI artifact that nothing outside a
running host could diff. MMCA.ADC now writes it to disk at build time. MMCA.ADC/Directory.Build.props
gives every *.Service host the MmcaGenerateOpenApiDocument target, which runs the ASP.NET Core
document generator (Microsoft.Extensions.ApiDescription.Server) against the built assembly under a
design-time environment: the Development configuration plus a placeholder JWT authority, so the host
builds without a database or an Identity peer and is never started to serve a request. The output is
Source/Services/<host>/openapi/<host>.json, and the four documents are committed. The OpenAPI documents are current step in MMCA.ADC/.github/workflows/deploy.yml runs after the build and fails
the PR when a document changed without being committed, so a route, parameter, status code or
response-shape change is a reviewable diff in the PR that caused it. Generation was verified
byte-identical across runs before the files were committed. The package's own build hook stays off
because it cannot carry the design-time environment, and dotnet publish skips the target, so the
container builds do not run it.
The framework side is unchanged: OpenApiContractTestsBase still asserts the live document, and the
committed file is the consumer's artifact, produced by the consumer's build. The gate is not a
framework primitive: each consumer carries its own copy of the target and the CI step, and both
consumers now do (see the 2026-09-25 revision).
Revision (2026-09-25): both consumers commit and gate their OpenAPI documents
MMCA.Store now carries the same gate, so the committed contract is a property of both consumers
rather than of MMCA.ADC alone. Each repo's Directory.Build.props references
Microsoft.Extensions.ApiDescription.Server (MMCA.ADC/Directory.Build.props:172,
MMCA.Store/Directory.Build.props:176), switches the package's own build hook off
(MMCA.Store/Directory.Build.props:186), and defines the MmcaGenerateOpenApiDocument target
(MMCA.ADC/Directory.Build.props:200, MMCA.Store/Directory.Build.props:207), which writes
Source/Services/<host>/openapi/<host>.json after each *.Service build under the design-time
environment. Store's design-time environment also switches off the background work its hosts start
(payment reconciliation and the two backfills, MMCA.Store/Directory.Build.props:221), because
document generation starts the host. Every service Program.cs skips the schema initializer when
MmcaOpenApiDesignTime is set in Development: ADC Conference :438, Engagement :315, Identity
:343, Notification :262; Store Catalog :313, Sales :297, Identity :297.
ADC commits four documents and Store commits three
(MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/openapi/MMCA.Store.Catalog.Service.json,
MMCA.Store.Sales.Service/openapi/MMCA.Store.Sales.Service.json,
MMCA.Store.Identity.Service/openapi/MMCA.Store.Identity.Service.json). Each repo's deploy.yml
runs an OpenAPI documents are current step in build-and-test after the Release build
(MMCA.ADC/.github/workflows/deploy.yml:297, MMCA.Store/.github/workflows/deploy.yml:232): it fails
when the committed count differs from the host count (Store :241-243) or when
git diff --exit-code finds a regenerated document (ADC :309, Store :245), and uploads the
regenerated set on a mismatch (ADC :314, Store :250).
Revision (2026-10-01)
No decision or rationale changed. Re-anchored the citations in Decision, Trade-offs and Related, which
had moved: AddCommonApiVersioning and its options (WebApplicationBuilderExtensions.cs:37-:51),
AddCommonOpenApi (WebApplicationBuilderExtensions.cs:91, :93, doc comment :82-:84),
MapCommonOpenApi (OpenApiEndpointExtensions.cs:42, :44, :46), the host call sites (ADC
Conference :218, Identity :149, Engagement :146, Notification :138; Store Catalog :139, Sales
:146, Identity :133) and EntityControllerBase.cs:31-33. The Decision now records that the
document endpoint is mapped .AllowAnonymous() (OpenApiEndpointExtensions.cs:46). Two details in the
2026-09-25 revision are left as recorded: the design-time schema-initializer skip now runs through the
framework helper InitializeDatabaseUnlessDesignTimeAsync
(Source/Presentation/MMCA.Common.API/Startup/DatabaseInitializationExtensions.cs:144, check at
:151), which each service Program.cs calls, and ADC's OpenAPI documents are current step
(MMCA.ADC/.github/workflows/deploy.yml:305) also checks the committed count (:314).
Revision (2026-10-01, v1.217.0): the host registers the OpenAPI document
What changed. AddCommonOpenApi used to register the documents itself through the versioning
builder (AddApiVersioning().AddOpenApi(), one document per discovered API version), and
MapCommonOpenApi mapped them with .WithDocumentPerVersion(). Both ADC and Store therefore kept
their own services.AddOpenApi() plus a hand-written MapOpenApi().AllowAnonymous() instead of the
framework pair. Switching a host to the framework pair changed its committed document: across ADC's four
hosts 134 operation summaries disappeared, info.title became GetDocument.Insider | v1, info.version went
from 1.0.0 to 1.0, every api-version header gained enum: ["1.0"], and an extra v2 document
appeared.
Why. The .NET 10 OpenAPI XML-comment source generator attaches a project's controller summaries by
intercepting the AddOpenApi call sites of the project being compiled. A registration made inside the
framework assembly is never intercepted, so the host's summaries were lost. The versioning
registration's own transformers produced the title, version, enum and extra-document differences.
Decision now (MMCA.Common #465). The host keeps the services.AddOpenApi() call in its own
project; AddCommonOpenApi (WebApplicationBuilderExtensions.cs:95) only configures the registered
documents (the ADR-115 strongly-typed-identifier transformers, :102-:106, and the parameter
backfill guard); MapCommonOpenApi maps the plain v1 document and throws when none was registered.
Asp.Versioning.OpenApi is no longer a dependency. Its analyzer rule AV0030 still fires on the plain
mapping (the rule ships with the versioning packages Common keeps) and is suppressed at that one
declaration as a false positive (OpenApiEndpointExtensions.cs:56-:59). Every ADC and Store service
host now registers as services.AddOpenApi(); services.AddCommonOpenApi(); and maps with
app.MapCommonOpenApi() (ADC Conference Program.cs:317, :318, :451; Engagement :165, :166,
:327; Identity :177, :178, :352; Notification :148, :149, :269; Store Catalog :193,
:194, :322; Sales :167, :168, :306; Identity :154, :155, :306; adopted in ADC #234 and
Store #179). The swap left all seven committed documents byte-identical in content, which is the
acceptance test the change was held to. AddCommonOpenApiHostRegistrationTests pins the contract in
the framework.
Revision (2026-10-06)
- The Decision now records that
AddCommonApiVersioningalso registersApiVersionReportingResultFilteras a global MVC filter (WebApplicationBuilderExtensions.cs:60; class atCaching/ApiVersionReportingResultFilter.cs:21), so version headers are written before the body and an output-cache hit still carries them. No decision or rationale changed. - Current locations of facts recorded in earlier revisions:
AddCommonOpenApiis atWebApplicationBuilderExtensions.cs:101with its transformers at:105-:112; the host registrations (AddOpenApi,AddCommonOpenApi,MapCommonOpenApi) are ADC Conference:319,:320,:453; Engagement:168,:169,:331; Identity:182,:183,:363; Notification:155,:156,:276; Store Catalog:193,:194,:327; Sales:167,:168,:306; Identity:154,:155,:306. The design-time skip helperInitializeDatabaseUnlessDesignTimeAsyncis declared atDatabaseInitializationExtensions.cs:146with its check at:153, called from ADC ConferenceProgram.cs:443, Engagement:321, Identity:353, Notification:266; Store Catalog:317, Sales:296, Identity:296. TheOpenAPI documents are currentstep is atMMCA.ADC/.github/workflows/deploy.yml:305(count check:313-:315, diff:317, upload:322) andMMCA.Store/.github/workflows/deploy.yml:290(count check:299-:301, diff:303, upload:308). - Anchors in Decision, Trade-offs and Related were re-verified against current source.
Revision (2026-10-07)
Re-verified against current source. No decision, rationale or trade-off changed, and every anchor in
Decision, Trade-offs and Related still resolves. Line numbers cited inside the earlier dated
revisions (host registrations, the design-time skip helper, both deploy.yml gates and both
Directory.Build.props blocks) have since moved and are left as recorded.
- Anchors re-verified against current source:
AddCommonApiVersioning(WebApplicationBuilderExtensions.cs:39, comment:41-:43, options:46-:48, explorer:49-:53, result filter:58-:60),AddCommonOpenApi(WebApplicationBuilderExtensions.cs:101, doc comment:87-:100),MapCommonOpenApi(OpenApiEndpointExtensions.cs:60, guard:62, throw:64-:72,.AllowAnonymous():74),ApiVersionReportingResultFilter.cs:21,ServiceInfoControllerBase.cs:30,:39,:40,:45,:46,:51,:54, both subclasses (ServiceInfoController.cs:17,:18,:19,:23in ADC Conference and Store Catalog),ServiceInfoVersioningContractTestsBase.cs:20,:39,:55,ApiVersioningTests.cs:14,OpenApiContractTestsBase.cs:31,EntityControllerBase.cs:31-33, and theAddCommonApiVersioninghost call sites (ADC ConferenceProgram.cs:219, Identity:154, Engagement:150, Notification:139; Store Catalog:140, Sales:147, Identity:134; Helpdesk WebProgram.cs:35).
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:31-33).