↑↓ to navigate Enter to open "…" all these words ANDOR to combine

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). Revised 2026-08-21: the order became data (named steps seeded by MiddlewarePipelineBuilder.CreateDefault()), gained a scoped configure overload with startup-validated invariants, and is frozen by the MiddlewarePipelineOrderTestsBase fitness function; the two costs this record originally carried as open trade-offs are retired below. Revised 2026-09-19: the HTTPS-redirect gRPC exemption is now keyed on the negotiated protocol (MiddlewarePipelineBuilder.IsCleartextHttp2) rather than on a forgeable Content-Type header (SEC-Common-44), and the fitness function is recorded as adopted by all three consumer repos. Revised 2026-09-25: the ForwardedHeaders step builds its options from CommonForwardedHeaders.Create() (v1.211.0), the same factory both Blazor UI hosts now adopt through UseCommonUiForwardedHeaders(), so the UI hosts reuse the forwarded-headers posture as well as the localization half; the MiddlewarePipelineBuilder.cs, WebApplicationExtensions.cs and UI-host anchors that change moved are refreshed.

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:48), 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 applies every edge middleware and finishes by mapping controllers, returning the app for chaining. A host's composition root is one line, not a hand-ordered list.
  • The order is data, not prose. Each step is a named MiddlewarePipelineStep (a name plus the configure delegate), the names are constants on MiddlewarePipelineStepNames (Startup/Pipeline/MiddlewarePipelineStepNames.cs:17-74, declared in application order), and MiddlewarePipelineBuilder.CreateDefault() (Startup/Pipeline/MiddlewarePipelineBuilder.cs:31-151) seeds the eighteen defaults: exception handler, correlation id, request localization, pre-forwarded scheme/host capture, forwarded headers, gRPC-exempt HTTPS redirect, response compression, routing, CORS, authentication, tenant resolution, rate limiter, soft-deleted-user check, authorization, output cache, JWKS and OIDC discovery endpoints, controllers. Both overloads route through one private helper that builds the list and applies it in order (WebApplicationExtensions.cs:163-179).
  • A scoped escape hatch, validated at startup. The UseCommonMiddlewarePipeline(Action<MiddlewarePipelineBuilder>) overload (WebApplicationExtensions.cs:60) hands the host the seeded builder, which can InsertBefore, InsertAfter, Replace, or Remove steps by name (MiddlewarePipelineBuilder.cs:161-224; unknown anchors and duplicate names throw). Build() (:252-275) then re-checks the load-bearing adjacencies below and throws naming the violated invariant, so a customized pipeline fails while the host is starting instead of misordering silently. An invariant binds only when both of its steps are still present, so dropping a whole capability (both members of a pair) stays legal.
  • Authentication before tenant resolution, and the code says why. The TenantResolution step sits immediately after Authentication (MiddlewarePipelineBuilder.cs:107-113), because the claim strategy reads HttpContext.User, which carries token claims only once authentication has run (comment at :109-112, ADR-073). Build() enforces the adjacency (:259-262).
  • Authentication before rate limiting, and the code says why. The RateLimiting step runs after authentication on purpose: the global partition keys on the authenticated principal and routes anonymous traffic down a NoLimiter branch, so an unpopulated HttpContext.User would make every request look anonymous and the per-user cap would never engage (comment at MiddlewarePipelineBuilder.cs:117-120, ADR-019). Build() enforces the precedence (:264-267).
  • Forwarded headers before anything that reads the client IP, trusting any proxy. The ForwardedHeaders step (MiddlewarePipelineBuilder.cs:64-69) builds its options from the framework's one posture, CommonForwardedHeaders.Create() (:69; Startup/CommonForwardedHeaders.cs:42-53): XForwardedFor | XForwardedProto | XForwardedHost (CommonForwardedHeaders.cs:33-34) with both KnownProxies and KnownIPNetworks cleared (:49-50), because cloud reverse proxies front the services from internal addresses that are not in the default allow-lists (:14-20, and the step's own comment at MiddlewarePipelineBuilder.cs:66-68). The same factory is what the server-rendered UI hosts adopt through UseCommonUiForwardedHeaders() (Startup/CommonForwardedHeadersExtensions.cs:24-25), so a service and a UI host behind the same ingress read the scheme, host and client address identically. It sits ahead of the rate limiter, which is the ordering ADR-019 depends on, and Build() enforces that it precedes the HTTPS redirect (MiddlewarePipelineBuilder.cs:269-272).
  • HTTPS redirect is exempted for cleartext HTTP/2, which is the gRPC case. The redirect is wrapped in app.UseWhen and skipped for any request that matches MiddlewarePipelineBuilder.IsCleartextHttp2 (MiddlewarePipelineBuilder.cs:85-87), a predicate defined as !Request.IsHttps && HttpProtocol.IsHttp2(Request.Protocol) (:350-356), because extracted services are reached over HTTP/2 cleartext (h2c) and a 307 on those requests breaks the call (comment at :73-77, ADR-012). The exemption keys on the protocol Kestrel negotiated during connection setup, which no header can fake; it does not read Content-Type and it does not read routed-endpoint gRPC metadata, because the step runs before UseRouting and no endpoint metadata exists yet (comment at :79-84). Matching on a Content-Type of application/grpc was the original shape and was replaced as forgeable: any caller could have set that header and been served plaintext (SEC-Common-44). The predicate is public so a host that rebuilds this step through the configure overload reuses it rather than reinventing the weaker check (:345-349).
  • The soft-deleted-user check sits between the limiter and authorization. SoftDeletedUserMiddleware (Source/Presentation/MMCA.Common.API/Middleware/SoftDeletedUserMiddleware.cs:31) is the SoftDeletedUserFilter step (MiddlewarePipelineBuilder.cs:123-125), after RateLimiting and before Authorization, so a revoked account is rejected before any endpoint authorizes it (ADR-047).
  • JWKS and OIDC discovery are always mapped. The JwksEndpoint and OidcDiscoveryEndpoint steps (MiddlewarePipelineBuilder.cs:135-146) are unconditional; a non-Identity host answers with an empty key set or a 404 rather than a different pipeline shape (comment at :137-141; OidcDiscoveryEndpointExtensions.cs:63-66 is the 404 path).
  • The order is frozen by a fitness function. MiddlewarePipelineOrderTestsBase (Source/Hosting/MMCA.Common.Testing/Conformance/MiddlewarePipelineOrderTestsBase.cs:29) is the edge counterpart of ADR-014's DecoratorPipelineOrderTestsBase: it seeds the default builder, applies the subclass's Configure customization if any (:35), and asserts the step sequence is exactly the documented order (:60-67) and that Build()'s invariants hold (:69-77). No WebApplication is built, so it runs in the fast unit tier. The framework subclasses it in its own test pass (Tests/Hosting/MMCA.Common.Testing.Tests/MiddlewarePipelineOrderTests.cs), and all three consumer repos subclass it next to their decorator-order tests, each with no overrides because every host calls the zero-argument overload: MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/Api/MiddlewarePipelineOrderTests.cs:16, MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/Api/MiddlewarePipelineOrderTests.cs:16 and MMCA.Helpdesk/Tests/Architecture/MMCA.Helpdesk.Architecture.Tests/MiddlewarePipelineOrderTests.cs:15.
  • Conditional middleware is registered unconditionally and made inert at runtime. Both TenantResolutionMiddleware (the TenantResolution step, MiddlewarePipelineBuilder.cs:107-113) and SoftDeletedUserMiddleware (the SoftDeletedUserFilter step, :123-125) are always in the chain: the first passes the request straight through unless Tenancy:Enabled is set (Middleware/TenantResolutionMiddleware.cs:62), the second resolves ISoftDeletedUserValidator lazily and no-ops where no implementation is registered (Middleware/SoftDeletedUserMiddleware.cs:43-47, the lazy GetService call at :75), 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:343), ADC Conference (MMCA.ADC.Conference.Service/Program.cs:442), ADC Engagement (MMCA.ADC.Engagement.Service/Program.cs:318), ADC Notification (MMCA.ADC.Notification.Service/Program.cs:263), Store Catalog (MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:313), Store Identity (MMCA.Store.Identity.Service/Program.cs:297) and Store Sales (MMCA.Store.Sales.Service/Program.cs:297). The reference app calls it too (MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:142), and because that tree is the mmca-app template (MMCA.Helpdesk/.template.config/template.json:5,7, ADR-065), a scaffolded app gets the same line: the generated MMCA.ECommerce sample has it at MMCA.ECommerce/Source/Hosts/MMCA.ECommerce.Web/Program.cs:100.
  • Hosts extend it by appending, after the call, or through the builder. Service hosts map their extra endpoints below the one line: OpenAPI outside Production (MMCA.ADC.Notification.Service/Program.cs:270-277), the SignalR hub (:284, which SignalRExtensions.cs:18-19 documents as "call after UseCommonMiddlewarePipeline"), and gRPC services (:293 and :301). A host that needs a change inside the edge uses the configure overload instead; no host does today, and every one of the eight production and reference hosts calls the zero-argument overload.

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:158-221, MMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:150-173), and the UI hosts hand-compose their own, reusing two pieces of this pipeline through public methods. The first is the forwarded-headers posture, opened first in each UI pipeline via UseCommonUiForwardedHeaders() (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:192, MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:188), which applies the same CommonForwardedHeaders.Create() options the ForwardedHeaders step uses (Startup/CommonForwardedHeadersExtensions.cs:24-25). The second is the localization half via UseCommonRequestLocalization() (MMCA.ADC.UI.Web/Program.cs:220, MMCA.Store.UI.Web/Program.cs:221), which is the public method the pipeline's RequestLocalization step calls (MiddlewarePipelineBuilder.cs:47, WebApplicationExtensions.cs:73).

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.cs files.
  • 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

  • Two of this record's original costs are retired (2026-08-21). As accepted, nothing froze the order (no test referenced the method; the adjacencies were protected by comments and review, and that was the weakest point of the decision) and there was no extension point (the method took no parameters, so the escape hatch was all-or-nothing: stop calling it and re-implement the chain, which is what the Blazor UI hosts still deliberately do, MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:188,192,221, MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:192,198,220). Both are addressed by the revision above: MiddlewarePipelineOrderTestsBase turns a reorder into a red test, Build() turns a misordered customization into a startup failure, and the configure overload makes the escape hatch scoped instead of all-or-nothing. What remains true: the fitness function is opt-in per repo. All three consumer repos have taken it up, so nothing is currently uncovered, but a future repo that never subclasses it would get only the startup validation, and only for the invariants Build() knows about, not for the full sequence.
  • The extension API is a new public surface to hold stable. Step names are now contract: renaming a constant on MiddlewarePipelineStepNames, or reordering in a way the invariants do not cover, is a behavior change for any host using the configure overload. No host uses it yet, which makes this cheap today and easy to underestimate later.
  • Controllers are mapped inside the call. Because the Controllers step is last (MiddlewarePipelineBuilder.cs:148-150), every endpoint a host maps after the call is registered after controller routing. Hosts that need a hub or a gRPC service simply map it later; a host that genuinely needs something ahead of controllers can now InsertBefore(Controllers, ...) through the configure overload instead of abandoning the method.
  • Trusting any proxy is a deliberate security trade. With KnownProxies and KnownIPNetworks cleared (Startup/CommonForwardedHeaders.cs:49-50), X-Forwarded-For is 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. The UI hosts take the identical posture through UseCommonUiForwardedHeaders(), which rests on the same assumption stated in the code: the ingress is the only thing that can reach the container (CommonForwardedHeaders.cs:19-20).
  • Security-response headers are not in this pipeline. ADR-023's UseCommonSecurityHeaders is applied by the gateways and UI hosts only (MMCA.ADC.Gateway/Program.cs:168, MMCA.Store.Gateway/Program.cs:159, MMCA.ADC.UI.Web/Program.cs:198, MMCA.Store.UI.Web/Program.cs:192). 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 (the PreForwardedCapture step, MiddlewarePipelineBuilder.cs:49-62) writes HttpContext.Items["PreForwardedScheme"] and ["PreForwardedHost"], and the keys' XML doc (WebApplicationExtensions.cs:18-35) says the OIDC discovery endpoint consumes them. It no longer does: MapOidcDiscoveryEndpoint derives jwks_uri from Jwt:Issuer (OidcDiscoveryEndpointExtensions.cs:76, with the rationale at :68-75), and a workspace-wide search for PreForwarded finds no reader anywhere outside this file and the onboarding chapters that describe it. The step costs one delegate per request and stays because removing it is a separate change (now a one-line Remove for a host that wants it gone), but it is not load-bearing today, and its Build() adjacency invariant guards the capture's fidelity, not a live consumer. A related casualty of the accepted revision: the method's summary comment, which used to list a stale subset of the order, now points at the step-name contract instead of restating it.

Revision (2026-10-01)

Anchor refresh only; no decision or rationale changed. Refreshed citations: the private ApplyPipeline helper (WebApplicationExtensions.cs:163-179), the lazy validator resolution in SoftDeletedUserMiddleware.cs (:43-47, call at :75), the eight UseCommonMiddlewarePipeline() call sites (ADC Identity :343, Conference :442, Engagement :318, Notification :263; Store Catalog :313, Identity :297, Sales :297; Helpdesk Web :142), the Notification host extras (OpenAPI :270-277, hub :284, gRPC :293 and :301), and the gateway chains (ADC :158-221 with security headers at :168; Store :150-173).

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 :47), ADR-012 (the h2c transport the HTTPS-redirect exemption exists for), ADR-040 (the output cache at :131-133), ADR-023 (the edge middleware deliberately NOT in this pipeline), ADR-008 (the extraction path this shared edge preserves).