Architecture Decision Record
ADR-079: One Shared, Ordered HTTP Middleware Pipeline for Every Service Host
Status
Accepted (2026-08-14). Revised 2026-08-19 (refreshed the WebApplicationBuilderExtensions.cs
cross-reference anchor, which moved to :555).
Context
In ASP.NET Core, middleware order is behavior, not style: a rate limiter placed before authentication
partitions every request as anonymous, an HTTPS redirect placed in front of a gRPC call breaks the
call, and a tenant resolver that reads HttpContext.User before UseAuthentication resolves nothing.
Each of those is a silent, correct-looking build.
The framework already ships that pipeline as a single call. Three accepted records cite it as where
their middleware sits: ADR-019 relies on forwarded headers running before the rate limiter
(019-rate-limiting.md:77), ADR-047 pins SoftDeletedUserMiddleware between authentication and
authorization (047-soft-deleted-user-session-revocation.md:33-38), and ADR-073 places
TenantResolutionMiddleware immediately after UseAuthentication because claim-first resolution needs
a populated principal (073-multi-tenancy-model.md:74-81). All three take the pipeline as given
context. None of them decides it, and nothing else does either, so the one ordering that every host in
both production apps depends on has been an implementation detail of a method rather than a recorded
decision. This record makes the pipeline itself the decision.
It is the HTTP counterpart of ADR-014, which does the same job for the CQRS decorator chain: one fixed, documented order that handler and host authors do not re-litigate per project.
Decision
Ship the edge as one ordered pipeline in the framework, UseCommonMiddlewarePipeline
(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationExtensions.cs:45), and have
every REST/gRPC host call it instead of composing its own.
- One call, whole edge, controllers included. The method is an
extension(WebApplication app)member (WebApplicationExtensions.cs:37) that registers every edge middleware and finishes by mapping controllers (:121), returning the app for chaining (:123). A host's composition root is one line, not a hand-ordered list. - The order is fixed and complete. Exception handler (
:47), correlation id (:48), request localization (:53), pre-forwarded scheme/host capture (:72-77), forwarded headers (:79), gRPC-exempt HTTPS redirect (:87-89), response compression (:91), routing (:92), CORS (:93-95), authentication (:96), tenant resolution (:102), rate limiter (:108), soft-deleted-user check (:109), authorization (:110), output cache (:111), JWKS (:118) and OIDC discovery (:119) endpoints, controllers (:121). - Authentication before tenant resolution, and the code says why.
TenantResolutionMiddlewareis registered at:102, immediately afterapp.UseAuthentication()(:96), because the claim strategy readsHttpContext.User, which carries token claims only once authentication has run (comment at:98-101, ADR-073). - Authentication before rate limiting, and the code says why.
app.UseRateLimiter()(:108) runs after authentication on purpose: the global partition keys on the authenticated principal and routes anonymous traffic down aNoLimiterbranch, so an unpopulatedHttpContext.Userwould make every request look anonymous and the per-user cap would never engage (comment at:104-107, ADR-019). - Forwarded headers before anything that reads the client IP, trusting any proxy.
UseForwardedHeaders(:79) is configured forXForwardedFor | XForwardedProto | XForwardedHost(:55-58) with bothKnownProxiesandKnownIPNetworkscleared (:63-64), because cloud reverse proxies front the services from internal addresses that are not in the default allow-lists (comment at:60-62). It sits ahead of the rate limiter (:108), which is the ordering ADR-019 depends on. - HTTPS redirect is exempted for gRPC. The redirect is wrapped in
app.UseWhenand skipped for any request whoseContent-Typestarts withapplication/grpc(:87-89), because extracted services are reached over HTTP/2 cleartext and a 307 on those requests breaks the call (comment at:81-86, ADR-012). - The soft-deleted-user check sits between the limiter and authorization.
SoftDeletedUserMiddleware(Source/Presentation/MMCA.Common.API/Middleware/SoftDeletedUserMiddleware.cs:31) is registered at:109, afterUseRateLimiter(:108) and beforeUseAuthorization(:110), so a revoked account is rejected before any endpoint authorizes it (ADR-047). - JWKS and OIDC discovery are always mapped.
MapJwksEndpoint()(:118) andMapOidcDiscoveryEndpoint()(:119) are unconditional; a non-Identity host answers with an empty key set or a 404 rather than a different pipeline shape (comment at:113-117;OidcDiscoveryEndpointExtensions.cs:63-66is the 404 path). - Conditional middleware is registered unconditionally and made inert by configuration. Both
TenantResolutionMiddleware(:102) andSoftDeletedUserMiddleware(:109) are always in the chain and gated by their own settings, so the pipeline is literally one shape on every host rather than a per-host permutation. - Every REST/gRPC host calls it. All seven extracted services in the two production apps: ADC
Identity (
MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:322), ADC Conference (MMCA.ADC.Conference.Service/Program.cs:392), ADC Engagement (MMCA.ADC.Engagement.Service/Program.cs:328), ADC Notification (MMCA.ADC.Notification.Service/Program.cs:258), Store Catalog (MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:266), Store Identity (MMCA.Store.Identity.Service/Program.cs:257) and Store Sales (MMCA.Store.Sales.Service/Program.cs:275). The reference app calls it too (MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:111), and because that tree is themmca-apptemplate (MMCA.Helpdesk/.template.config/template.json:5,7, ADR-065), a scaffolded app gets the same line: the generatedMMCA.ECommercesample has it atMMCA.ECommerce/Source/Hosts/MMCA.ECommerce.Web/Program.cs:100. - Hosts extend it by appending, after the call. Service hosts map their extra endpoints below the
one line: OpenAPI outside Production (
MMCA.ADC.Notification.Service/Program.cs:265-268), the SignalR hub (:275, whichSignalRExtensions.cs:18-19documents as "call afterUseCommonMiddlewarePipeline"), and gRPC services (:277-279).
Scope is REST and gRPC service hosts. The Blazor UI hosts and the YARP gateways deliberately do not
call it: the gateways compose a much thinner chain (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:95,
MMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:119), and the UI hosts hand-compose their own,
reusing only the localization half via UseCommonRequestLocalization()
(MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:124,
MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:164), which is the public method the pipeline
itself calls at :53 (WebApplicationExtensions.cs:133).
Rationale
- Order is behavior, so it belongs to the framework, not to each host. Four of the adjacencies above fail silently when reversed: the limiter stops limiting, the tenant resolver resolves nothing, the client IP becomes the proxy's, and gRPC calls get redirected. Centralizing removes the chance to get any of them wrong once per host, which is the same invariant-over-discipline posture as ADR-015.
- One place to change means one place to review. ADR-019, ADR-047 and ADR-073 each added middleware
by editing this method, and each got a comment explaining its position. The comments accumulate where
the ordering lives instead of being copied into seven
Program.csfiles. - Unconditional-and-inert keeps the shape stable. Gating on configuration rather than on registration means a host that turns multi-tenancy on later changes a setting, not its pipeline, and a diff between two hosts' edges is empty by construction.
- It preserves the extraction path. ADR-008 promises a module can be lifted into its own service without a rewrite. A host-owned pipeline would make the new service's edge a hand-transcribed copy; one shared call makes the extracted service inherit the identical edge behavior for free.
- It is the HTTP sibling of a pattern already proven in-process. ADR-014 fixes the decorator order for commands and queries for the same reason and with the same result: authors reason about one documented sequence.
Trade-offs
- Nothing freezes the order. A workspace-wide search finds no test referencing
UseCommonMiddlewarePipeline: the only non-host references are the method itself, a cross-reference inWebApplicationBuilderExtensions.cs:555and one inSignalRExtensions.cs:19. Reordering two lines compiles, passes every analyzer, and passes the unit tiers. The load-bearing adjacencies are protected by comments and review, not by a fitness test. That is the weakest point of this decision. - There is no extension point for a host that needs a deviation. The method takes no parameters and
exposes no hooks (
WebApplicationExtensions.cs:45). A host can only prepend or append around the call; it cannot insert into the middle, replace one step, or drop one. A host that genuinely needs a different edge has to stop calling the method and re-implement the whole chain, which is what the Blazor UI hosts do (MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:145,164,MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:105,124). That is the honest cost of the fixed order: the escape hatch is all-or-nothing. - Controllers are mapped inside the call. Because
MapControllers()runs at:121, every endpoint a host maps afterwards is registered after controller routing. Hosts that need a hub or a gRPC service simply map it later, but a host cannot use this method and still map something ahead of controllers. - Trusting any proxy is a deliberate security trade. With
KnownProxiesandKnownIPNetworkscleared (:63-64),X-Forwarded-Foris accepted from any caller, so the IP-keyed rate-limit partitions are spoofable by anything that can reach a service directly. This is safe only because the services are not publicly routable and sit behind the gateway; ADR-019 records the same caveat. - Security-response headers are not in this pipeline. ADR-023's
UseCommonSecurityHeadersis applied by the gateways and UI hosts only (MMCA.ADC.Gateway/Program.cs:117,MMCA.Store.Gateway/Program.cs:140,MMCA.ADC.UI.Web/Program.cs:105,MMCA.Store.UI.Web/Program.cs:145). A service host exposed directly, without a gateway in front, would serve responses without them. - One step in the fixed order is currently dead weight. The pre-forwarded scheme/host capture
(
:72-77) writesHttpContext.Items["PreForwardedScheme"]and["PreForwardedHost"], and its XML doc (:18-35) says the OIDC discovery endpoint consumes them. It no longer does:MapOidcDiscoveryEndpointderivesjwks_urifromJwt:Issuer(OidcDiscoveryEndpointExtensions.cs:76, with the rationale at:68-75), and a workspace-wide search forPreForwardedfinds no reader anywhere outside this file and the onboarding chapters that describe it. The middleware costs one delegate per request and stays because removing it is a separate change, but it is not load-bearing today. - The method's own summary comment is not the contract. The order listed at
:39-44omits request localization, the pre-forwarded capture, and the JWKS/OIDC mapping that the body actually registers. Read the body, not the summary.
Related
ADR-014 (the in-process sibling: one fixed decorator order for commands
and queries), ADR-019 (depends on forwarded headers before the limiter and on the
limiter after authentication), ADR-047 (depends on the slot
between authentication and authorization), ADR-073 (depends on the slot
immediately after authentication), ADR-027 (the request localization this
pipeline wires at :53), ADR-012 (the h2c transport the HTTPS-redirect
exemption exists for), ADR-040 (the output cache
at :111), ADR-023 (the edge middleware deliberately NOT in this
pipeline), ADR-008 (the extraction path this shared edge preserves).