ADR 0006 — Source-generated JSON and AOT analyzers on the minimal-API hosts#
| Status | Accepted |
| Date | 2026-09-23 |
| Authors | Ruben Knuijver |
| Supersedes | — |
| Superseded by | — |
| Related | ADR 0005 (WebApi absorbs BMO's API) — this is its Phase 2.5 |
Context#
Before the first BMO slice lands in CommandCenter.WebApi (ADR 0005), the question was how far the
CommandCenter APIs are from AOT readiness — specifically whether any of them serialize through a
JsonSerializerContext inserted into TypeInfoResolverChain. A survey of every host under
backend/ and of BMO's approach found:
- One context existed, and it was inert.
CommandCenter.WebApi/Domain/DomainJsonContext.cs(two types, one of them an EF entity, body//test, from 2024) was registered withTypeInfoResolverChain.Add(...). The chain is first-match-wins and ASP.NET Core seeds it with the reflection resolver, so a context appended is never consulted. Nothing in CommandCenter had ever serialized through source-generated metadata. - No trim or AOT flag anywhere under
backend/, whileDirectory.Build.propssetsTreatWarningsAsErrors=truewith no exemptions. The codebase had never seen an IL2026/IL3050, and the first analyzer switched on would have turned all of them into build errors at once. - Both hosts that configure enums used the non-generic
JsonStringEnumConverter, which is[RequiresDynamicCode]. Worse, a converter inJsonSerializerOptions.Converterstakes precedence over every type-level converter (STJ's documented order: property attribute →Converterscollection → type attribute), so WebApi's global camelCase enum converter would also camelCase every BMO enum the moment BMO's contexts are chained in — and the SPA's BMO client expects PascalCase ("Running","Accepted") while WebApi's own spec expects camelCase ("queued","succeeded"). This was the "enum wire form" question Phase 3 was going to probe. - Reflection on the wire everywhere else: 12 anonymous payloads in WebApi, 3 in Keyplex, 1 in the
Diagnostic API, 1 in the BFF; the ad-hoc query result shape
List<Dictionary<string, object?>>; an open-genericDeserialize<TPayload>in the background-task queue;Newtonsoft.Jsonreferenced by three production projects and used by none of them. - BMO's pattern, which works: a context per assembly with one options header, hosts that
Insert(0, HostContext)and keep the reflection resolver as the last link,IsAotCompatible=trueon contracts and clients,EnableTrimAnalyzeron hosts, and noPublishAotanywhere. One correction to it, found while adopting it: BMO's hostsAdd(...)their shared contexts after the host context, which puts them behind the reflection resolver where they resolve nothing — BMO only works because its host context duplicates the shared types. Shared contexts go in withInsert(1, …).
What "AOT readiness" can honestly mean here: MVC is not AOT-compatible, minimal APIs are "partial", and EF Core's NativeAOT is experimental and not for production. Every CommandCenter host depends on at least one of EF Core, Quartz, MassTransit, Microsoft.Identity.Web, Fluid or Dapper. Publishing any of them AOT is not on the table. Source-generated JSON for the wire contracts, and analyzers that make the remaining reflection visible and countable, is.
Decision#
- Scope: the three minimal-API hosts.
CommandCenter.WebApi,CommandCenter.DiagnosticApiandCommandCenter.Keyplexget a host-localJsonSerializerContextinserted at the front of the HTTPJsonOptionschain, the trim/AOT analyzers, and the Request Delegate Generator where it does not degrade the OpenAPI document.CommandCenterandCommandCenter.SystemStatsAPI(MVC) andCommandCenter.Bff.Frontdoor(a proxy) get cleanup only. - The reflection resolver stays as the last link. BMO's posture. Source generation wins for
every registered type;
ProblemDetails, other framework types and anything not yet listed still serialize. What makes that safe rather than lazy is a test per host (CommandCenter.Test/Json/EndpointJsonCoverageTests.cs) that hosts the real app, enumerates the endpoint table, and fails when a declared request or response type does not resolve from the host's context alone. The fallback is measured, not relied on. - Analyzers warnings-first, with a ratchet.
EnableTrimAnalyzer+EnableAotAnalyzeron the hosts, and the IL and RDG diagnostic families excluded from warnings-as-errors inDirectory.Build.props.scripts/aot-warnings.ps1rebuilds each host, counts the distinct IL/RDG warnings, and CI fails when a host's count exceeds.gitlab/ci/aot-warnings-baseline.json. The number goes down deliberately, in MRs that say why; it never goes up silently. BMO's alternative — warnings-as-errors with per-site suppressions — produced 34 bareUnconditionalSuppressMessages with no justification, which hides the same information the ratchet keeps visible. - Enum casing is decided per type, never globally. The global
Converters.Add(new JsonStringEnumConverter(CamelCase))goes. CommandCenter's own wire enums carry[JsonConverter(typeof(CamelCaseJsonStringEnumConverter<T>))](Shared.Web.Json, aJsonStringEnumConverter<T>fixed to camelCase because[JsonConverter]needs a parameterless constructor). BMO's enums keep BMO's PascalCase because their contexts decide it. Where a CommandCenter DTO carries a BMO enum today and the wire is camelCase, a property-level attribute keeps it that way. Result: every existing spec is byte-identical, and every BMO type absorbed in Phase 3 serializes exactly as BMO serializes it — property names follow the runtime options (camelCase on both hosts), enum values follow the context (PascalCase),[JsonPropertyName]wins over both. - No new contracts assembly for WebApi. Nothing in .NET consumes WebApi's DTOs — the SPA reads
the spec and the
bmoCLI keepsBenefits.CommandCenter.Contracts— so the context lives in the host, next to the endpoints it describes. IsAotCompatibleonly where it is true.Shared.Webclaims it and propagates the requirement honestly:AddCommandCenterOpenApiis[RequiresUnreferencedCode]/[RequiresDynamicCode]becauseAddOpenApiis, so the two warnings land in the host that calls it, where they belong in the count. Libraries built on Dapper or Serilog configuration do not claim it.
Consequences#
- The OpenAPI documents are the regression net.
Shared.Web's schema transformer reads the host's liveJsonOptions, so a JSON configuration change is a spec change. Every MR under this decision runsscripts/openapi-export.ps1 -Checkand the expected diff is none — except where metadata that was missing is added (Keyplex gained its three400responses and two typed200s in the first MR). - The Request Delegate Generator is on where it is neutral and off where it is not. Measured on
the Diagnostic API: with RDG on,
POST /check/sftp-key's[FromForm] stringparameters lose their names and required-ness in the document (three anonymoustype: stringentries). RDG stays off there, with the reason beside the flag, and the analyzer then counts everyMap*call as the reflection it is — 24 warnings, which is the honest number. - Anonymous payloads are gone from the wire on the hosts in scope; each became a named record
in the context with the same bytes.
Newtonsoft.Jsonis removed from the repository; its one use was a test. - Phase 3's enum spike is closed by design, and the per-slice checklist gains a line: every absorbed DTO is in a chained context or the coverage test fails.
Explicitly not doing#
PublishAot or PublishTrimmed on any host; JsonSerializerIsReflectionEnabledByDefault=false
anywhere; contexts for the MVC hosts; converting the streaming ad-hoc result shape away from
object cells (its primitive cell types are registered instead, and the fallback covers the rest);
suppressing IL warnings per site to reach zero.