Onboarding guide
Aspire Orchestration and Containers
This chapter teaches how the MMCA.ADC system goes from a single dotnet run on your workstation to a
running stack of six .NET processes plus four containers: databases, a broker, a cache, a mail
interceptor, and a dashboard.
You will learn which resources the AppHost provisions and why, how service discovery and health-based
startup ordering wire everything together, which probe each resource carries and why the choice of
probe is load-bearing, what service defaults each service host gets from the framework's
MMCA.Common.Aspire package (there is no ADC-local ServiceDefaults project), how the two
MMCA.Common.Aspire* framework packages embed that machinery at the framework level, and how each
deployable is packaged into its Docker image. By the end you should be able to follow a WithReference
edge from first principles and explain local-to-cloud parity without looking at any other document.
Cross-references: primer §1, the big picture, primer §2, architectural styles, and the per-type reference in group 16, Aspire orchestration and service defaults.
The one-command local run
dotnet run --project Source/Hosting/MMCA.ADC.AppHost
That command brings up everything the application needs locally: four SQL Server databases, Redis,
RabbitMQ with management UI, a MailDev SMTP interceptor, four extracted microservice processes, a YARP
gateway pinned to https://localhost:6001, and the Blazor UI pinned to https://localhost:6002. The
Aspire dashboard, opened automatically, shows live logs, metrics, and distributed traces from every
process via an OTLP endpoint it injects into each one. No Docker Compose file is needed; no environment
variables need to be set by hand; the AppHost is the single source of truth for the local topology.
One environment variable changes the shape of that stack. Setting ADC_BROKER=servicebus before the
run swaps RabbitMQ for the official Azure Service Bus emulator container, so the whole stack speaks the
same transport production speaks (MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:91-101).
Unset is the default, and the rationale block above the switch is explicit about why
(Program.cs:66-88): the emulator costs a second container plus a warm-up, which is not a price the
everyday inner loop should pay, but a developer chasing a transport-specific bug (entity-name limits,
topic and subscription provisioning through the admin plane, scheduled redelivery pacing) previously
could not reproduce it locally at all.
One operational caveat that no source file states: run that command in an interactive terminal only.
Launched from a background or non-interactive shell the AppHost stalls at control-plane init (no
dashboard, no :6001), so a headless "verification run" hangs rather than failing (MMCA.ADC/CLAUDE.md,
Build/Test/Run section).
[Rubric §33, Developer Experience] assesses how quickly a new engineer becomes productive. A
single-command local run that matches production topology (same services, same broker option, same auth
flow) means the feedback loop is: edit, restart, observe real cross-service behavior, rather than
mocking everything and discovering integration bugs in CI. The ADC_BROKER opt-in is the same category
of decision applied to the one place where local and production genuinely diverge: parity is available
on demand rather than either always paid for or permanently missing.
MMCA.ADC.AppHost, the orchestration project
Source file:
MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.csAppHost-local helper:MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/BrokerSelection.csFramework extension helpers:MMCA.Common/Source/Hosting/MMCA.Common.Aspire.Hosting/Extensions.csand.../H2cHealthCheckExtensions.csProject file:MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/MMCA.ADC.AppHost.csproj
Project identity
The .csproj (AppHost.csproj:3) opts in to the Aspire.AppHost.Sdk (version 13.5.2), which activates
the Aspire resource model, code-generates strongly-typed Projects.* references from the six project
references listed below it (AppHost.csproj:22-27), and arranges for the .Build().RunAsync() entry
point (Program.cs:464) to launch the dashboard and all declared resources. Four hosting integrations
are referenced directly (Aspire.Hosting.AppHost, .Redis, .SqlServer, .RabbitMQ,
AppHost.csproj:15-18), and MMCA.Common.Aspire plus MMCA.Common.Aspire.Hosting come in as plain
PackageReferences marked IsAspireProjectResource="false" (AppHost.csproj:28-29) so the hosting
extensions those packages export are available without treating them as orchestrated processes.
One suppression is worth reading rather than skipping: NoWarn carries ASPIRE010
(AppHost.csproj:11), with the comment recording why (AppHost.csproj:9-10). The analyzer nudges toward
launching through the Aspire CLI bundle; this AppHost runs via dotnet run, and the suppression keeps
that a deliberate, documented choice rather than a warning everyone learns to ignore.
Infrastructure containers
SQL Server is declared as a persistent container named "sql" (Program.cs:15-16). The
ContainerLifetime.Persistent option keeps the container alive across AppHost restarts, preserving
data and avoiding re-seeding during inner-loop development (Program.cs:12-14 comment). Four databases
are carved from it (Program.cs:37-40):
adc-identity -> ADC_Identity (Identity service)
adc-conference -> ADC_Conference (Conference service)
adc-engagement -> ADC_Engagement (Engagement service)
adc-notification -> ADC_Notification (Notification service)
One database per service is the direct implementation of ADR-006 (database-per-service). No service
touches another service's database; no service races for another service's outbox rows
(Program.cs:18-27). The long comment above the declarations (Program.cs:29-36) is the honest part: the
legacy single AtlDevCon database is deliberately not provisioned here, and it is gone in Azure too.
It was exported to the bacpac blob sql-archive/AtlDevCon-20260902.bacpac and dropped on 2026-09-02,
so the template no longer declares it and the blob, restorable with az sql db import, is the rollback
source of record (MMCA.ADC/infra/main.bicep:890-899). These four databases are the entire application
data estate (main.bicep:904-916). Database-per-service is the topology everywhere, not a
local-development shortcut.
Redis is also persistent (Program.cs:44-45), used by service hosts for distributed output caching
and ICacheService. All four service hosts receive WithReference(redis) and WaitFor(redis).
The broker is chosen, not hardcoded. The AppHost picks one of two broker resources up front and captures the choice as a delegate (Program.cs:89-101):
Func<IResourceBuilder<ProjectResource>, IResourceBuilder<ProjectResource>> withBroker;
if (string.Equals(Environment.GetEnvironmentVariable("ADC_BROKER"), "servicebus", StringComparison.OrdinalIgnoreCase))
{
var serviceBus = builder.AddServiceBusEmulatorBroker(sqlServer);
withBroker = service => service.WithBroker(serviceBus);
}
else
{
var rabbit = builder.AddMessageBroker()
.WithLifetime(ContainerLifetime.Persistent);
withBroker = service => service.WithBroker(rabbit);
}
The delegate exists because the two WithBroker overloads in MMCA.Common.Aspire.Hosting take
different resource types (RabbitMQServerResource at Common.Aspire.Hosting/Extensions.cs:252 and
ServiceBusEmulatorResource at Extensions.cs:280), so the choice cannot be expressed as one variable
handed to one call. WithSelectedBroker (BrokerSelection.cs:21-26, the AppHost's one local
extension) applies it, and the class comment says exactly what that buys
(BrokerSelection.cs:7-12): every service's wiring chain reads as the same single line, which is what
stops a fifth service from quietly being wired to the wrong broker. See
BrokerSelection for the per-type entry.
AddMessageBroker() wraps builder.AddRabbitMQ(name).WithManagementPlugin() with the resource name
defaulting to "rabbitmq" (DefaultBrokerResourceName, Common.Aspire.Hosting/Extensions.cs:28,
method at 160-165); the management plugin exposes the admin UI at http://localhost:15672.
AddServiceBusEmulatorBroker(sqlServer) (Extensions.cs:201-238) provisions the official emulator image
mcr.microsoft.com/azure-messaging/servicebus-emulator:2.0.1 (Extensions.cs:94-107) and points it at
the existing SQL Server resource rather than starting a second engine, because the emulator stores
its state in SQL Server (Extensions.cs:176-181, SQL_SERVER and MSSQL_SA_PASSWORD at 228-233, and a
WaitFor(sqlServer) at 237 because the emulator's first act is to create its schema).
MailDev comes from a framework helper rather than an inline container declaration (Program.cs:109):
var mailDev = builder.AddMailDev();
AddMailDev (Common.Aspire.Hosting/Extensions.cs:141-150) adds the maildev/maildev container with a
persistent lifetime and two fixed host ports: 1080 for the web UI and 1025 for SMTP
(MailDevHttpPort / MailDevSmtpPort, Extensions.cs:118 and 124). Fixed rather than Aspire's dynamic
ports for a stated reason (Extensions.cs:114-124): a developer opens the web UI by hand, and the SMTP
port is what each consumer's Smtp:Port setting names. Every service WaitFor(mailDev) but none
WithReference it, because the address is a literal in configuration rather than a discovered one.
[Rubric §17, DevOps and Deployment] assesses whether the local environment closely mirrors production. Persistent container lifetime means you are not starting from an empty database on every run; the same SQL, broker and Redis containers back both development and the Aspire-driven E2E CI run, so there is no "works on my machine" topology gap between developer and pipeline.
Health-gated startup, and which resource carries which probe
This is the part of the AppHost that is easiest to skim past and most expensive to get wrong. A
WaitFor edge on its own only proves that the target process started. Adding a health check to the
target turns every existing WaitFor into "the app is up and answering". Every service resource in this
AppHost carries one; only the probe differs, because of the Kestrel profile each target serves
(Program.cs:308-343 is the comment block that records all of this):
- Notification takes Aspire's stock
WithHttpHealthCheck("/alive")(Program.cs:140). It runs the ADR-012 mixedHttp1AndHttp2profile on its default endpoint (Notification.Service/appsettings.json:9-19) so the SignalR WebSocket Upgrade works, which means Aspire's default HTTP/1.1HttpClientgets a real answer. - Identity, Conference and Engagement take
WithH2cHealthCheck()(Program.cs:242, 213, 166). Those three run KestrelHttp2-only on cleartext (h2c prior knowledge) so gRPC clients negotiate HTTP/2 without TLS. A stock HTTP/1.1 probe is answered with GOAWAYHTTP_1_1_REQUIREDrather than the health payload, so it would never turn healthy, and since the gateway and the UIWaitForall four services an always-failing check would deadlock the whole stack at startup.
WithH2cHealthCheck lives in the framework
(MMCA.Common.Aspire.Hosting/H2cHealthCheckExtensions.cs:110-138). It issues the same GET over HTTP/2
with RequestVersionExact, registers the check under a key of the form {resource}-h2c-{endpoint}
(CheckKey, H2cHealthCheckExtensions.cs:75-76), and then calls WithHealthCheck(key), because
association and registration are two separate halves and a WaitFor gates only when both are present
(H2cHealthCheckExtensions.cs:135-137). A second call for the same resource and endpoint is a no-op
rather than a duplicate registration, which the health-check service rejects at startup
(H2cHealthCheckExtensions.cs:121-124, ledger at 153-199). The probe budget is two seconds
(H2cHealthCheckExtensions.cs:66) because Aspire polls the check for as long as a dependent resource is
waiting, and a probe slower than the poll interval turns the wait into a queue.
The class comment also records a rejected alternative so it is not re-proposed
(H2cHealthCheckExtensions.cs:24-35). A deployed service already runs a dedicated HTTP/1.1 probe listener
driven by the HealthProbe:Port key, so injecting that key locally and pointing a stock probe at it
looks tempting. Doing so flips ConfigureEndpointsWithHealthProbe out of endpoint-defaults mode into
explicit-listener mode, whose Listen calls override the ASPNETCORE_URLS binding Aspire injects: the
service stops listening on its Aspire-allocated port and every co-hosted service collides on one fixed
port. The probe listener is a deployment-only construct.
The second half of the rule is which path the gate probes. Every service startup gate is /alive
(liveness), never /health/ready, and the reason is a cycle (Program.cs:324-331). Service readiness
includes the ADR-025 warm-up gate, whose OIDC metadata task fetches the discovery document through the
gateway; the gateway in turn WaitFors these services. A readiness gate there means each service
hammers the not-yet-started gateway with 30 second Polly timeouts until warm-up fails open at roughly
90 seconds. The default path in the framework helper is /alive for the same reason
(DefaultProbePath, H2cHealthCheckExtensions.cs:53, with the rationale at 44-52).
The gateway resource carries the same rule in its sharpest form (Program.cs:337-343): its readiness
aggregate includes the downstream-{name} probes, so gating the Aspire startup edge on that aggregate
means any single unreachable downstream stops ui.WaitFor(gateway) from ever releasing. That is the
2026-08-29 UI WaitFor wedge, and how Store's run 33237319737 died before it: the stack never became
ready and a whole E2E run reported zero tests executed. The gateway therefore gates on /alive
(Program.cs:354), and /health/ready stays the load-balancer signal in deployed environments where the
ACA probe owns it. Only the UI, which nothing WaitFors, gates on /health/ready
(Program.cs:396).
[Rubric §29, Resilience and Business Continuity] assesses whether the system survives partial failure. The liveness-versus-readiness split here is that rubric applied to startup: readiness is the correct signal for a load balancer and the wrong one for a dependency graph, because readiness aggregates other resources and a startup gate that aggregates its own waiters cannot converge.
The WithSQLServerDataSource extension (and its Cosmos/SQLite siblings)
WithSQLServerDataSource is a framework extension method, not an AppHost-local helper. It lives in
MMCA.Common.Aspire.Hosting's Extensions class (Common.Aspire.Hosting/Extensions.cs:483-494). (It was
named WithDataSource until ADR-018; the rename to WithSQLServerDataSource gives it a consistent
With*DataSource shape with the two polyglot siblings below, a breaking API change swept across
consumers in one lockstep release, ADR-016.)
It is declared inside a C# preview extension(...) block rather than as a classic this-parameter
static method (Extensions.cs:410), which is why the receiver does not appear in the signature:
extension(IResourceBuilder<ProjectResource> service)
{
public IResourceBuilder<ProjectResource> WithSQLServerDataSource(
IResourceBuilder<SqlServerDatabaseResource> database,
string logicalName)
{
ArgumentNullException.ThrowIfNull(service);
ArgumentNullException.ThrowIfNull(database);
return service
.WithReference(database)
.WaitFor(database)
.WithEnvironment($"DataSources__{logicalName}__SQLServerConnectionString",
database.Resource.ConnectionStringExpression);
}
}
It injects the connection string once, and that single entry is the whole configuration
(Extensions.cs:473-478). DataSources__{logicalName}__SQLServerConnectionString feeds the MMCA.Common
multi-database routing: entities whose logical source matches logicalName are routed to this database.
With no top-level connection string present, the one database a host declares this way also seeds its
Default source, so the framework's own tables, the [Required] validation and the readiness health
check all resolve from it, and the logical name collapses onto Default: one SQLServerDbContext
instance, one EF change tracker, one migrations set per service. The Azure side matches exactly: the
Bicep hands Identity only DataSources__Identity__SQLServerConnectionString
(MMCA.ADC/infra/main.bicep:1686) plus its migrations assembly (main.bicep:1687) and
Outbox__DatabaseName (main.bicep:1688), with no top-level ConnectionStrings__SQLServerConnectionString
anywhere. The WaitFor(database) (Extensions.cs:492) ensures the service process does not start until
SQL Server is healthy.
Polyglot siblings (ADR-018). Two more helpers wire the non-SQL engines for the staged Conference
Session to Cosmos and Room to SQLite move:
WithCosmosDataSource(database, logicalName)(Extensions.cs:512-524) takes anAzureCosmosDBDatabaseResourceand injects two env vars,DataSources__{logicalName}__CosmosConnectionStringandDataSources__{logicalName}__CosmosDatabaseName(Cosmos'sUseCosmostakes the database name separately). It layers on top ofWithSQLServerDataSource(a service uses Cosmos for one module alongside its SQL Server source, Extensions.cs:506-507).WithSqliteDataSource(logicalName, filePath)(Extensions.cs:537-548) takes a file path (SQLite has no Aspire container resource) and injectsDataSources__{logicalName}__SqliteConnectionString(Data Source=<path>). It adds no reference and no wait, because there is no resource to wait on.
The four service hosts and their wiring
Services are declared in this order: Notification (Program.cs:127-141), Engagement (156-167), Conference (199-214), Identity (227-248). Declaration order matters because the gateway and UI references are added after all four are declared (Program.cs:344 onward). Each service follows the same pattern:
builder.AddProject<Projects.MMCA_ADC_{Module}_Service>("{name}", launchProfileName: "https")
.WithSQLServerDataSource({moduleDb}, "{Module}")
.WithReference(redis)
.WithSelectedBroker(withBroker)
.WaitFor(redis)
.WaitFor(mailDev)
.WithH2cHealthCheck() // or WithHttpHealthCheck("/alive") on Notification
.WithExternalHttpEndpoints();
launchProfileName: "https" selects the HTTPS launch profile from launchSettings.json so Aspire
registers both HTTP and HTTPS endpoints for service discovery (Program.cs:117-119 comment).
WithSelectedBroker(withBroker) resolves to one of the two WithBroker overloads. The RabbitMQ one
chains .WithReference(broker).WaitFor(broker).WithEnvironment("MessageBus__Provider", "RabbitMq")
(Common.Aspire.Hosting/Extensions.cs:258-261). The emulator one sets
MessageBus__Provider=AzureServiceBus plus two more variables:
MessageBus__ConnectionString (which ends in UseDevelopmentEmulator=true, the marker
AddBrokerMessaging keys its emulator branch off) and MessageBus__EmulatorAdminEndpoint, the
management-plane address MassTransit v8 needs for its second administration client
(Extensions.cs:286-291, connection-string shape at
ServiceBusEmulatorResource.cs:66-68, admin address at 80-82). Either way each service waits for the
broker to be healthy and gets the environment variable that makes AddBrokerMessaging() select the
transport. When that variable is absent (integration tests running via WebApplicationFactory),
AddBrokerMessaging short-circuits to in-process mode so existing tests continue to work without a real
broker (Program.cs:61-64).
Conference service has one extra line (Program.cs:210):
.WithEnvironment("Seeding__IncludeSampleConferenceData", "true")
This seeds sample speakers and sessions so the public browse grids are non-empty for the Playwright E2E suite. The comment (Program.cs:207-209) explicitly restricts this to local dev and E2E CI; production leaves it unset, and the AppHost never runs in production at all.
Identity service carries two extras. Seeding__IncludeSampleUsers=true (Program.cs:238) seeds the
well-known organizer/attendee accounts the Playwright suite logs in with; the comment
(Program.cs:235-237) restricts it to local dev and E2E CI so production creates no weak-credential
accounts. WithE2eRsaKeys() (Program.cs:248) is a framework extension, not AppHost-local code: when
E2E_JWT_PRIVATE_KEY_PEM and E2E_JWT_PUBLIC_KEY_PEM are present in the AppHost's own environment
(injected by the e2e.yml workflow for the ephemeral CI keypair) it maps them onto
Jwt__RsaPrivateKeyPem, Jwt__RsaPublicKeyPem and Jwks__RsaPublicKeyPem on the Identity resource
(Common.Aspire.Hosting/Extensions.cs:353-368). Without either variable the method returns the resource
untouched (Extensions.cs:359-362), so locally and in production user-secrets and Azure Key Vault are
used instead.
gRPC cross-service references
Six directed edges express the gRPC topology (Program.cs:268-292):
// Notification -> Identity
notificationService.WithReference(identityService).WaitFor(identityService);
// Engagement -> Conference
engagementService.WithReference(conferenceService).WaitFor(conferenceService);
// Conference -> Engagement (reverse edge, deliberately no WaitFor)
conferenceService.WithReference(engagementService);
// Engagement -> Notification (live-channel push, deliberately no WaitFor)
engagementService.WithReference(notificationService);
// Identity -> Engagement and Identity -> Notification (data-subject export aggregation, no WaitFor)
identityService.WithReference(engagementService);
identityService.WithReference(notificationService);
WithReference on a project resource injects services__{name}__http__0 (and https__0) environment
variables into the consumer. The AddTypedGrpcClient<T>(serviceName) call in each service's
Program.cs uses serviceName to resolve http://{name} through Aspire's service discovery. The
Engagement-to-Notification edge targets a named endpoint rather than the default one: Notification
keeps Http1AndHttp2 on its default endpoint so the SignalR WebSocket Upgrade works and declares a
separate Http2-only grpc endpoint on 8081 (Notification.Service/appsettings.json:15-18), so the
reference injects services__notification__grpc__0 and the client resolves http://_grpc.notification
(Program.cs:274-281, Notification.Contracts/DependencyInjection.cs:42).
The deliberate asymmetry in the Conference/Engagement pair is explained in the inline comment
(Program.cs:258-265): bidirectional WaitFor would produce a circular wait and deadlock. Instead,
transient "peer not ready" gRPC errors during startup self-heal via the Polly retry and circuit-breaker
pipeline baked into AddTypedGrpcClient. Engagement waits for Conference as a best-effort ordering hint
since Conference is the heavier producer (Program.cs:264-265). The three later edges omit WaitFor for
the same reason plus one of their own: Engagement's live-channel publish is fire-and-forget-with-logging
(Program.cs:274-280), and Engagement and Notification already depend on Identity, so a reciprocal wait
for the export aggregation would deadlock startup (Program.cs:282-292). Aspire's hosting package has no
gRPC-specific API here: these are stock WithReference calls, and MMCA.Common.Aspire.Hosting
deliberately adds none.
What consumes those injected variables lives in a third package, MMCA.Common.Grpc. On the server side
AddGrpcServiceDefaults() registers AddGrpc with the GrpcResultExceptionInterceptor (mapping
Result failures to RpcException), turns detailed errors off, and adds server reflection
(MMCA.Common.Grpc/DependencyInjection.cs:26-38); all four ADC services call it (Notification
Program.cs:228, Identity 304, Engagement 297, Conference 362). On the client side
AddTypedGrpcClient<TClient>(serviceName) (DependencyInjection.cs:66-117) sets the address to
http://{serviceName}, which is HTTP/2 cleartext with prior knowledge, adds the
JwtForwardingClientInterceptor so an inbound bearer token rides along to the peer, forces a
SocketsHttpHandler (the global ConfigureHttpClientDefaults wrapper can otherwise defeat HTTP/2
negotiation, DependencyInjection.cs:80-97) and applies a resilience pipeline from
GrpcResilienceDefaults (DependencyInjection.cs:106-115). That type is the interesting one: its
timeouts and retry budget are re-exposed from HttpResilienceDefaults so the east-west path cannot
drift from the outbound-HTTP path (Core/MMCA.Common.Shared/Resilience/GrpcResilienceDefaults.cs:15-24),
while its circuit-breaker shape is stated explicitly (failure ratio 0.5 at line 27, minimum throughput
10 at line 30, break duration 10 seconds at line 33) because an east-west gRPC call addresses a peer
directly and bypasses the gateway's destination health checks, so the breaker is the only thing that notices
a peer going bad. The h2c choice is not a shortcut: Aspire's endpoint discovery does not reliably
produce a services__{name}__https__0 key for project resources, so the resolver falls back to http
regardless of the requested scheme, and the target must therefore serve HTTP/2 on its cleartext endpoint
(DependencyInjection.cs:44-52).
[Rubric §7, Microservices Architecture] assesses how cleanly services are decoupled and how
inter-service calls are managed. Declaring WithReference only for actual call edges (not broadcasting
every service to every other service) keeps the service-discovery injection minimal and makes the
topology readable as code.
[Rubric §29, Resilience and Business Continuity] assesses whether the system can survive partial failures. The no-WaitFor choice on the reverse edges, combined with a resilience pipeline on the typed gRPC clients, means a slow-starting peer never blocks the whole stack from reaching a healthy state.
The Gateway
var gateway = builder.AddProject<Projects.MMCA_ADC_Gateway>("gateway")
.WithReference(notificationService)
.WithReference(engagementService)
.WithReference(conferenceService)
.WithReference(identityService)
.WaitFor(notificationService)
.WaitFor(engagementService)
.WaitFor(conferenceService)
.WaitFor(identityService)
.WithExternalHttpEndpoints()
.WithHttpHealthCheck("/alive")
.WithEndpoint("https", endpoint => endpoint.Port = 6001);
Program.cs:344-355. The gateway has no WithSQLServerDataSource, no WithReference(redis), and no
broker attachment. It is stateless: no database, no cache, no broker traffic (Program.cs:304-306). The
four WithReference calls are purely for Aspire service-discovery injection, they make the gateway able
to resolve http://identity, http://conference, and so on at runtime through YARP's configured route
table. The HTTPS endpoint is pinned to port 6001 (Program.cs:355) because the MAUI native client has
this URL baked into its appsettings.json and cannot participate in Aspire's dynamic port allocation
(Program.cs:300-302).
The gateway waits for all four backend services before Aspire marks it healthy. This means the UI, which in turn waits for the gateway, only starts after the full backend is ready, a deliberate staging of the startup sequence.
Why the gateway proxies JWKS discovery, and stamps the issuer
Identity runs Http2-only on cleartext (h2c prior knowledge) so gRPC clients can negotiate HTTP/2
without TLS. The downside is that the default JwtBearer backchannel HttpClient sends HTTP/1.1, which
a Kestrel Http2-only endpoint rejects. The solution (Program.cs:357-366 and
Common.Aspire.Hosting/Extensions.cs:309-337) is WithJwksDiscovery, which sets
Authentication__JwtBearer__Authority to the gateway's HTTPS endpoint rather than Identity's:
notificationService.WithJwksDiscovery(identityService, gateway);
engagementService.WithJwksDiscovery(identityService, gateway);
conferenceService.WithJwksDiscovery(identityService, gateway);
The gateway accepts both HTTP/1.1 and HTTP/2 via ALPN, and its /.well-known/* route forwards the
discovery fetch on to Identity over h2c internally. So the JwtBearer middleware's HTTP/1.1 metadata
fetch works end-to-end without any workaround on the services themselves.
The other half of that contract is one line further down (Program.cs:375):
identityService.WithEnvironment("Jwt__Issuer", gateway.GetEndpoint("https"));
The issuer Identity stamps into every token it signs has to be the same URL its peers just discovered,
or every cross-service call fails validation on the issuer claim. The appsettings default hardcodes
the pinned dev port for a standalone F5 run, so deriving the value from the gateway resource means an
Aspire run cannot mint wrong-issuer tokens if that port ever moves (Program.cs:368-374). Production sets
the equivalent from the gateway's ACA FQDN (MMCA.ADC/infra/main.bicep:1711).
[Rubric §11, Security] assesses how credentials and tokens flow through the system. No symmetric JWT secret is shared between services; each non-Identity service fetches the RSA public key dynamically. Routing that fetch through the gateway rather than hitting Identity directly keeps the internal topology (cleartext h2c) invisible to callers, and deriving the issuer from the same resource keeps discovery and issuance from drifting apart.
The Blazor UI
var ui = builder.AddProject<Projects.MMCA_ADC_UI_Web>("ui", launchProfileName: "https")
.WithExternalHttpEndpoints()
.WithReference(gateway)
.WithReference(notificationService)
...
.WaitFor(gateway)
...
.WithHttpHealthCheck("/health/ready")
.WithEndpoint("https", endpoint => endpoint.Port = 6002);
Program.cs:381-397. The UI waits for the gateway and the four services, ensuring it does not open to
traffic while the backend is still cold. It is the one resource gated on /health/ready rather than
/alive, and it can be: nothing WaitFors the UI, so there is no cycle to close, and the UI head runs
the default Http1AndHttp2 profile so the stock probe answers (Program.cs:393-395). Port 6002 is pinned
for the same reason as 6001: E2E tests and out-of-Aspire clients need a stable address
(Program.cs:378-380).
Three environment-derived values are then pushed back onto other resources, all of them things only the AppHost knows:
Api__ApiEndpointandApi__WasmApiEndpointon the UI (Program.cs:450-451). These are both halves of the splitinfra/main.bicep:2516and2518make in production, derived from the gateway resource instead of hardcoded. The WASM one is what/client-confighands the browser (and what the Blazor CSP pinsconnect-srcto), so it must be an externally reachable URL: a browser cannot resolve an Aspire service-discovery name. The server-side one is overridden here because exactly one consumer is not anIHttpClientFactoryclient:NotificationHubServicebuilds a SignalRHubConnection, which creates its own message handler and never sees the service-discovery handler (Program.cs:437-449).OAuth__UIBaseUrlon Identity (Program.cs:455), so the post-login OAuth redirect lands on the UI host rather than staying on the API server.PasswordReset__ResetUrlon Identity (Program.cs:460-462), built asReferenceExpression.Create($"{ui.GetEndpoint("https")}/reset-password"). The UI port is dynamic under Aspire, so the appsettings default (which serves a standalone F5 run) would point the emailed link at a host that is not listening (Program.cs:457-459).
Between the UI declaration and those lines sit the E2E switches (Program.cs:399-435), all absent in
production. E2E_FORCE_WASM sets E2E__ForceWebAssembly on the UI to run the suite without a Blazor
Server circuit; it is a local fast-box option only, since on the two-core hosted runner the per-context
WASM boot never wins the race against the suite's waits, so e2e.yml deliberately does not set it
(Program.cs:400-405, 411-415). E2E_FORCE_SERVER sets E2E__ForceServer to pin the UI to
InteractiveServer, and loses to E2E_FORCE_WASM when both are set (Program.cs:417-426).
The other two switches are not if blocks in the AppHost at all, they are single calls:
identityService.WithE2eRegistrationThrottleLift(alsoLiftWhen: forceWasm); // Program.cs:428
gateway.WithE2eGatewayRateLimitLift(alsoLiftWhen: forceWasm); // Program.cs:435
For both, the trigger (E2E_LIFT_REGISTRATION_THROTTLE), the target setting names and the lifted values
live inside the framework extension, so the AppHost passes only its own extra condition: forcing WASM
implies the same request volume, hence alsoLiftWhen: forceWasm. The registration literal is the
framework constant E2eRegistrationsPerIpPerHour = 1000 (Common.Aspire.Hosting/Extensions.cs:35),
lifted from the production default of 10
(MMCA.Common.Infrastructure/Auth/LoginProtectionSettings.cs:37), because the suite registers far more
than ten accounts from one localhost IP. The gateway lift is covered in the package section below.
Where service defaults come from, MMCA.Common.Aspire, not a local project
There is no MMCA.ADC.ServiceDefaults project. Source/Hosting/ contains the AppHost and the four
per-service migrations assemblies, nothing else. The conventional Aspire "ServiceDefaults" shared project
that scaffolding generates has been deleted; each service host (and the UI, and the Gateway) instead
calls AddServiceDefaults() from the framework's MMCA.Common.Aspire package, and MapDefaultEndpoints()
to expose the health endpoints. All six ADC deployables do this: Notification (Program.cs:86 / 243),
Engagement (83 / 311), Conference (101 / 376), Identity (97 / 314), the Gateway (57 / 136) and the UI
(30 / 119). This means the OpenTelemetry wiring, health checks, service discovery, and Polly resilience
are identical across both downstream apps (ADC and Store, neither of which has a ServiceDefaults
project any more) and are versioned in lockstep with the rest of the framework, there is no per-app copy
to drift. The full behavior of those methods is documented in the next section.
Framework host extensions that are opt-in per host
Not every framework host extension is bundled into the defaults. Several are opt-in per host, called
from each host's own Program.cs, and most are gated on a configuration key that is absent locally,
so they no-op on a developer machine and in the test tiers while doing real work in Azure. That gating
is what keeps local-to-cloud parity honest: the same call sites run in both places, and only the
configuration differs.
AddCommonSerilog(path)(MMCA.Common.Aspire/Logging/SerilogHostExtensions.cs:48-56) is called beforeAddServiceDefaultsin every ADC service (for example Notification Program.cs:85-86). The provider registration is the load-bearing part, and the comment says why (SerilogHostExtensions.cs:16-19, echoed at Notification Program.cs:79-84):UseSerilog()replaces the wholeILoggerFactoryand so silently bypasses every other provider, including the OpenTelemetry to Azure Monitor oneAddServiceDefaultswires. That bypass is why no application log line from these hosts ever reached Application Insights, while the Gateway (which never calledUseSerilog) did show up.builder.Logging.AddSerilog(Log.Logger, dispose: true)(line 55) adds Serilog as one provider alongside the others. Production then caps what ships to Azure Monitor withLogging__OpenTelemetry__LogLevel__Default=Warning(MMCA.ADC/infra/main.bicep:257-266); Serilog keeps writing Information to stdout, so container logs stay complete while only warnings and above bill against the workspace.ConfigureEndpointsWithHealthProbe(protocols, redeclareCleartextEndpoint, cleartextPort)(MMCA.Common.Aspire/Kestrel/KestrelEndpointExtensions.cs:77-101) appliesprotocolsto every Kestrel endpoint default and, whenHealthProbe:Portis set (HealthProbePortConfigKey, KestrelEndpointExtensions.cs:31), adds a dedicated Http1-only listener for the platform probes. Both deployed profiles are this one call with different arguments, and the second parameter is the reason there are two (KestrelEndpointExtensions.cs:45-59). The three h2c REST services passHttpProtocols.Http2and keepredeclareCleartextEndpointat its default oftrue(for exampleMMCA.ADC.Conference.Service/Program.cs:85), because an explicitListencall overrides the container'sASPNETCORE_HTTP_PORTSdefault binding and the main h2c endpoint has to be re-declared alongside the probe port or it disappears. Notification passesHttpProtocols.Http1AndHttp2, redeclareCleartextEndpoint: false(MMCA.ADC.Notification.Service/Program.cs:70) because its two endpoints come from configuration already; there the probe listener is strictly additive and nothing re-binds a port the configuration owns. A non-integer probe port throws at startup rather than silently producing no listener, since the platform would then probe a closed port and the revision would never come up (KestrelEndpointExtensions.cs:72-76). In Azure the key is8081on Identity, Conference and Engagement (main.bicep:1682, 1909, 2049) and8082on Notification (main.bicep:2201), because the ADR-012 mixed profile already owns 8080 for ingress and 8081 for gRPC there (main.bicep:2159-2164, 2259-2260). Locally the key is absent on every host, which is exactly why the AppHost has to use the h2c probe instead.AddCommonKeyVaultConfiguration()(MMCA.Common.Aspire/Configuration/KeyVaultConfigurationExtensions.cs:78-111) layers an Azure Key Vault over the host configuration, so a secret in the vault reads back throughIConfigurationlike any other setting and overrides the sources added before it. The gate isKeyVault:Uri: absent or whitespace and the method returns the builder untouched (KeyVaultConfigurationExtensions.cs:80-87), which is why local dev, CI and the integration tier take no Azure dependency at startup. Two details worth knowing. It is called early, before anything binds settings, becauseConfigurationManagerbuilds and loads each source as it is added (MMCA.ADC.Conference.Service/Program.cs:110, with the comment at 105-109 saying exactly that); and a secret name cannot contain a colon, so the provider maps--onto the configuration separator, meaningJwt--SigningKeyarrives asJwt:SigningKey(KeyVaultConfigurationExtensions.cs:44-45). Authentication isDefaultAzureCredential(line 109), so a deployed host uses its managed identity. All four ADC services call it, as does the UI (MMCA.ADC.UI.Web/Program.cs:38).AddCommonDataProtection()(MMCA.Common.Aspire/DataProtection/DataProtectionExtensions.cs:52-87) persists the ASP.NET Core DataProtection key ring to a single Azure blob so every replica of a scaled-out host shares one key ring. The default key ring is per-process and in memory, which is correct for one process and wrong for two: a cookie or antiforgery token minted by replica A cannot be decrypted by replica B, and the user sees sign-outs that follow the load balancer rather than any pattern. The gate isDataProtection:BlobStorageUri(DataProtectionExtensions.cs:54-61). A second, deliberately separate gate,DataProtection:KeyVaultKeyUri, encrypts that key ring at rest (DataProtectionExtensions.cs:81-85): the two are not folded together because blob persistence is what fixes cross-replica decryption and has to work on its own, since the Key Vault Crypto User role is granted out of band and can lag a deployment, so coupling them would turn a missing role assignment into a total auth outage. The two ADC hosts that do cookie cryptography call it: Identity (MMCA.ADC.Identity.Service/Program.cs:112) and the UI (MMCA.ADC.UI.Web/Program.cs:40).AddRedisCaching()/AddRedisOutputCaching()(MMCA.Common.Aspire/Caching/RedisCachingExtensions.cs:57-66and91onward) register the Aspire distributed cache plus theIConnectionMultiplexerthatDistributedCacheServiceneeds for SCAN-based prefix eviction, and back the output cache with the same Redis. Both no-op when the connection string is absent (RedisCachingExtensions.cs:59-62), where the in-memory fallback is the documented behavior. The load-bearing argument isDisableHealthChecks = trueon both registrations (RedisCachingExtensions.cs:64-65): the raw Aspire integration registers an untagged StackExchange.Redis check, which/health/readytherefore includes, and against Azure Managed Redis it issuesCLUSTER INFO, a command StackExchange.Redis 3.x refuses outside admin mode. Every probe threw against a healthy cache and every revision failed activation. Common contributes its own PING-only check instead (seeAddInfrastructureHealthChecksbelow). Conference Program.cs:122-141 records the whole incident inline. Conference gates one more cache on the same key:AddCommonHybridCache()runs only when aredisconnection string is present (Conference.Service/Program.cs:149-152), because without Redis the second level is the in-memory distributed cache and an L1 in front of it buys nothing.AddScheduledJobs(builder.Configuration)(MMCA.Common.Infrastructure/DependencyInjection.cs:391-405) registers the recurring-job runner: it binds theSchedulersection and addsScheduledJobRunneras a hosted service viaTryAddEnumerable(notAddHostedService, so two callers in one process cannot end up with two runners racing for the same job rows, DependencyInjection.cs:398-402). Registering it is not the same as turning it on: the runner'sExecuteAsynclogs once and returns whenScheduler:Enabledis false (MMCA.Common.Infrastructure/Scheduling/ScheduledJobRunner.cs:77). The runner owns no jobs of its own; jobs areIScheduledJobimplementations contributed separately. The framework ships one,AuditTrailCleanupJob(MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailCleanupJob.cs:48), registered byAddAuditTrailand running daily at 03:00 UTC (CronExpression => "0 3 * * *", AuditTrailCleanupJob.cs:67) to prune the change trail. Conference, Engagement and Identity all callAddScheduledJobs(Conference.Service/Program.cs:293, comment 291-292).
[Rubric §11, Security] assesses how secrets and key material reach a running process. Vault-backed configuration keeps connection strings and signing keys out of both the repository and the process environment, and a shared, optionally vault-encrypted DataProtection key ring keeps auth cookies portable across replicas. Both are single calls in the framework, so every consumer opts in the same way rather than inventing its own.
One more AI signal is worth noting because ADC's session scorer is its only producer. Conference
subscribes its host to the "MMCA.Common.AI" meter and activity source explicitly
(Conference.Service/Program.cs:172-174), a name AddServiceDefaults also registers
(MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.Telemetry.cs:86 and :315). It carries the
paid-call cost counters mmca.ai.input_tokens and mmca.ai.output_tokens, emitted by the governed chat
client rather than by ADC code and tagged with model, prompt_name, prompt_version and provider
(MMCA.Common/Source/Core/MMCA.Common.AI/Observability/AiUsageMeter.cs:26-32, tags at :161-164).
They replace the former per-service MMCA.ADC.Conference.Scoring meter and its scoring.tokens.*
counters, and the AI spend alert queries the new names (Conference.Service/Program.cs:156-165). Reusing
the one name is the point: it is also the governed client's ActivitySource, so one name turns on both
the token metrics and the per-call spans. The tags that move spend are the model (a swap changes the
per-token price) and the prompt version (a revision changes the token count), which is why these
counters are the aggregate a budget alert queries. A background scoring run that exhausts its retries is
no longer counted on a Conference meter: it runs as an internal command
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/ScoreEventSessionsInternalCommand.cs:39),
so the framework's internal_commands.dead_letter.count, tagged reason attempts_exhausted, reports
it
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/Processing/InternalCommandMetrics.cs:52-58).
Like the framework meters, the name is a literal (Conference.Service/Program.cs:164-165) so the host's
startup wiring does not have to
reference an Infrastructure type.
MMCA.Common.Aspire, the framework service-defaults package
Source:
MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.csTags:.../HealthCheckTags.csTelemetry:.../Telemetry/OutboxPollFilterProcessor.csSecurity:.../Security/SecurityHeaders.csWarmup:.../Warmup/Health:.../Health/RedisPingHealthCheck.csGateway building blocks:.../Gateway/
This package is the canonical, and the only, service-defaults implementation. Each ADC service host calls its methods directly; there is no ADC-local copy that shadows or extends it.
AddServiceDefaults<TBuilder>
MMCA.Common.Aspire/Extensions.cs:30-90. The Extensions class is partial: this file holds the
service defaults and the warm-up registration, while the health half lives in Extensions.Health.cs and
the telemetry half in Extensions.Telemetry.cs. Called early in each service's Program.cs,
AddServiceDefaults chains ConfigureOpenTelemetry() (Extensions.cs:32), AddDefaultHealthChecks(),
which adds a "self" check tagged "live" (Extensions.cs:33; Extensions.Health.cs:25-28),
AddWarmupReadiness() (Extensions.cs:34), and Services.AddServiceDiscovery() (Extensions.cs:35). It
then applies a Polly resilience pipeline to every HttpClient (ConfigureHttpClientDefaults,
Extensions.cs:39-87) with 30 s per-attempt / 60 s circuit-breaker sampling / 90 s total-request timeouts
and one retry per hop (Extensions.cs:49-54). That one retry is switched off for POST and PATCH
(Extensions.cs:63): an attempt that timed out may still be running server-side, and most hops carry no
idempotency key, since a server-to-server call never does and the UI's "APIClient" stamps one only on
EntityServiceBase creates, whose replay the UI service base classes own (Extensions.cs:56-59). GET,
PUT, DELETE and the other idempotent verbs keep their retry, which is why the code names the two verbs
instead of calling DisableForUnsafeHttpMethods, a helper that would also switch the retry off for PUT
and DELETE (Extensions.cs:60-62). Finally it installs a SocketsHttpHandler tuned explicitly for Azure
Container Apps Consumption plan (Extensions.cs:78-86):
PooledConnectionLifetime = 10 min, forces connection recycling so DNS changes during ACA replica rollovers are picked up without an app restart.PooledConnectionIdleTimeout = 5 min, keeps idle connections in the pool long enough for low-traffic inter-service calls to reuse them.KeepAlivePingDelay = 60 swithKeepAlivePingTimeout = 30 s, socket-level keep-alive pings prevent idle TCP connections from being dropped by Azure's load balancer, without generating HTTP traffic that would shift the replica from idle-vCPU billing (roughly 8x cheaper) to active billing.EnableMultipleHttp2Connections = true, avoids a single multiplexed connection becoming a bottleneck under concurrent requests.
None of those numbers are literals here. They all come from HttpResilienceDefaults in
MMCA.Common.Shared (Core/MMCA.Common.Shared/Resilience/HttpResilienceDefaults.cs:13-43), which
exists so the Aspire HTTP defaults and the MMCA.Common.Grpc typed clients cannot drift apart: the
hand-mirrored copies previously did, leaving the gRPC side on the 10 s / 30 s library defaults
(HttpResilienceDefaults.cs:3-9). MaxRetryAttempts is deliberately 1 (HttpResilienceDefaults.cs:28),
because the UI service base classes own the user-facing retries and stacking a full budget at every hop
turned a backend brownout into an up-to-16x request storm (HttpResilienceDefaults.cs:21-27).
Health-check tags, the vocabulary the endpoints filter on
HealthCheckTags.cs defines the three tag literals every check in the framework is classified with:
Live (line 12), Ready (line 18) and Optional (line 32). Reading the doc comment on Optional
(HealthCheckTags.cs:20-31) is the fastest way to understand the whole health model: a distributed cache
sitting behind an in-memory fallback, or a broker behind a retrying outbox, can fail without the app
losing the ability to serve, and gating readiness on it converts a partial degradation into a total
outage because every replica goes unready simultaneously. Leave a check untagged only when the app
genuinely cannot serve correct responses without it, its own database being the standard example. See
HealthCheckTags.
The endpoint paths are declared once alongside the tags, in HealthEndpointPaths
(.../HealthEndpointPaths.cs:11, 14, 17), with an IsProbePath predicate that also matches anything
below /health/ (HealthEndpointPaths.cs:29-33). That single declaration is what keeps the mapping, the
probe-telemetry filters described below and any host bypass list from drifting apart: a probe route
added to MapDefaultEndpoints is automatically a probe route everywhere else
(HealthEndpointPaths.cs:3-7).
Warmup infrastructure
AddWarmupReadiness<TBuilder> (Common.Aspire/Extensions.cs:101-112) registers:
WarmupReadinessGate(singleton), a boolean gate that opens when all warm-up tasks finish.WarmupHostedService, runs all registeredIWarmupTaskimplementations on startup, then opens the gate in afinallyblock (Warmup/WarmupHostedService.cs:58), so a failing task degrades to a slow start rather than a permanently unready replica. Each task is capped at 120 seconds (TaskTimeoutSeconds, WarmupHostedService.cs:42), a backstop rather than a latency budget: the built-in OIDC task is already bounded at 90 s by the resilience pipeline, so a task reaching this limit is one waiting on something that will never arrive (WarmupHostedService.cs:36-41).WarmupReadinessHealthChecktagged"ready"(Extensions.cs:108-109), reports unhealthy until the gate opens. Because it appears on/health/ready(the readiness probe) but not on/alive, ACA ingress holds back user traffic from a replica that is still warming up.OpenIdConnectMetadataWarmupTask, pre-fetches{authority}/.well-known/openid-configuration, where{authority}is theAuthentication:JwtBearer:AuthoritykeyWithJwksDiscoverysets; with that key unset the task returns immediately (Warmup/OpenIdConnectMetadataWarmupTask.cs:32-36). The problem it solves is documented in the class summary (OpenIdConnectMetadataWarmupTask.cs:6-13): on a CPU-throttled idle ACA replica, a lazy metadata fetch on the first request can stretch past the client timeout, producing the "first request fails, second succeeds" pattern. What it warms is narrower than "the connection", and the remarks say so (OpenIdConnectMetadataWarmupTask.cs:14-22): the authority's own discovery-document cache and the host-level DNS path, plus one connection in the task's ownIHttpClientFactoryclient pool (the fetch is at OpenIdConnectMetadataWarmupTask.cs:47-48). The JwtBearerConfigurationManagercaches discovery state separately and fetches through its own default backchannelHttpClient, because noBackchannelorBackchannelHttpHandleris configured, so the first authenticated request still performs its own fetch over a connection of its own. What the warm-up buys is a warmed authority, not a shared warm connection.
Hosts can contribute their own tasks with services.AddWarmupTask<TTask>() (Extensions.cs:124), and
three ADC services do. Identity (Program.cs:169), Engagement (Program.cs:157) and Conference
(Program.cs:257) each register a subclass of the framework's SelfHttpWarmupTaskBase
(Warmup/SelfHttpWarmupTaskBase.cs:28-33), which replays a short list of hot read paths against the
host's own Kestrel endpoint once the server is listening. The OIDC task warms the authority side
of one outbound call; this warms the full inbound path (Kestrel, output cache, routing, authentication,
controller, EF Core, SQL), which is where an idle CPU-throttled replica's cold-start cost actually sits
(SelfHttpWarmupTaskBase.cs:11-15). Two details are load-bearing. The request version defaults to HTTP/2
with RequestVersionExact (SelfHttpWarmupTaskBase.cs:70, 77), because these hosts serve h2c only and
an OrLower policy would downgrade to HTTP/1.1 and fail the warm-up on every startup
(SelfHttpWarmupTaskBase.cs:64-68); and the whole task is skipped under the Testing environment
(SelfHttpWarmupTaskBase.cs:41-46), where WebApplicationFactory's in-memory TestServer opens no
socket for a self-request to reach. The derived class contributes only the path list, and that list has
to match the URLs real callers issue in their values, not just their shape, or a query-keyed output
cache is warmed with an entry nothing ever reads (SelfHttpWarmupTaskBase.cs:51-59).
[Rubric §12, Performance] assesses whether the system is tuned for its hosting environment. The
SocketsHttpHandler tuning and the OIDC warmup task both target the same class of problem: ACA
Consumption plan cold-starts and idle-replica penalties. Solving them in the framework means every
consumer inherits the fix.
ConfigureOpenTelemetry<TBuilder>
MMCA.Common.Aspire/Extensions.Telemetry.cs:71-145. Configures OTel logging (IncludeFormattedMessage +
IncludeScopes, Extensions.Telemetry.cs:73-77), metrics (ConfigureMetrics, Extensions.Telemetry.cs:244-342:
ASP.NET Core, HttpClient, .NET runtime), and tracing (the application's own source plus
"MMCA.Common.Outbox", "MMCA.Common.InternalCommands" and the optional AI package's "MMCA.Common.AI",
Extensions.Telemetry.cs:83-86 and 44, with ASP.NET Core and HttpClient instrumentation). Four
MMCA.Common-specific additions stand out:
MMCA.Common meters, nine of them (Extensions.Telemetry.cs:308-316). Seven are the operational meters below; the other two are
"MMCA.Common.InternalCommands"(the internal-command queue's meter) and"MMCA.Common.AI"(the optional AI package's token spend plus call duration, inert until a host adds that package and enablesAi:Enabled, Extensions.Telemetry.cs:305-307)."MMCA.Common.Outbox"(outbox counters and dispatch lag),"MMCA.Common.Cqrs"(RED histograms for command/query handlers plus query cache hit/miss),"MMCA.Common.Idempotency"(the idempotency filter's replay, conflict and degraded counters),"MMCA.Common.Scheduler"(the recurring runner's run outcomes, duration and schedule lag, inert in a host that never setsScheduler:Enabled),"MMCA.Common.Broker"(consumer faults plus outbox circuit-breaker openings, inert on the in-process bus),"MMCA.Common.OutputCache"(the eviction consumer's failed tag evictions) and"MMCA.Common.BestEffort"(swallowed failures of best-effort side effects). They are registered by literal name because the Aspire package has no project reference to the assemblies that define them (Extensions.Telemetry.cs:297-307).Polly's own meter (Extensions.Telemetry.cs:323,
PollyMeterName = "Polly"at 31). The standard resilience handler runs on everyHttpClientand every gRPC typed client, so it is the component that decides whether an inter-service call is retried, timed out or refused by an open circuit; until this meter was subscribed none of that left the process, and a brownout looked from the outside exactly like latency (Extensions.Telemetry.cs:318-322, citing ADR-009).OutboxPollFilterProcessoris added to the tracing pipeline (Extensions.Telemetry.cs:122) before the exporters, so itsOnEndruns first (comment at Extensions.Telemetry.cs:117-121).Six cost knobs ([Rubric §31, Cost and FinOps]): four that change nothing until a host sets them, and two whose saving is the default.
Telemetry:DisableHttpClientMetrics(Extensions.Telemetry.cs:248-276) andTelemetry:DisableRuntimeMetrics(Extensions.Telemetry.cs:278-295) each drop a metric family. Both branches are more than "skip the instrumentation call", and the comments explain why (Extensions.Telemetry.cs:257-266 and 284-286): a deployed host also callsUseAzureMonitor(), and the Azure Monitor distro adds theSystem.Net.HttpandSystem.Runtimemeters itself, so skippingAddHttpClientInstrumentationlefthttp.client.open_connectionsas the single largest AppMetrics stream in both production workspaces despite the toggle being on. AMetricStreamConfiguration.DropView applies to the whole MeterProvider regardless of which component added the meter, which is what makes the toggle authoritative instead of advisory (System.Net.NameResolutionrides along because DNS-lookup metrics carry no signal without the rest of the family). The third knob,Telemetry:TracesSampleRatio, installs aParentBasedSamplerover aTraceIdRatioBasedSamplerfor head-based trace sampling (Extensions.Telemetry.cs:138-139). Traces are the largest observability line item while unsampled; once sampled, metrics can outweigh them (Extensions.Telemetry.cs:132-137, pointing at ADR-041). A value outside the open interval (0,1), or one that fails to parse, falls back to "sample everything" rather than silently blinding the host (TryGetTraceSampleRatio, Extensions.Telemetry.cs:186-199; the same defensive shape inIsInstrumentationDisabled, Extensions.Telemetry.cs:209-210). The fourth,Telemetry:FilterProbeTelemetry, is the first of the two that default on and is covered on its own below (IsProbeTelemetryFilterEnabled, Extensions.Telemetry.cs:234-235). A fifth,Telemetry:DisableAspNetCoreMetrics, drops the ASP.NET Core meter family (http.server.*,kestrel.*,aspnetcore.*,signalr.server.*) with the same whole-providerDropView, matched on theMicrosoft.AspNetCore.meter-name prefix, for the same reason: the distro adds those meters on its own (ConfigureAspNetCoreMetrics,MMCA.Common.Aspire/Extensions.Telemetry.cs:353-369). The sixth,Telemetry:EnablePollyDurationMetrics(Extensions.Telemetry.cs:25), is the second default-on saving: unless a host sets it totrue(while debugging a retry storm, say), aDropView removes Polly's two duration histograms,resilience.polly.strategy.attempt.durationandresilience.polly.pipeline.duration(Extensions.Telemetry.cs:333-341). They are per-bucket streams on a pipeline that runs on every outbound call and re-measure whathttp.client.request.durationand the dependency traces already report, while theresilience.polly.strategy.eventscounter (retries, circuit opens and closes, timeouts) is never dropped, because it is the signal the meter exists for (Extensions.Telemetry.cs:325-332). ADC's production Bicep sets four of the first five explicitly:Telemetry__TracesSampleRatio=0.25(MMCA.ADC/infra/main.bicep:279-282, rationale at 275-278),Telemetry__DisableHttpClientMetrics=true(main.bicep:312-315) andTelemetry__DisableRuntimeMetrics=true(main.bicep:316-319), those two measured at roughly 65% of AppMetrics ingestion, about 290 MB of a 500 MB daily stream (main.bicep:305-311), andTelemetry__DisableAspNetCoreMetrics=true(main.bicep:325-328), whose family was 73% of workspace ingestion over 2026-09-22..28 with no alert reading it, since request latency and failures alert offAppRequests(main.bicep:321-324). Alongside them it sets the distro's ownAzureMonitor__EnableLiveMetrics=false(main.bicep:335-338): Live Metrics is on by default and keeps every replica talking to the Live Metrics service with nobody watching, which held every app just above the Container Apps idle line so that 64% of vCPU-seconds billed at the active rate (main.bicep:330-334). All six container apps carry that entry (for example main.bicep:1729 on Identity and 2461 on the gateway). It also stretches the OTel export cadence with the standardOTEL_METRIC_EXPORT_INTERVAL=300000(main.bicep:346-349): the exporter ships cumulative aggregates, so a 5x longer interval drops roughly 80% of the remaining datapoints while five-minute alert windows keep the same signal (main.bicep:340-345). The Bicep sets noTelemetry__EnablePollyDurationMetrics, so production keeps the Polly histograms dropped. An unset host keeps every other metric family, samples every trace and exports on the SDK's 60 second default.
MapDefaultEndpoints
MMCA.Common.Aspire/Extensions.Health.cs:123-159. Maps three endpoints, each at its HealthEndpointPaths
constant rather than a literal, and each declared anonymous explicitly: a host that adopts the framework's
fallback authorization policy would otherwise 401 its own liveness and readiness probes, and a probe that
cannot answer takes the replica out of rotation (Extensions.Health.cs:125-127).
/health(Extensions.Health.cs:134), all checks must pass; used by humans and dashboards./alive(Extensions.Health.cs:138-141), liveness probe:"live"-tagged checks only, so a transient dependency outage (SQL Server down, say) does not mark the process dead and get it killed./health/ready(Extensions.Health.cs:154-156), readiness: everything except"live"-only and"optional"checks. This includes the warmup check (tagged"ready") and any untagged dependency checks, so a replica still in cold-start or with a failing dependency is removed from ACA ingress without being killed. The"optional"exclusion is deliberate and the comment above it (Extensions.Health.cs:148-153) restates the rule fromHealthCheckTags: a dependency the app degrades gracefully without must not gate readiness, or a partial degradation takes every replica unready at once and becomes a total outage. Those checks still surface on/health, so the degradation is visible without being self-inflicted.
/health and /health/ready are served by MapCachedHealthChecks (Extensions.Health.cs:170-192), not by
plain MapHealthChecks: a GET/HEAD endpoint that asks CachedHealthReportProvider for the report,
answers 503 only when the status is Unhealthy (200 otherwise, so Degraded still passes) and writes the
status as text/plain. The provider is a short-TTL, single-flight report cache that
AddDefaultHealthChecks registers next to the "self" check, with HealthReportCacheOptions bound from
configuration and validated at startup (Extensions.Health.cs:30-38). The reason is SEC-Common-71 /
SEC-ADC-17: both paths sit on a rate-limit bypass (see the gateway pipeline below), and that bypass cannot
bound the amplification of an anonymous flood in which every request runs a full round of dependency
probes. The cache can: one probe round per window instead of one per request
(Extensions.Health.cs:129-133). /alive is deliberately left uncached; it runs only the "self" check,
which answers Healthy without touching a dependency (Extensions.Health.cs:28, 136-141).
The checks that carry that "optional" tag come from AddInfrastructureHealthChecks(bool requireDatabase)
(Extensions.Health.cs:69-107), which a host calls separately from AddServiceDefaults; all four
ADC services pass requireDatabase: true (for example Notification Program.cs:128). It registers:
- Relational checks, one per distinct database (
AddDatabaseHealthChecks, Extensions.Health.cs:208-239). Three engines are read, SQL Server, PostgreSQL and SQLite (Extensions.Health.cs:213-215, registered at 225-238), because a host picks its engine from configuration, and both the top-levelConnectionStringssection and every namedDataSourcesentry are scanned (RelationalSources, Extensions.Health.cs:258-295). Deduplication is by connection string, so the entries that collapse onto one physical database contribute one check rather than one per logical name; the first database of each engine keeps the baresqlserver/postgresql/sqlitecheck name and later ones are named{engine}:{entry}(Extensions.Health.cs:260-265, 280, 284-292). None is tagged optional, so every one gates readiness. - Redis, via the framework's own
RedisPingHealthCheck(Extensions.Health.cs:75-92), tagged"optional". The comment at Extensions.Health.cs:78-83 is the postmortem: theAspNetCore.HealthChecks.Redischeck this replaced issuedCLUSTER INFOagainst any server it detected as clustered, which is how StackExchange.Redis 3.x sees Azure Managed Redis (Enterprise tier), and the server refuses that command outside admin mode, so every probe threw against a healthy cache. The replacement issues PING only and is registered as a singleton so the fallback multiplexer is built once rather than per probe. SeeRedisPingHealthCheck. - RabbitMQ (Extensions.Health.cs:94-104), also tagged
"optional", when arabbitmqormessagingconnection string parses as an absolute URI.
The asymmetry is intentional: Redis and RabbitMQ are optional per host, but a host that cannot resolve
any relational database at all is misconfigured, so requireDatabase: true throws at startup instead of
quietly registering no check and reporting healthy (throw at Extensions.Health.cs:217-223).
Dual telemetry export
AddOpenTelemetryExporters (MMCA.Common.Aspire/Extensions.Telemetry.cs:160-177) activates two exporters, each
conditional:
- OTLP when
OTEL_EXPORTER_OTLP_ENDPOINTis present (Extensions.Telemetry.cs:162-168), the Aspire dashboard sets this automatically; standalone deployments must supply it. - Azure Monitor when
APPLICATIONINSIGHTS_CONNECTION_STRINGis present (Extensions.Telemetry.cs:170-176), injected by the Bicep deployment so logs, metrics, and traces flow to the workspace-based Application Insights resource (main.bicep:250-265, env entry at 270-273).
Both can be active simultaneously; each exports an independent copy.
[Rubric §13, Observability and Operability] assesses whether a running system can be understood from the outside. Dual export means local runs use the lightweight Aspire dashboard (no Azure subscription required) while the same binary, with a different environment variable, ships telemetry to Application Insights in production. The switch is purely environment-driven; no code path changes.
OutboxPollFilterProcessor
MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Telemetry/OutboxPollFilterProcessor.cs
This OpenTelemetry BaseProcessor<Activity> (OutboxPollFilterProcessor.cs:17) drops recurring background
poll spans from export. The OutboxProcessor background service polls every relational outbox table on a
recurring cycle (2 s framework default,
Core/MMCA.Common.Infrastructure/Settings/OutboxSettings.cs:31; deployed environments set 300 s).
Without filtering, those idle polls would dominate Application Insights ingestion, both by span count
and by spawning SqlClient dependency spans when the Azure Monitor distro's auto-instrumentation is
active (OutboxPollFilterProcessor.cs:6-16). The InternalCommandProcessor polls every internal-command
queue table the same way, so its idle polls are dropped by the same processor for the same reason
(OutboxPollFilterProcessor.cs:9-12).
The processor walks the in-process parent chain in OnEnd (OutboxPollFilterProcessor.cs:32-53),
matching spans on source and operation name together: "MMCA.Common.Outbox" with "OutboxPoll", or
"MMCA.Common.InternalCommands" with "InternalCommandPoll" (OutboxPollFilterProcessor.cs:26-29).
Matching on both avoids suppressing an unrelated consumer span that happens to share a poll's name
(OutboxPollFilterProcessor.cs:40-41). When a match is found it clears the ActivityTraceFlags.Recorded
flag (line 49), which tells the batch export processors to skip the span. It is registered before the
exporters (MMCA.Common.Aspire/Extensions.Telemetry.cs:122) so its OnEnd runs before the batch
processors check the flag. A null activity returns early rather than throwing, because a telemetry
callback must never throw (OutboxPollFilterProcessor.cs:34-38).
Real work is unaffected: per-message OutboxProcess and per-command InternalCommandExecute spans
restore explicit parent contexts from the stored trace IDs and are never descendants of a poll span
(OutboxPollFilterProcessor.cs:12-15).
The four name constants (OutboxPollFilterProcessor.cs:26-29) are deliberately duplicated from
MMCA.Common.Infrastructure; the comment explains that the Aspire package's only ProjectReference is
MMCA.Common.Shared (for HttpResilienceDefaults), so AddServiceDefaults stays usable from a host
that does not take the persistence stack (OutboxPollFilterProcessor.cs:19-25).
[Rubric §31, Cost and FinOps] assesses whether observability costs are controlled. Suppressing poll
spans on a 300 s polling interval in production (Outbox__PollingIntervalSeconds=300 on all four ADC
container apps, MMCA.ADC/infra/main.bicep:1746, 1989, 2131, 2287) eliminates the majority of
idle-process telemetry ingestion. The framework makes this the default for every consumer; individual
services do not need to configure it.
Probe telemetry filtering
MMCA.Common.Aspire/Telemetry/ProbeTelemetryFilter.cs and .../ProbeTelemetryFilterProcessor.cs
The outbox poll is not the only span nobody asked for. Container Apps liveness and readiness probes,
the gateway's downstream-{name} aggregate probes, YARP's active health checks and the outside-in
availability web test between them accounted for every AppRequests row in both production
workspaces, and their children (the health check's SQL SELECT 1, the Redis PING, the gateway's
HttpClient call to each backend's /alive) for most of the AppDependencies rows
(ProbeTelemetryFilter.cs:6-11, ProbeTelemetryFilterProcessor.cs:6-12). None of it carries end-user
signal, and none of it is touched by Telemetry:TracesSampleRatio, because proportional sampling keeps
a proportion of exactly the traffic you did not want (MMCA.Common.Aspire/Extensions.Telemetry.cs:88-99).
The knob is Telemetry:FilterProbeTelemetry and it is the one that defaults to on: absent, blank or
unparseable all mean "filter", and only an explicit false turns it off, for a host debugging its own
probes (IsProbeTelemetryFilterEnabled, Extensions.Telemetry.cs:234-235, rationale at 96-97). Enabled, it
installs two halves:
- Instrumentation predicates (Extensions.Telemetry.cs:100-115).
ShouldCollectRequestrefuses an inbound request whose pathHealthEndpointPaths.IsProbePathmatches (ProbeTelemetryFilter.cs:40-55) andShouldCollectOutgoingrefuses an outbound call to a probe path (ProbeTelemetryFilter.cs:62-63). Both configure the DEFAULT-named instrumentation options, which is why they are authoritative without a View, unlike the metric toggles (Extensions.Telemetry.cs:103-105). The outbound half exists because the gateway'sDownstreamServiceHealthCheckcalls and YARP's active checks are driven by background timers and are nobody's descendants, so a processor would never see them (ProbeTelemetryFilter.cs:20-25). ProbeTelemetryFilterProcessor(Extensions.Telemetry.cs:129), registered under the same condition and, like the outbox processor, before the exporters. It walks the in-process parent chain and unrecords any span sitting under a probe request (ProbeTelemetryFilterProcessor.cs:42-64), because the probe's children are sampled independently of the request the predicate just refused.
The load-bearing detail is how the two halves talk to each other. When ShouldCollectRequest refuses a
request, the ASP.NET Core instrumentation returns before writing url.path, so the descendants would
have no way to recognize their own probe ancestor. The predicate therefore stamps a mmca.probe tag on
the server activity on its way out (ProbeTelemetryFilter.cs:33, 49-52), and that tag is the processor's
first match test (ProbeTelemetryFilterProcessor.cs:68-71). It falls back to url.path, http.route and
the route portion of the display name (ProbeTelemetryFilterProcessor.cs:76-79, 89-93) for the case where
an exporter-side enricher has already consumed the tags. The match is server-kind only, so a normal
request never loses its whole subtree because one dependency happened to be a probe
(ProbeTelemetryFilterProcessor.cs:73-76). The pass runs at both OnStart and OnEnd
(ProbeTelemetryFilterProcessor.cs:29, 40): clearing Recorded at end is what keeps the span out of the
exporters, while the start pass additionally stops the instrumentation from enriching data it will never
export, and running only at start would depend on callback ordering this processor does not control
(ProbeTelemetryFilterProcessor.cs:32-39).
Metrics are deliberately untouched. http.server.request.duration, the Kestrel instruments and the
routing instruments keep flowing, so probe traffic stays visible on dashboards and only the per-request
trace rows stop being billed (ProbeTelemetryFilterProcessor.cs:15-17).
[Rubric §13, Observability and Operability] assesses whether the signal a system emits is usable.
Filtering here is not "less telemetry", it is a better ratio: the rows that remain in AppRequests are
now requests a user made. [Rubric §31, Cost and FinOps] gets the other half, and note the default
direction: the three metric-family knobs are opt-in because dropping a metric family is a judgement call, while
this one is opt-out because probe chatter is ingestion no host wants billed.
Security headers
MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Security/SecurityHeaders.cs provides
SecurityHeadersMiddleware (line 133) and SecurityHeadersExtensions (line 210). The middleware sets
X-Content-Type-Options: nosniff (line 167), X-Frame-Options (default DENY, line 168 and settings
line 25), Referrer-Policy (strict-origin-when-cross-origin, line 28), Permissions-Policy (line 31),
HSTS outside Development (lines 34-37, applied at 172-175), and a Content-Security-Policy resolved from
ICspPolicyProvider (lines 177-199).
The default static policy is a complete hardened baseline, not a minimal one (SecurityHeaders.cs:53-55):
default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline';
object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
It ships script-src and style-src at exactly the strength Blazor ('wasm-unsafe-eval') and MudBlazor
('unsafe-inline' styles) require, so an HTML host that never registers a provider still gets a
functional policy instead of one silently missing both directives, while the JSON, WebSocket and static
responses of API and Gateway hosts are unaffected (SecurityHeaders.cs:39-52). A host needing something
stricter configures the SecurityHeaders section or registers its own ICspPolicyProvider; the
supported path off 'unsafe-inline' is the {nonce} placeholder, which the middleware replaces with a
freshly generated 16-byte value per request and also stashes in HttpContext.Items under
CspNonce.ItemKey before the pipeline runs, because the page render is what stamps it onto its tags
(SecurityHeaders.cs:110-113, 136-139, 184-188). Consumers call AddCommonSecurityHeaders() (line 220)
and UseCommonSecurityHeaders() (line 245); the ADC UI registers with EnableHsts = false
(MMCA.ADC.UI.Web/Program.cs:100) because that host calls UseHsts() itself outside Development
(UI.Web/Program.cs:116).
The same package also ships AddCommonGatewayCors
(MMCA.Common.Aspire/GatewayCorsExtensions.cs:24 onward), which the ADC Gateway calls
(Gateway/Program.cs:89). It is deliberately looser than MMCA.Common.API's allow-list version, because a
reverse proxy has to pass arbitrary client headers through: allow-any in Development
(GatewayCorsExtensions.cs:34-40), and in every other environment the origins from Cors:AllowedOrigins
with any header/method plus credentials (GatewayCorsExtensions.cs:44-53).
MMCA.Common.Aspire.Hosting, the AppHost extensions package
Source:
MMCA.Common/Source/Hosting/MMCA.Common.Aspire.Hosting/Files:Extensions.cs,H2cHealthCheckExtensions.cs,H2cEndpointHealthCheck.cs,ServiceBusEmulatorResource.cs
This package lives in a separate assembly from MMCA.Common.Aspire so running services do not pull in
the full Aspire.Hosting package (Common.Aspire.Hosting/Extensions.cs:13-17 class comment). Extensions.cs
exports twelve extension methods used in the AppHosts that orchestrate extracted microservices, and
H2cHealthCheckExtensions.cs adds the thirteenth. It exposes no gRPC API at all: gRPC peers are wired
with stock Aspire WithReference, as shown above.
Builder-level resource helpers
AddMailDev(name = "maildev")(Extensions.cs:141-150). The MailDev container with a persistent lifetime and the two fixed host ports, covered above.AddMessageBroker(name = "rabbitmq")(Extensions.cs:160-165). Wrapsbuilder.AddRabbitMQ(name).WithManagementPlugin(). The management plugin is always enabled for local debug.AddServiceBusEmulatorBroker(sqlServer, name = "servicebus")(Extensions.cs:201-238). The official Azure Service Bus emulator container. Both planes are published because the emulator serves two: AMQP on container port 5672 carries every publish and consume, and the HTTP management plane on 5300 is what MassTransit provisions topology through (ServiceBusEmulatorResource.cs:34-44). The image tag floor is 2.x for that exact reason: the admin plane shipped in 2.0.0, and MassTransit provisions its whole topology at bus start, so a silent downgrade to a 1.x image would leave the broker unusable rather than merely older (Extensions.cs:101-107). Host ports are left dynamic because nothing outside the stack dials these (Extensions.cs:186-189), andACCEPT_EULA=Yis set explicitly because the emulator refuses to start without it (Extensions.cs:224-227). No health check is declared, so aWaitForgates on the container running rather than on warm-up finishing; the consuming service absorbs the remainder because MassTransit starts its bus in the background and reconnects (ServiceBusEmulatorResource.cs:25-30). SeeServiceBusEmulatorResource.
WithBroker<TResource>, two overloads
Extensions.cs:252-262 (RabbitMQ) and 280-292 (emulator). Each chains
WithReference(broker).WaitFor(broker) plus the provider environment variable, so a single call is the
complete wiring for a broker-aware service: service discovery, health-based wait, and the variable that
AddBrokerMessaging() reads to select the transport. The emulator overload adds the connection string
and the management-plane address. The UseDevelopmentEmulator=true suffix on that connection string is
the marker AddBrokerMessaging keys its emulator branch off, and a real Azure Service Bus connection
string never carries it, so the emulator path cannot be entered by accident (Extensions.cs:271-276).
WithJwksDiscovery<TResource>
Extensions.cs:309-337. Injects Authentication__JwtBearer__Authority pointing to the gateway's HTTPS
endpoint (when a gateway is passed) or Identity's HTTPS endpoint (fallback, Extensions.cs:332-335). The
routing-through-gateway rationale is in the method comment (Extensions.cs:321-331): Identity runs
Http2-only, so the default HTTP/1.1 backchannel is rejected; the gateway terminates TLS, speaks ALPN,
and routes /.well-known/* to Identity over h2c. The method also calls
WithReference(identity).WaitFor(identity) (Extensions.cs:316-318). That WaitFor is easy to miss and
it shapes the startup graph: all three non-Identity ADC services wait on Identity because they call
WithJwksDiscovery, not because of any explicit WaitFor in the AppHost.
WithE2eRsaKeys
Extensions.cs:353-368. Reads E2E_JWT_PRIVATE_KEY_PEM / E2E_JWT_PUBLIC_KEY_PEM from the AppHost's own
process environment and, only when both are non-blank, maps them onto Jwt__RsaPrivateKeyPem,
Jwt__RsaPublicKeyPem and Jwks__RsaPublicKeyPem on the Identity resource. Without the forwarding
every CI login and register fails with "No supported key formats were found" and the readiness gate
times out (Extensions.cs:348-350). Both ADC (AppHost/Program.cs:248) and Store
(MMCA.Store/Source/Hosting/MMCA.Store.AppHost/Program.cs:168) call it, which is why it lives in the
framework rather than in either AppHost.
The two E2E lifts
Both read the same trigger, E2eLiftTriggerVariable = "E2E_LIFT_REGISTRATION_THROTTLE"
(Extensions.cs:42), OR-ed with an optional alsoLiftWhen parameter evaluated at the call site, and both
return the resource untouched when neither fires.
WithE2eRegistrationThrottleLift(alsoLiftWhen = false)(Extensions.cs:392-407) setsLoginProtection__MaxRegistrationsPerIpPerHourtoE2eRegistrationsPerIpPerHour(1000, Extensions.cs:35), lifted from the production default of 10. An E2E suite registers far more than ten accounts from a single localhost IP, so the production default refuses every register test past the tenth and the failures look like broken registration rather than the anti-abuse control doing its job (Extensions.cs:371-377).WithE2eGatewayRateLimitLift(alsoLiftWhen = false)(Extensions.cs:440-465) lifts three gateway values:GatewayRateLimiting__PermitLimitto 100000 (Extensions.cs:48),GatewayRateLimiting__GlobalConcurrencyLimitto 10000 (line 54), and the named auth route policy'sPermitLimitto 100000 (line 60). The whole E2E suite arrives from one loopback client IP, so the per-IP window that protects production from a single-source flood reads the suite itself as that flood (2026-08-18 ADC run 32185349945: 12 login failures once the window saturated), and the anti-credential-stuffingauth-tightpolicy has the same problem for the same reason (Extensions.cs:416-424). The three keys are built from constants that mirror the settings types rather than referencing them (GatewayRateLimitingSectionat line 70,MmcaGatewaySectionat line 77,AuthTightPolicyNameat line 84), because this AppHost-tier package must not pull in the service-defaults graph to spell one configuration key; a unit test cross-asserts the mirrors, so a section rename cannot silently orphan the lift (Extensions.cs:62-70).
The shape is the interesting part in both cases. The trigger, the target setting names and the lifted
values all live in the framework; the AppHost passes only the condition that is its own. ADC calls both
with alsoLiftWhen: forceWasm (AppHost/Program.cs:428 and 435) because its forced-WASM switch implies
the same volume; Store calls both parameterless (MMCA.Store/Source/Hosting/MMCA.Store.AppHost/Program.cs:311
and 320), leaving the environment variable as the only trigger. A silent regression here surfaces as
login and register E2E reds, never as a build failure (Extensions.cs:429-430).
WithH2cHealthCheck
H2cHealthCheckExtensions.cs:110-138, covered in full in the health-gating section above. The point to
carry away is that it is the AppHost-side counterpart of the Http2-only Kestrel profile: without it,
a WaitFor edge into an h2c service silently degrades to "the process started", which is precisely the
condition that lets a dependent resource race a service that has not finished migrating, seeding or
warming up (H2cHealthCheckExtensions.cs:13-21). See
H2cHealthCheckExtensions.
The data-source helpers
WithSQLServerDataSource (Extensions.cs:483-494), WithCosmosDataSource (512-524) and
WithSqliteDataSource (537-548), covered in the AppHost section above.
The six Dockerfiles
All six Dockerfiles share the same multi-stage structure (base, build, publish, final) and
the same base images. None build the AppHost: it is a local-only orchestration artifact, never deployed.
Common structure
Stage base (first FROM in all six): mcr.microsoft.com/dotnet/aspnet:10.0 pinned by digest
(@sha256:2d58...746f, Gateway.Dockerfile:4), with WORKDIR /app and EXPOSE 8080 / EXPOSE 8081
(Gateway.Dockerfile:5-7). This is the runtime-only image; it has no SDK tools, minimizing the attack
surface of the final image. The digest is the point of that FROM line: the tag still names the channel,
but the digest is what resolves, so a re-tagged :10.0 cannot silently change the runtime under a
rebuild (Gateway.Dockerfile:1-3). The human-readable version (10.0.11 for aspnet, 10.0.400 for the SDK)
lives in the comment directly above, because a Dockerfile comment has to sit on its own line, and it is
bumped together with the digest.
Stage build: mcr.microsoft.com/dotnet/sdk:10.0, digest-pinned for the same reason
(Gateway.Dockerfile:9-10), restoring the MMCA.Common.* packages from GitHub Packages. The token is a
BuildKit secret, never a build argument: each restore step is
RUN --mount=type=secret,id=github_token and reads /run/secrets/github_token into GITHUB_TOKEN for
that one command (Gateway.Dockerfile:30-32). The header comment says why: an ARG promoted to ENV
lands in image layers, the build cache, and docker history (Gateway.Dockerfile:12-14). The build call is
therefore --secret id=github_token,env=GITHUB_TOKEN, not --build-arg.
Restore runs in locked mode wherever a lock file exists. The gateway copies
packages.lock.json alongside its .csproj precisely so the restore can pass --locked-mode
(Gateway.Dockerfile:25-27, 32), and the four service images do the same against their own project
(MMCA.ADC.Identity.Service/Dockerfile:30-32). That turns the image build into a reproducible restore:
a transitive version that moved since the lock was written fails the build instead of quietly shipping.
The UI image is the documented exception: MMCA.ADC.UI.Web and MMCA.ADC.UI.Web.Client set
RestorePackagesWithLockFile=false (TD-01, NETSDK1124), so neither project has a lock file to lock
against and its restore is plain (UI.Web.Dockerfile:36-38, 41). The publish restore is never locked
in any of the six, and the comment is explicit about why: publish is RID-specific (linux-x64, inferred
for ReadyToRun) while the committed lock files are RID-agnostic, so locked mode fails there with NU1004
(Gateway.Dockerfile:51-55). The restore step above it is the locked gate.
There is deliberately no dotnet build step in any of the six. The comment records the measurement
(CI run 30115729720, 2026-07-24, Gateway.Dockerfile:39-44): a build stage emitted
bin/Release/net10.0/ while the ReadyToRun publish emits bin/Release/net10.0/linux-x64/, so publish
never reused the build output and every image compiled twice, about 75 s of waste per image. Publish does
its own restore and build, and the analyzer gating (TreatWarningsAsErrors, AnalysisMode=All) still
runs inside it.
Stage publish: runs dotnet publish ... -o /app/publish /p:UseAppHost=false against the same
project, with the same secret mount because publish performs its own restore pass
(Gateway.Dockerfile:56-58). The four services and the Gateway add /p:PublishReadyToRun=true so cold
starts on fractional-vCPU containers skip first-request JIT (Gateway.Dockerfile:48-50, 58;
MMCA.ADC.Identity.Service/Dockerfile:51-53); the UI image does not, and its comment notes that
without R2R the publish reuses the same obj/ path a build step would have produced
(UI.Web.Dockerfile:46-48, 55).
Stage final: COPY --from=publish /app/publish . into the base layer
(Gateway.Dockerfile:60-62), sets ASPNETCORE_ENVIRONMENT=Production (line 63), and then drops
privileges with USER $APP_UID (Gateway.Dockerfile:72, UI.Web.Dockerfile:69,
MMCA.ADC.Identity.Service/Dockerfile:67). APP_UID (1654) and its matching non-root app user come
from the aspnet base image. Nothing in these images writes to the container filesystem at runtime: the
Serilog rolling file sink is skipped in Production, which this stage pins, and the DataProtection key
ring, where configured, lives in blob storage, so read and execute on the root-owned /app payload is
all the runtime needs. Every port the hosts bind is 8080 or higher, outside the privileged range a
non-root user cannot bind. The line is kept last in the final stage on purpose: the build and publish
stages still need write access to /src and the NuGet folder (Gateway.Dockerfile:64-71). The
ENTRYPOINT form of the dotnet invocation closes each file.
Gateway Dockerfile
MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Dockerfile
The build stage copies only the gateway's .csproj and its packages.lock.json before restoring
(Gateway.Dockerfile:27), then copies the full Source/ tree before publishing
(Gateway.Dockerfile:37). The comment at lines 34-36 explains why the full tree is needed:
Directory.Build.props links GlobalUsings.IdentifierType.cs from each module's Shared project, so
the source tree must be present even though the gateway itself references no module projects. The
gateway has minimal NuGet dependencies (MMCA.Common.Aspire and YARP); the partial restore approach is
feasible, and the lock file travelling with the project file is what lets that restore run
--locked-mode (Gateway.Dockerfile:25-26).
Entrypoint: dotnet MMCA.ADC.Gateway.dll (Gateway.Dockerfile:73).
UI (Blazor Web) Dockerfile
MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Dockerfile
The build stage copies the solution file (UI.Web.Dockerfile:21) and then eight .csproj files
individually before restoring (UI.Web.Dockerfile:26-33): three *.Shared projects, three *.UI
projects, and the two web host projects (MMCA.ADC.UI.Web + MMCA.ADC.UI.Web.Client). This is a
layer-caching optimization: a change in a source file does not invalidate the restore cache. After
restoring, the full Source/ tree is copied (UI.Web.Dockerfile:44) and the publish stage builds it.
Entrypoint: dotnet MMCA.ADC.UI.Web.dll (UI.Web.Dockerfile:70).
Four service Dockerfiles
MMCA.ADC/Source/Services/MMCA.ADC.{Identity,Conference,Engagement,Notification}.Service/Dockerfile
All four are identical in structure, down to the line numbers. The build stage copies the full Source/
tree (line 27) before restoring, and the comment above it says why (lines 24-26): services have deep
project-reference chains through Migrations to all module Infrastructure assemblies, so copying the full
Source/ tree is simplest. Each service's Dockerfile then restores its own .Service.csproj in locked
mode (lines 30-32) and publishes it with ReadyToRun (lines 51-53) independently; all four drop to
USER $APP_UID on line 67 and carry their entrypoint on line 68:
dotnet MMCA.ADC.Identity.Service.dlldotnet MMCA.ADC.Conference.Service.dlldotnet MMCA.ADC.Engagement.Service.dlldotnet MMCA.ADC.Notification.Service.dll
[Rubric §17, DevOps and Deployment] continues: having one Dockerfile per deployable means each image
is independently versioned and deployed. CI declares all six as a six-way parallel matrix
(.github/workflows/deploy.yml:1095-1116), gated as a whole on the changes job classifying the diff as
code (deploy.yml:1086), and each leg additionally carries a changed column from that job's per-image
dirty map (deploy.yml:1101, 1104, 1107, 1110, 1113, 1116). A leg whose image is clean skips the build and
push (deploy.yml:1136-1137) and re-tags the last :latest to this sha instead
(deploy.yml:1170-1183), a registry-side az acr import manifest copy that pulls and pushes no layer
(deploy.yml:1166-1167), so it still concludes success: the comment above the job
(deploy.yml:1077-1081) records why that matters, since deploy gates on the job-level
needs.build-images.result == 'success' equality (deploy.yml:1263) and a skipped leg would turn the
whole job skipped and silently cancel the deploy. The UseAppHost=false publish flag strips the native
executable wrapper; the Docker entrypoint invokes the DLL directly via the already-present runtime in the
base image.
The same clean leg then copies the fresh sha tag back onto :latest (deploy.yml:1192-1196). That
second copy exists because of the registry's own housekeeping: the daily ACR purge task keeps only the
three most recently updated tags per repository and deletes anything older than three days, and minting
a sha tag never touches :latest, so after a few deploys that leave an image clean the very tag the
re-tag reads from would age out, get purged, and fail the next clean leg, which blocks the whole deploy
through the same success equality (deploy.yml:1184-1191). Copying the identical digest moves only the
tag's timestamp; the build-and-push leg already pushes :latest on every build, so both legs keep it
alive.
Each leg ends with a container-image CVE scan of the exact tag this deploy will roll out: Trivy
against {acr}/{image}:{sha}, table format, CRITICAL,HIGH, ignore-unfixed: true, exit-code: 1
(deploy.yml:1206-1214). It exists because the supply-chain job's CycloneDX SBOM and
dotnet list --vulnerable cover the managed NuGet graph only, so nothing else inspects the base layer of
any of the six images (deploy.yml:1198-1200). It is non-gating (continue-on-error: true,
deploy.yml:1207): findings are printed in the step log rather than reddening every deploy. Note the
honest residue, the comment justifies that choice by the images building on a floating aspnet base
(deploy.yml:1202-1205), while the six Dockerfiles now pin that base by digest
(Gateway.Dockerfile:4): the stated precondition for flipping continue-on-error to false is met on the
pinning half, and the step is still non-gating.
[Rubric §32, Dependency and Supply-Chain] assesses whether what ships is known, pinned and checked. The
digest-pinned bases, the --locked-mode restores in five of the six images, the BuildKit-secret token
and the per-image Trivy scan are four independent halves of that: what the image is built from is
pinned, what it restores is pinned, the credential never lands in a layer, and the assembled image is
inspected before the deploy job points a container app at it.
Local-to-cloud parity
The AppHost topology maps directly to the Azure infrastructure provisioned by infra/main.bicep. The
table below cross-references the local resource with its Azure equivalent:
| Local (AppHost) | Azure (Bicep) |
|---|---|
| SQL Server container (persistent) | Azure SQL Server; the same four databases (ADC_Identity, ADC_Conference, ADC_Engagement, ADC_Notification, main.bicep:911-916), each Basic 5 DTU / 2 GB (main.bicep:926-934) and carrying a service tag equal to the owning container app's, so one tag-grouped cost report splits compute and storage per service (main.bicep:170-174, 922-924), with weekly/monthly/yearly long-term retention on top of Basic-tier PITR (main.bicep:938-954). The three databases that hold an audit-trail table (ADC_Identity, ADC_Conference, ADC_Engagement; Notification has no audited entities) also get a database-level audit policy recording every UPDATE or DELETE on dbo.AuditTrailEntries outside the database (main.bicep:981-985, 1003-1020), because the services connect with the server admin login and could otherwise rewrite the ADR-075 trail without a record (main.bicep:959-967). The legacy AtlDevCon database is declared in neither place (main.bicep:890-899) |
| Redis container (persistent) | Azure Managed Redis (Microsoft.Cache/redisEnterprise, Balanced B0, no HA, main.bicep:1383-1394), OSS-cluster policy and volatile-LRU eviction (main.bicep:1402, 1405), injected as ConnectionStrings__redis from Key Vault (main.bicep:1708) |
RabbitMQ container (persistent, management plugin), or the Service Bus emulator container under ADC_BROKER=servicebus |
Azure Service Bus (Standard tier, main.bicep:1035-1046; Basic lacks the topics MassTransit needs, main.bicep:1030-1031), with one per-service SAS rule rather than one credential for the whole fleet (main.bicep:1048-1050) |
| MailDev container (fixed ports 1080/1025) | Not provisioned; a real SMTP relay via Smtp__Host / Smtp__Port / Smtp__From (main.bicep:1754 for Identity, 2238 for Notification) |
MessageBus__Provider=RabbitMq (AppHost default) |
MessageBus__Provider=AzureServiceBus (Bicep env var on all four services, main.bicep:1733, 1943, 2085, 2236) |
| Aspire dashboard (OTLP) | Application Insights workspace-based resource (APPLICATIONINSIGHTS_CONNECTION_STRING, main.bicep:243-246) |
WithSQLServerDataSource injects one connection-string env var |
Bicep injects the same one plus DataSources__{Module}__SQLServerMigrationsAssembly and Outbox__DatabaseName (main.bicep:1686-1688) |
h2c health probe from the AppHost (WithH2cHealthCheck) |
Dedicated HTTP/1.1 probe listener via HealthProbe__Port, 8081 on the three h2c services (main.bicep:1682, 1909, 2049) and 8082 on Notification (main.bicep:2201), because the ACA platform probes speak HTTP/1.1 |
WaitFor gates on /alive; only the UI gates on /health/ready |
The ACA probe block splits the same two paths by job: startup and liveness on /alive, readiness on /health/ready, all three against the probe port (main.bicep:1815-1839). Readiness polls every 30 s rather than 10 s, because the DB-aware check issues a SELECT 1 per probe and neither the probe request nor its dependency row is sampled (main.bicep:1809-1814) |
| Outbox poll interval: framework default 2 s | Outbox__PollingIntervalSeconds=300 on every service (main.bicep:1693, 1916, 2056, 2210); Identity, Conference and Engagement, the three hosts that call AddScheduledJobs, also slow the runner's idle wake to Scheduler__PollingIntervalSeconds=300 (main.bicep:1704, 1924, 2059). Internal commands (ADR-114) deliberately poll faster at InternalCommands__PollingIntervalSeconds=60 (main.bicep:1699), because only a row enrolled in a transaction cannot be signalled |
| Telemetry knobs at their defaults (sample everything, all metric families on, probe traces filtered) | Telemetry__TracesSampleRatio=0.25, Telemetry__DisableHttpClientMetrics=true, Telemetry__DisableRuntimeMetrics=true, Telemetry__DisableAspNetCoreMetrics=true, OTEL_METRIC_EXPORT_INTERVAL=300000, plus the distro switch AzureMonitor__EnableLiveMetrics=false (main.bicep:279-282, 312-319, 325-328, 335-338, 346-349). Telemetry__FilterProbeTelemetry is set nowhere, because its default is already the production behavior |
Gateway: YARP request-routing lines at Information (Gateway/appsettings.json:6) |
Logging__LogLevel__Yarp=Warning on the gateway container and no other (main.bicep:267-276, 2368), because YARP's two lines per proxied request duplicated AppRequests and AppDependencies in the console-log stream; Warning keeps the forwarder's error lines |
The transport switch (RabbitMq to AzureServiceBus) is entirely environment-driven. No code path
changes between local and production: the same AddBrokerMessaging(configuration) call in each
service's Program.cs reads MessageBus:Provider and branches accordingly. This is ADR-003 (outbox +
in-process dispatch + background processor) combined with the infrastructure flexibility of
MessageBusProvider selection.
Be honest about what that buys and what it does not. Environment-driven transport selection means no
#if and no second code path, but the default local run still uses a different broker than
production: RabbitMQ exercises the MassTransit RabbitMQ transport, not the Service Bus topic topology,
its quotas, or its admin-plane behavior. ADC now closes that gap on two fronts rather than one. The
ADC_BROKER=servicebus opt-in (AppHost Program.cs:91-95) runs the whole inner-loop stack against the
official emulator, so a developer chasing a transport bug can reproduce it locally; and
Tests/Integration/MMCA.ADC.ServiceBusEmulator.IntegrationTests smokes MassTransit against the same
emulator on the weekday nightly, continue-on-error and never deploy-gating. The residual gap is what
the emulator itself does not model (real quotas, real throttling, the real admin plane), which is
narrower than the gap a RabbitMQ-only inner loop leaves.
[Rubric §7, Microservices Architecture] is directly served by the fact that the extraction boundaries (gRPC contracts, broker interfaces, JWKS discovery) are identical in both environments. An engineer can validate a cross-service event flow locally before it reaches the Azure Service Bus in production.
[Rubric §17, DevOps and Deployment] is served by the single-command local run matching the production topology in process count, service-discovery mechanism, and transport semantics. The two remaining gaps, MailDev versus a real SMTP relay and the default RabbitMQ versus Azure Service Bus, are intentional and each is scoped: the first is never exercised in production paths, the second has both the opt-in emulator and the nightly emulator tier above.
The YARP Gateway's role
The gateway (Source/Hosts/MMCA.ADC.Gateway) is a pure YARP reverse proxy. It has no DbContext, no
ModuleLoader, no REST controllers, and no broker connection. Its Program.cs is 228 lines and
contains no route definitions at all: the route table is configuration, not code
(Gateway/Program.cs:9-13). appsettings.json's ReverseProxy section owns every route pattern and every
cluster, so adding or repointing a route is an appsettings edit plus a matching entry in RouteMapTests
(which pins the whole table), never a redeploy of hand-written MapForwarder calls.
The route table
Gateway/appsettings.json:78-258 declares 33 routes across 5 clusters (clusters at
appsettings.json:260-298), every destination an in-cluster service-discovery name over cleartext, never a
public URL:
- Eight Identity routes to cluster
identity(http://identity, appsettings.json:88-133, 261-269):/Auth/loginand/Auth/register(90-103), the/Auth/{**catch-all}that carries everything else (106-110),/Users(111-115),/UserClaims(116-120),/Admin/Users(121-125),/Admin/Roles(126-130) and/.well-known/*(131-135). Only the two credential-submission routes carry"RateLimiterPolicy": "auth-tight", and they carry"Order": -1to win over the catch-all explicitly rather than by route-precedence inference (lines 94-95, 101-102). - Eighteen Conference prefixes (
/Events,/Sessions,/Speakers,/Rooms,/ConferenceCategories,/CategoryItems,/SessionSpeakers,/EventSpeakers,/SessionCategoryItems,/SessionQuestionAnswers,/EventQuestionAnswers,/SpeakerCategoryItems,/SessionSelection,/Questions,/Sponsors,/Partners,/Activities,/SessionAssets) to clusterconference(appsettings.json:134-223, 270-278)./Partnersis the newest of them (conference-partners, appsettings.json:209-213), and it is the shape every new aggregate takes at the edge: one prefix route,"AuthorizationPolicy": "anonymous", the sameconferencecluster, no forwarder config of its own, and a matching row in the test that pins the table (Tests/Hosts/MMCA.ADC.Gateway.Tests/RouteMapTests.cs:162). Nothing in the gateway image or itsProgram.cschanged to carry it. - Five Engagement prefixes (
/Bookmarks,/CheckIns,/LivePolls,/Points,/SessionQuestions) to clusterengagement(appsettings.json:224-248, 279-287). /Notificationsto clusternotification-restand/hubs/*to clusternotification-hub(appsettings.json:249-258, 288-297). Both point at the samehttp://notificationdestination; they are two clusters because a cluster is what carries the forwarder request config, and these two need different ones.
Two of those entries are worth reading rather than counting.
The auth-tight split is a bug fix, not a tightening. The policy used to sit on the single /Auth
catch-all, which also matched /Auth/refresh, and the UI container calls the gateway server-side at
http://adc-gateway for every SSR session refresh. Those internal calls carry no X-Forwarded-For, so
the ClientIp partition keyed every user's refresh on the UI replica's own container IP: one shared
30-per-minute bucket for the whole site. ADR-019 deliberately leaves RefreshAsync unthrottled at the
service layer for exactly that shared-IP reason, and the gateway policy was silently undoing it
(appsettings.json:79-87). Login and register are split out and keep the tight policy; everything else
under /Auth (refresh, logout, forgot/reset password, the OAuth callbacks) stays on the edge global
limiter alone (appsettings.json:102-103).
Every route states "AuthorizationPolicy": "anonymous". A proxied route carries no authorization
metadata of its own, so without the declaration the anonymous posture of the whole table would be an
absence rather than a statement, with nothing for a reviewer or a fitness test to read (SEC-Common-16,
appsettings.json:72-76, Gateway/Program.cs:100-109). The declaration makes the posture readable; it does
not make a new route fail closed. The framework's fallback policy is deliberately not registered in
this host, so a route added without a policy still inherits the anonymous default rather than failing
closed at the edge (appsettings.json:76-79). builder.Services.AddAuthorization() (Program.cs:114) plus
app.UseAuthorization() (Program.cs:231) evaluate the declared policy, with no UseAuthentication
above them on purpose: the gateway authenticates no one and the bearer travels untouched to the service
that does (Program.cs:228-230). The fallback is not adopted because it ships in MMCA.Common.API, and
referencing that package would pull the whole MVC and controller stack into a pure-YARP host and into
the edge container image; the four services and the UI head, which already reference it, do adopt it
(Program.cs:110-113).
Three forwarder profiles, on two axes
There are three profiles, not two, and they differ on HTTP version and activity timeout (the header comment enumerates them, Gateway/Program.cs:15-31).
The identity, conference and engagement clusters each state
"Version": "2.0" with "VersionPolicy": "RequestVersionExact" in their own HttpRequest block
(appsettings.json:265-268, 274-277, 283-286). Exact is load-bearing: on cleartext there is no ALPN to
negotiate, so RequestVersionOrLower silently downgrades to HTTP/1.1 and the Http2-only backend
rejects it. That pair stays per-cluster rather than moving into the shared defaults, because which
clusters speak h2c is a per-cluster fact, not a default (Program.cs:42-43).
The two Notification clusters state no HttpRequest block at all, so YARP's HTTP/1.1-capable defaults
apply, because SignalR's WebSocket transport begins with an HTTP/1.1 Upgrade handshake and Notification
therefore stays Http1AndHttp2 (Program.cs:23-26).
The timeouts moved out of the clusters entirely. They live in the MmcaGateway section that
MMCA.Common.Gateway binds (GatewaySettings.SectionName,
MMCA.Common/Source/Hosting/MMCA.Common.Gateway/GatewaySettings.cs:15):
MmcaGateway:ClusterRequestDefaults:ActivityTimeout = "00:01:40"(appsettings.json:44-46) applies to every cluster that states no value of its own. That value is the shared backend total-request budget of 90 s (HttpResilienceDefaults.TotalRequestTimeout,Core/MMCA.Common.Shared/Resilience/HttpResilienceDefaults.cs:19) plus a deliberate 10 second margin. The margin is the whole point: the forwarder must outlive the backend's own total request budget, or a call that exhausts the backend surfaces a gateway abort that hides the backend's real error (Program.cs:35-39).MmcaGateway:ClusterRequestOverrides:notification-hub:ActivityTimeout = "01:00:00"(appsettings.json:47-51) is the one cluster whose budget genuinely differs. A hub connection is long-lived by design (a WebSocket or long-poll stays open for the session), so applying the REST budget to it would tear down healthy hubs.
Precedence is per property and most-specific-wins, which is why the three h2c clusters keep their local
Version/VersionPolicy pair while inheriting the shared timeout (Program.cs:136-137).
What AddMmcaGateway adds on top
builder.Services.AddReverseProxy().LoadFromConfig(...).AddMmcaGateway(configuration).AddServiceDiscoveryDestinationResolver()
(Gateway/Program.cs:146-149). AddServiceDiscoveryDestinationResolver is what turns http://identity
into a real endpoint; without it YARP would treat identity as a DNS host (Program.cs:129-130).
AddMmcaGateway (MMCA.Common.Gateway/GatewayReverseProxyExtensions.cs:47) adds four things on top of
the loaded table (Program.cs:132-145):
- The shared cluster request profiles described above.
- HTTP/2 forwarding, applied by the package's own config filter. It is the single topology, with no switch to flip (Program.cs:45-47).
- Passive destination health checks on every cluster that declares none, so YARP ejects a failing
destination instead of continuing to balance onto it, and it is free: it watches the responses the
gateway is already forwarding (Program.cs:140-142, defaults at
GatewaySettings.cs:121-122). ADC keeps the active probe explicitly off in configuration (MmcaGateway:HealthCheckDefaults:Active:Enabled = false, appsettings.json:57-60), and the comment above it (appsettings.json:52-56) gives two reasons. Every cluster fronts exactly one Container Apps internal address, so an active probe could only ever eject the sole destination, turning a slow request into a 503, while the platform already routes only to ready replicas. And each probe is an HTTP request that bills an otherwise idle downstream replica at the Container Apps active rate, eight times the idle rate ([Rubric §31, Cost and FinOps]). Passive checks cost no extra traffic, so they are the only destination health signal the gateway runs. X-MMCA-Route/X-MMCA-Clustertrace headers on every proxied request, with any inbound value stripped first so a downstream can trust them (GatewaySettings.cs:181-184), plus the named per-route rate-limiter policies routes reference by name.
The gateway's own pipeline
The middleware order in Gateway/Program.cs:158-221 is itself the documentation, and each position is
argued:
UseCommonForwardedHeaders()(line 158) comes first, because behind Azure Container Apps ingress every connection arrives from the ingress proxy's IP, and without it the per-client-IP rate-limit partition would collapse to one shared window for all real users (Program.cs:153-157).UseGatewayCorrelation()(line 163) stamps the correlation ID on the request headers before anything can short-circuit (a 429 from the edge limiter included), so the proxied request carries it downstream and the service-side middleware adopts the same ID rather than minting a second one (Program.cs:160).UseCommonSecurityHeaders()(line 168) is early so the headers apply to forwarded API responses, the SignalR hub, the static privacy page and the health endpoints alike (Program.cs:166-167).MapDefaultEndpoints()(line 170) thenUseCors()(line 171).UseGatewayRateLimiting()(line 178) sits after CORS so a rejected preflight is still a CORS answer, and before the proxy is mapped so a throttled request never reaches a backend. One middleware serves both limiters: the edge global limiter fromAddGatewayRateLimiting(line 74) and the named per-route policiesAddMmcaGatewayregistered, and a request must satisfy both (Program.cs:173-177).UseStaticFiles()(line 183) and a/privacyalias (lines 190-192) beforeMapReverseProxy()(line 221), so the App Store privacy URL is served locally rather than forwarded.- One
UseWhenbranch raises the request body cap to 50 MB plus multipart headroom, and only on/SessionAssets/file(Program.cs:194-213, the literal at line 201). Session-material uploads are the one request the gateway forwards that is bigger than Kestrel's 30 MB default. The service that terminates the request raises its own cap with[RequestSizeLimit], but the gateway has no endpoint to hang an attribute on, so a global raise would hand every other route a 50 MB body budget it has no use for. The number is a literal on purpose: the gateway references no Conference assembly,RouteMapTestspins the route, and the service-side attribute is the real enforcement. UseAuthorization()(line 218) evaluates the per-routeAuthorizationPolicythe table declares, with noUseAuthenticationabove it (Program.cs:215-217).
The edge limiter's own numbers are configuration: 120 requests per 60 second window per client IP, a
replica-wide concurrency ceiling of 200, and /hubs on the bypass list because a SignalR connection is
long-lived and its negotiate/reconnect traffic must not be throttled (appsettings.json:21-25, defaults in
MMCA.Common.Aspire/Gateway/GatewayRateLimitingSettings.cs:59, 63, 73); health probes and
/.well-known are always exempt (Program.cs:69-73). The auth-tight route policy is
30 requests per 60 seconds partitioned on client IP with no queue (appsettings.json:62-68).
One exemption is configured but inert until a secret arrives. GatewayRateLimiting:TrustedCallerHeaderName
names the header (X-Internal-Caller-Key, appsettings.json:32) and only the name: the secret itself
arrives as GatewayRateLimiting__TrustedCallerSecret from Key Vault, and the exemption is off until it
does (appsettings.json:26-31). It exists for the same shared-IP problem the auth-tight split solves
from the other side: every server-side call the UI container makes to this gateway, the SSR session
refresh above all, leaves from that container's single address, which the per-client-IP window would
otherwise collapse into one partition for the whole site.
The two health paths are cached rather than exempted-and-uncached. /health and /health/ready are
anonymous and unconditionally exempt from both limiters (which ADR-088 requires so a flood cannot
starve Container Apps' own probe), and without a cache that exemption turns one anonymous request into
four live internal GETs against a 0.25 vCPU gateway. MMCA.Common.Aspire now serves both paths from a
single-flight CachedHealthReportProvider whose window is HealthChecks:CacheSeconds, pinned to 10 here
(appsettings.json:40-41, rationale at Program.cs:90-96). The Container Apps readiness probe does not read
this report (it targets /alive, see below), so its consumers are the availability web test and
operators, and ten seconds keeps a real outage visible almost immediately (appsettings.json:34-39).
Readiness that reflects the edge's job
AddGatewayDownstreamHealthChecks("identity", "conference", "engagement", "notification")
(Gateway/Program.cs:88) registers one downstream-{name} check per service, each GETting /alive
through a service-discovery-resolved client, tagged Ready
(MMCA.Common.Aspire/Gateway/GatewayHealthCheckExtensions.cs:130, 211). Tagging matters: a downstream
outage pulls the gateway out of the load balancer without ever failing liveness, because restarting the
gateway fixes nothing about a downstream being down (Gateway/Program.cs:76-79).
Production does not point the platform's readiness probe at that aggregate. The gateway container app
probes /alive for startup, liveness and readiness, all on port 8080 (MMCA.ADC/infra/main.bicep:2408-2434),
and the comment on the readiness entry records why (main.bicep:2423-2429). /health/ready fans out to
every downstream's /alive, so a 30 second probe there was an HTTP request on every service every 30
seconds, which alone keeps an idle replica off the Container Apps idle rate; and one failed downstream
marked the only gateway replica unready and took the whole site down, healthy routes included. The
full aggregate stays on /health for the availability web test and its alert. No cold-revision
protection is lost, because the gateway registers no JwtBearer scheme, so the OIDC metadata warm-up task
AddServiceDefaults would otherwise wait on has nothing to fetch. This is the gateway's exception to the
service pattern in the parity table, where readiness stays on /health/ready.
One call covers all four because the probe no longer needs to be told which protocol each downstream
speaks. DownstreamProbeVersion.Auto is the default
(GatewayHealthCheckExtensions.cs:59); it negotiates per downstream and latches the answer for the life of
the process, so the three h2c REST services latch HTTP/2 on their first poll and Notification answers
HTTP_1_1_REQUIRED once and latches HTTP/1.1. The fallback is a one-time cost per downstream, not a
per-poll one (Gateway/Program.cs:80-87).
Whatever still probes that aggregate has an observable cost, and the gateway's appsettings.json pays it
down at the logging layer (appsettings.json:2-15): at Information three categories (Polly retry
attempts, the per-request System.Net.Http.HttpClient logs for the gateway-downstream-* clients, and
the YARP health monitor, Yarp.ReverseProxy.Health) write a line per probe for traffic no user made, so
they sit at Warning while YARP's own request-routing lines stay at Information (appsettings.json:6,
13). Production goes one step further and caps YARP too: the Bicep sets Logging__LogLevel__Yarp=Warning
on the gateway container and no other host (main.bicep:267-276, 2368), because at Information YARP writes two lines per
proxied request that Container Apps ships to Log Analytics, measured at about 177k lines and 77 MB a week,
the largest console-log stream in the workspace and all of it already recorded as AppRequests and
AppDependencies. Warning keeps the forwarder's error lines, so a failed proxy hop still logs.
The three architectural purposes
Single entry point. The MAUI client and Blazor UI always talk to
https://localhost:6001(local) or the equivalent Azure Container Apps ingress URL (production). Neither client is hardcoded to individual service addresses. This allows services to be scaled, moved, or split without client changes.TLS termination. The three REST backends (Identity, Conference, Engagement) run HTTP/2 cleartext (h2c) for gRPC; Notification keeps
Http1AndHttp2defaults for the SignalR WebSocket Upgrade and serves gRPC on a separateHttp2-only endpoint. The gateway terminates TLS and forwards to services over cleartext, avoiding TLS overhead on the internal network. The JWKS discovery routing exploits this: JwtBearer's HTTP/1.1 backchannel hits the gateway over HTTPS, the gateway negotiates h2c to Identity, and the JWKS document is returned transparently.Extraction reversibility. If a service needs to be re-merged into the monolith or split further, only the YARP route table changes, and it is a configuration file. Clients and other services are unaffected. This is ADR-008 (service extraction topology): "transport at the edge keeps extraction reversible."
[Rubric §7, Microservices Architecture] is directly served: clients talk to one address; services talk to each other via gRPC or the broker; the gateway is the only component that knows the current service topology on behalf of clients.
Startup ordering summary
The health-based WaitFor chain imposes this ordering. Two things shape it that are not visible at the
call site: three of the four services wait on Identity without any explicit WaitFor in the AppHost
(WithJwksDiscovery adds it, Common.Aspire.Hosting/Extensions.cs:316-318), and every one of those waits
gates on a liveness answer over the protocol the target actually serves, not on readiness.
SQL Server container health
|- Database resources health (adc-identity, adc-conference, adc-engagement, adc-notification)
|- Identity Service (WaitFor: identityDb, redis, mailDev, broker; gate: h2c /alive)
|- Conference Service (WaitFor: conferenceDb, redis, mailDev, broker,
| identity via JWKS; gate: h2c /alive)
| |- Engagement Service (WaitFor: engagementDb, redis, mailDev, broker,
| conferenceService, identity via JWKS; gate: h2c /alive)
|- Notification Service (WaitFor: notificationDb, redis, mailDev, broker,
identityService twice over: explicit at Program.cs:268 and again via JWKS;
gate: stock HTTP/1.1 /alive)
|- Gateway (WaitFor: all four services; gate: stock /alive, never /health/ready)
|- UI (WaitFor: gateway + all four services; gate: /health/ready)
Four edges deliberately carry a WithReference with no WaitFor: Conference to Engagement (the reverse
half of the bidirectional gRPC pair, Program.cs:273), Engagement to Notification (fire-and-forget live
channel, Program.cs:281), and Identity to Engagement / Identity to Notification (the export aggregation,
Program.cs:291-292). Each would close a cycle against a wait that already exists in the other direction.
All four retry via the gRPC resilience pipeline until the peer is ready.
Where the composition is exercised
Everything above is composition code, and composition code has a specific blind spot: a renamed
resource, a reference that no longer resolves, a WaitFor cycle, or a data source that stopped being
injected is invisible to dotnet build and to every in-process test tier, because those tiers boot hosts
directly through WebApplicationFactory and bypass the orchestration. Two places run the orchestration
for real. The framework owns the test base: MMCA.Common's apphost-testing job boots a sample AppHost
through Aspire.Hosting.Testing and is blocking in Common's CI, though not a required merge check
(MMCA.Common/.github/workflows/ci.yml:905-921; the base is
ADR-117). And ADC's
e2e.yml starts this AppHost for every Playwright run (MMCA.ADC/.github/workflows/e2e.yml:228), so a
wiring break surfaces as a failed stack start or a red browser suite: on the deploy path through the
chromium e2e-gate on a UI diff (MMCA.ADC/.github/workflows/deploy.yml:792-807), and on the scheduled
firefox and webkit legs. ADC carries no AppHost test project of its own.
[Rubric §14, Testability and Test Strategy] assesses whether the test suite covers the risks the system actually carries. An orchestration file is a genuine failure surface with no compiler covering it, and the answer here is proportionate: the framework proves the shared test base once, and the consumer's composition is proven by the suite that has to boot it anyway.
Rubric category index for this chapter
| Category | Where primarily embodied |
|---|---|
| §7 Microservices Architecture | WithReference declared only for real call edges, so the gRPC topology reads as code (AppHost Program.cs:268-292); the gateway as the only component that knows the service topology on behalf of clients; identical extraction boundaries (gRPC contracts, broker interfaces, JWKS discovery) locally and in Azure |
| §11 Security | The GitHub Packages token as a BuildKit secret in all six Dockerfiles (Gateway.Dockerfile:12-14, 30-32), and the non-root USER $APP_UID final stage (Gateway.Dockerfile:72); JWKS discovery with no shared symmetric secret, routed through the gateway and issuer-pinned from the same resource (Program.cs:364-375); AddCommonKeyVaultConfiguration and AddCommonDataProtection as single framework calls gated on configuration keys absent locally; the hardened default CSP baseline (SecurityHeaders.cs:53-55) |
| §12 Performance & Scalability | The ACA-tuned SocketsHttpHandler (MMCA.Common.Aspire/Extensions.cs:78-86) and the OIDC metadata warm-up task, both aimed at Consumption-plan cold starts and idle-replica penalties; ReadyToRun publish on the five non-UI images |
| §13 Observability & Operability | Dual OTLP / Azure Monitor export from one binary (Extensions.Telemetry.cs:160-177); nine MMCA.Common meters registered by literal name (Extensions.Telemetry.cs:308-316); probe-trace filtering that raises the signal ratio of AppRequests rather than only cutting volume |
| §14 Testability & Test Strategy | AppHostBicepParityTests reads the AppHost Program.cs and infra/main.bicep as text, without booting the stack, and fails when a WithReference edge has no matching services__ entry in Bicep or a Bicep entry has no matching edge (AppHostBicepParityTests.cs:53, AppHostBicepParityTests.cs:73), which pins the one failure surface no compiler covers; the booted composition is exercised by e2e.yml, and ADC carries no AppHost test project of its own |
| §17 DevOps & Deployment | Persistent container lifetimes shared by the inner loop and the Aspire-driven E2E CI run; one Dockerfile per deployable behind the six-way build-images matrix with per-image dirty gating (deploy.yml:1095-1116); the parity table's environment-driven local-to-cloud mapping |
| §29 Resilience & Business Continuity | The liveness-versus-readiness split on every startup gate (Program.cs:324-343), including the gateway's /alive gate that avoids the readiness-aggregate wedge; WithReference without WaitFor on the four cycle-closing edges, absorbed by the gRPC resilience pipeline; "optional"-tagged dependency checks that keep a degradation partial |
| §31 Cost / FinOps | The five telemetry knobs, the Live Metrics switch (main.bicep:307-310) and the metric export interval; OutboxPollFilterProcessor and ProbeTelemetryFilterProcessor suppressing the two highest-volume classes of span nobody asked for; the gateway's probe-log trim (Gateway/appsettings.json:2-16) and its production-only YARP log floor (main.bicep:267-276, 2368); the 30 s readiness cadence in the ACA probe block (main.bicep:1809-1814); the gateway's YARP active probe switched off (Gateway/appsettings.json:52-60) and its readiness probe on /alive rather than the downstream fan-out (main.bicep:2423-2432) |
| §32 Dependency & Supply-Chain | Digest-pinned aspnet and sdk base images in all six Dockerfiles (Gateway.Dockerfile:4, 10); --locked-mode restore in five of the six (Gateway.Dockerfile:32, the UI's documented exception at UI.Web.Dockerfile:36-38); the per-image Trivy scan of the exact tag being rolled out, non-gating today (deploy.yml:1206-1214) |
| §33 Developer Experience | One command brings up six processes and four containers with no Compose file and no hand-set environment variables; the ADC_BROKER=servicebus parity opt-in that is paid for only when needed |
Not determinable from source
- The specific integration events that flow over the broker (
UserRegistered,SpeakerLinkedToUser,SpeakerUnlinkedFromUser) are cited from AppHost inline comments (Program.cs:50-56, 176-182), not from the handler implementations. The comments name the publisher and consumer handler on each side; whether those handlers still match the comment is a question for the messaging chapter, not this one. - The headless-AppHost stall is an operational fact recorded in
MMCA.ADC/CLAUDE.mdand the workspaceCLAUDE.md, not something any source file asserts. There is no code path or config key to cite for it. - The AppHost declares the Service Bus emulator's endpoints and connection string, but how
AddBrokerMessagingconsumesUseDevelopmentEmulator=trueandMessageBus:EmulatorAdminEndpointis inMMCA.Common.Infrastructure, outside the files this chapter walks. The emulator resource's own doc comment (ServiceBusEmulatorResource.cs:18-24) is the only statement of that contract cited here. - Every ingestion figure quoted in the cost sections (roughly 65% of AppMetrics, about 290 MB of a
500 MB daily stream, 100% of
AppRequestsrows from probes, the ASP.NET Core meter family at 73% of ingestion, Live Metrics holding 64% of vCPU-seconds on the active rate, the YARP forwarder's roughly 177k console lines a week) comes from the inline comment that records the measurement, not from a workspace query this chapter ran:MMCA.ADC/infra/main.bicep:267-272,278-283,293-296and302-306, andMMCA.Common.Aspire/Telemetry/ProbeTelemetryFilter.cs:8-11. - That the
AtlDevCondatabase is actually gone from the Azure SQL server is asserted by the template's comment (main.bicep:890-899), which is explicit that Incremental-mode Bicep never deleted it and an operator did, after the template stopped declaring it. The template can only prove the declaration is absent, not the server's current state.