Skip to content

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 with TypeInfoResolverChain.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/, while Directory.Build.props sets TreatWarningsAsErrors=true with 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 in JsonSerializerOptions.Converters takes precedence over every type-level converter (STJ's documented order: property attribute → Converters collection → 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-generic Deserialize<TPayload> in the background-task queue; Newtonsoft.Json referenced 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=true on contracts and clients, EnableTrimAnalyzer on hosts, and no PublishAot anywhere. One correction to it, found while adopting it: BMO's hosts Add(...) 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 with Insert(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#

  1. Scope: the three minimal-API hosts. CommandCenter.WebApi, CommandCenter.DiagnosticApi and CommandCenter.Keyplex get a host-local JsonSerializerContext inserted at the front of the HTTP JsonOptions chain, the trim/AOT analyzers, and the Request Delegate Generator where it does not degrade the OpenAPI document. CommandCenter and CommandCenter.SystemStatsAPI (MVC) and CommandCenter.Bff.Frontdoor (a proxy) get cleanup only.
  2. 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.
  3. Analyzers warnings-first, with a ratchet. EnableTrimAnalyzer + EnableAotAnalyzer on the hosts, and the IL and RDG diagnostic families excluded from warnings-as-errors in Directory.Build.props. scripts/aot-warnings.ps1 rebuilds 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 bare UnconditionalSuppressMessages with no justification, which hides the same information the ratchet keeps visible.
  4. 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, a JsonStringEnumConverter<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.
  5. No new contracts assembly for WebApi. Nothing in .NET consumes WebApi's DTOs — the SPA reads the spec and the bmo CLI keeps Benefits.CommandCenter.Contracts — so the context lives in the host, next to the endpoints it describes.
  6. IsAotCompatible only where it is true. Shared.Web claims it and propagates the requirement honestly: AddCommandCenterOpenApi is [RequiresUnreferencedCode] / [RequiresDynamicCode] because AddOpenApi is, 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 live JsonOptions, so a JSON configuration change is a spec change. Every MR under this decision runs scripts/openapi-export.ps1 -Check and the expected diff is none — except where metadata that was missing is added (Keyplex gained its three 400 responses and two typed 200s 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] string parameters lose their names and required-ness in the document (three anonymous type: string entries). RDG stays off there, with the reason beside the flag, and the analyzer then counts every Map* 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.Json is 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.