ADR 0001 — Backend OpenAPI and logging standardization#
| Status | Accepted |
| Date | 2026-05-14 |
| Authors | Ruben Knuijver |
| Supersedes | — |
| Superseded by | — |
Context#
Two cross-cutting concerns drifted into five different patterns across the .NET 10 backend over the lifetime of the solution:
OpenAPI — surveyed across 7 HTTP API projects, five distinct setups were in use:
| Project | Pattern |
|---|---|
CommandCenter |
Swashbuckle, dev-only Swagger UI, no AddSwaggerGen (broken) |
CommandCenter.WebApi |
Swashbuckle + manual OpenApiInfo/OpenApiContact/OpenApiLicense |
CommandCenter.DiagnosticApi |
Bare AddOpenApi(), no MapOpenApi() (no document served) |
CommandCenter.Keyplex |
AddOpenApi() and AddSwaggerGen(), custom YAML path /openapi/keyplex/openapi.yaml |
CommandCenter.SystemStatsAPI |
AddOpenApi() + MapOpenApi() (dev-only) |
CommandCenter.Bff.Frontdoor |
None (gateway) |
CommandCenter.BenefitsReverseProxy |
None (proxy) |
The mix made onboarding hard ("which API has a UI? at which path?") and surfaced two concrete bugs:
- NU1608 build failure —
Serilog.Sinks.ApplicationInsights 5.0.1requiresMicrosoft.ApplicationInsights (>= 2.23.0 && < 3.0.0)but3.1.1was resolved transitively.Directory.Packages.propshasTreatWarningsAsErrors=true, so restore aborted across all 12 projects. Investigation showed the Serilog sink was never actually configured at runtime — dead packages bloating the dependency graph. - CS0200 build failure —
Microsoft.OpenApi 3.xmadeIOpenApiMediaType.Exampleread-only. TheMicrosoft.AspNetCore.OpenApi 10.0.7source generator emitsExample = …assignments. Four projects had aMicrosoft.OpenApi 3.5.3pin that overrode the older transitive version the generator was built against, breaking compilation.
Logging — surveyed across all 11 backend projects:
- All projects that called
Shared.Logging.AddLoggingConfiguration()got the same boilerplate Serilog setup driven by theirappsettings.jsonSerilogblock. - Four of 11 projects (
CommandCenter.DiagnosticApi,CommandCenter.Keyplex,CommandCenter.BenefitsAgent,CommandCenter.ServerAgent) had missing or empty Serilog sections, meaning they lost console/file output entirely or defaulted to whatever framework loggers picked up. - Exactly one project (
CommandCenter.Bff.Frontdoor/LogEvents.cs) used the[LoggerMessage]source generator (3 partial methods). Every other call site usedlogger.LogXxx(…)with string interpolation — re-parsing the template, allocating per call, and missing the structured-property benefits. - OpenTelemetry → Azure Monitor was wired in 4 of 7 API projects and 0 of 4 workers (
Shared.Tracing/OpenTelemetryExtensions.cs).
The user asked for a uniform standard across the solution: OpenAPI in every HTTP API project, and a logging path that switches easily between console (ANSI-coloured), file, and a remote server. The decision below codifies the standard that the May 2026 standardization sweep introduced.
Decision#
1. OpenAPI generator: Microsoft.AspNetCore.OpenApi (native), not Swashbuckle#
Adopt Microsoft.AspNetCore.OpenApi 10.0.7+ as the uniform OpenAPI generator across every HTTP API project. It is the recommended generator on .NET 10, produces OpenAPI 3.1 documents natively, ships with MapOpenApi, and supports JSON+YAML output from a single endpoint pattern. Swashbuckle is removed from the solution.
2. Uniform wiring via Shared.Web.OpenApiExtensions#
Every HTTP API registers OpenAPI through two extension methods in backend/Shared.Web/OpenApiExtensions.cs:
builder.Services.AddCommandCenterOpenApi(
title: "CommandCenter Web API",
description: "Analytics and management endpoints.");
// after var app = builder.Build();
app.MapCommandCenterOpenApi();
Endpoints (always exposed — not Development-gated):
GET /openapi/v1.jsonGET /openapi/v1.yaml(and.yml)
UI: Scalar at GET /scalar/v1, Development only.
Scope:
- In scope:
CommandCenter,CommandCenter.WebApi,CommandCenter.DiagnosticApi,CommandCenter.Keyplex,CommandCenter.SystemStatsAPI. - Out of scope:
CommandCenter.Bff.FrontdoorandCommandCenter.BenefitsReverseProxy(YARP gateways — no endpoints of their own), and the four worker services (no HTTP surface).
3. Logging defaults are code-first#
backend/Shared.Logging/ConfigurationExtensions.cs::AddLoggingConfiguration is the single entry point for every host. The defaults are now configured in code, not in each appsettings.json:
- Console sink with
AnsiConsoleTheme.Code— renders ANSI colour in Windows Terminal, VS Code, Docker logs, and CI runners. - Async rolling file sink —
Logs/app-.txt, daily roll, 7-file retention, 50 MB cap, shared writer. - Level overrides in code:
Informationin non-Development,Debugin Development;Microsoft.AspNetCorecapped atWarning;MassTransitfollows the Dev/Prod toggle.
.ReadFrom.Configuration(builder.Configuration) still layers on top, so per-host Serilog overrides in appsettings.json continue to work for ad-hoc additions (e.g. a Seq sink for one environment). The existing duplicate Console/File blocks were removed from all 9 appsettings.json files because they would have doubled the sinks.
4. Remote logging exclusively via OpenTelemetry#
backend/Shared.Tracing/OpenTelemetryExtensions.cs is the only path for remote logs, metrics, and traces. Set OTEL_SERVICE_NAME and either APPLICATIONINSIGHTS_CONNECTION_STRING (Azure Monitor) or OTEL_EXPORTER_OTLP_ENDPOINT (any collector) and everything flows. The OpenTelemetry logger provider already captures every ILogger event — including those that pass through Serilog — so there is no need for a second pipeline.
The previously-referenced Serilog.Sinks.ApplicationInsights and Microsoft.ApplicationInsights packages were removed from Shared.Logging; they were never wired at runtime and were the root cause of NU1608.
5. Semantic logging via the [LoggerMessage] source generator#
For any non-trivial log statement, use the source-generated logger pattern. backend/Shared.Logging/SharedLog.cs is the canonical template and documents the EventId convention:
using Microsoft.Extensions.Logging;
namespace CommandCenter.WebApi;
public static partial class WebApiLog
{
[LoggerMessage(
EventId = 3001,
Level = LogLevel.Information,
Message = "Analytics query {QueryId} executed in {ElapsedMs}ms returning {RowCount} rows")]
public static partial void AnalyticsQueryExecuted(
this ILogger logger,
string queryId,
long elapsedMs,
int rowCount);
}
EventId 1000-range per service (see SharedLog.cs for the full table). Source generation eliminates message-template re-parsing, boxes nothing, and emits the IsEnabled checks automatically.
Consequences#
Positive
- One mental model across the solution. New API projects copy two lines (
AddCommandCenterOpenApi+MapCommandCenterOpenApi) and inherit the security posture (UI gated to Development, JSON/YAML always available). - OpenAPI 3.1 by default, with a build-time path that can be re-enabled when upstream stabilises.
- Developer UX: Scalar at
/scalar/v1is faster, cleaner, and offline-first compared to Swagger UI. - Semantic, allocation-free logging on hot paths; consistent structured properties enable better Azure Monitor / KQL queries.
- One remote-log pipeline (OpenTelemetry → Azure Monitor) means one place to look during incidents.
Negative / tracked
- Build-time OpenAPI export disabled.
Microsoft.Extensions.ApiDescription.Server 10.0.7hits a JSON serialization bug on one ofCommandCenter.WebApi's response schemas. We set<OpenApiGenerateDocumentsOnBuild>false</OpenApiGenerateDocumentsOnBuild>inCommandCenter,CommandCenter.WebApi, andCommandCenter.Keyplex. Runtime/openapi/v1.jsonis the source of truth; CI can curl that endpoint if it needs the contract artefact. Revisit when the upstream fix lands. - Per-project security schemes are still per-project.
Microsoft.OpenApi 3.xreshaped the security types:OpenApiSecurityScheme.Referenceis gone, replaced by the separateOpenApiSecuritySchemeReferencetype. The initial standard ships with only the document-info transformer; security schemes are a project concern until the first concrete need codifies a new shared pattern inShared.Web.OpenApiExtensions.
Breaking
CommandCenter.Keyplexpreviously served the document at/openapi/keyplex/openapi.yaml. The new path is/openapi/v1.yaml. External consumers of the legacy URL must be updated.
Maintenance posture
- The
[LoggerMessage]rollout starts from a handful of seed entries inSharedLog.csplus the existing three inLogEvents.cs. Migrating existinglogger.LogXxx(…)call sites is opportunistic — touch a file, upgrade its log lines in the same PR. - Adding a new HTTP API project: include
<ProjectReference Include="..\Shared.Web\Shared.Web.csproj" />, call the two extension methods inProgram.cs. Nothing else.
Alternatives considered#
- Stay on Swashbuckle. Rejected.
Microsoft.AspNetCore.OpenApiis the supported generator on .NET 10, ships in-box, supports OpenAPI 3.1 natively, and removes a third-party dependency whose primary remaining value is the Swagger UI bundle. - Keep Swashbuckle just for the Swagger UI. Rejected. Scalar is the community default in 2026 against the native generator, ships as a single NuGet package, and renders both the bundled doc and an interactive client without
AddSwaggerGenceremony. - Mixed approach — some projects keep Swashbuckle. Rejected. Uniformity was the explicit goal; the mixed state was already the problem.
- Add
Serilog.Sinks.OpenTelemetryas an opt-in remote sink. Rejected. Duplicates the OpenTelemetry logger provider that already captures everything routed throughILogger. Two pipelines mean two places for events to diverge. - Delete
appsettings.jsonSerilog blocks entirely (code-only). Rejected. Keeping.ReadFrom.Configurationlets ops add a Seq sink or override a per-env level without redeploying. The defaults are in code; the overrides are still in config.
References#
- Implementation:
backend/Shared.Web/OpenApiExtensions.cs,backend/Shared.Logging/ConfigurationExtensions.cs,backend/Shared.Logging/SharedLog.cs,backend/Shared.Tracing/OpenTelemetryExtensions.cs. - Backend guide:
backend/CLAUDE.md— sections "OpenAPI (uniform across all HTTP API projects)" and "Logging (uniform across every host)". - Microsoft Learn:
- NuGet: Scalar.AspNetCore 2.14.11 (released 2026-05-05).
- Upstream issue context: Microsoft.OpenAPI 2.0.0 has breaking changes — dotnet/aspnetcore#61123.