Architecture Decision Record
ADR-088: Gateway Edge Responsibilities (and the Three It Declines)
Status
Accepted (2026-08-18). Extends ADR-019 with a fourth, edge-tier layer whose
posture is the deliberate opposite of the service tier's authenticated-only global limiter; nothing in
ADR-019's three service-tier layers changes. It also extends
ADR-041's correlation id one hop outward, to the process that
sees a request first and its response last. ADR-004's validation
authority is unchanged on purpose: the gateway does not validate tokens, recorded below as a
rejection with a named trigger rather than as an omission. The framework half shipped in v1.154.0
(MMCA.Common/CHANGELOG.md) and both consumer gateways were wired to it in the same wave, so the
adoption statements below describe what each gateway does today.
Context
ADR-008 made the Gateway the only client entry point and gave it
three jobs: the route-to-service map, CORS, and forwarding the caller's Authorization header. Nothing
was added to it since. Meanwhile the service tier accumulated a standardized cross-cutting pipeline
(correlation id, rate limiting, forwarded headers, tenant resolution, output cache) that
ADR-079 fixed into one ordered method, and that record
scopes the gateways out of it deliberately: a reverse proxy has no controllers, no localization and
no tenant context, so composing the service chain there would be wrong.
Scoping the gateways out was right, and it left a real gap, because three of those behaviors are not
service concerns at all. They are edge concerns the service tier had been performing one hop too late.
Before this record the gateway chains were four calls long and contained none of them: ADC registered
security headers, default endpoints, CORS, static files and a privacy endpoint around its forwarders
(MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:77, :117, :119, :120, :129, :134-135),
and Store registered security headers, default endpoints and CORS
(MMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:58, :64, :140, :142, :143). Neither
had a rate limiter or a correlation id of any kind.
A correlation id minted per service is not a correlation id. CorrelationIdMiddleware (ADR-041)
runs inside each service host and falls back to the W3C trace id when the client sends no
X-Correlation-ID. A browser call crossing the Gateway into two services therefore produced two
independent ids, neither present in the Gateway's own logs, and an operator holding the id from one
service could not find the request's first hop. The one process guaranteed to see every request
exactly once was the only one not stamping it.
ADR-019's anonymous exemption is correct one hop in and wrong at the edge. That record exempts anonymous traffic from the global limiter for two stated reasons: Blazor Server fronts public browsing behind a single IP, so an anonymous IP cap would throttle real visitors, and public reads are served from the output cache anyway, so the backend they reach is already cheap. Both are properties of a service sitting behind the Gateway. At the edge neither holds. The output cache that made an anonymous read cheap lives behind the proxy, so a flood is paid for in full by the Gateway (accept, route, forward, copy the response) before anything can be served from cache, and the fleet's only shared choke point had no cap at all on the traffic class that most needs one.
A gateway that is "healthy" while every downstream is unreachable still receives traffic. The Gateway's readiness endpoint reported only that the Gateway process was up. Azure Container Apps then routed to it and it forwarded to services that were not answering, converting a downstream outage into a wall of 502s from a replica the platform believed was ready.
One packaging constraint shapes where the fix can live. A YARP host references MMCA.Common.Aspire and
nothing else in the framework: it has no controllers, so it does not take MMCA.Common.API, where
CorrelationIdMiddleware and AddCommonRateLimiting live. Anything the edge owns has to be reachable
from the Aspire package alone.
Decision
Ship a gateway edge kit in a Gateway namespace inside MMCA.Common.Aspire, owning exactly three
responsibilities, and record three more as deliberately declined.
What the edge owns
1. Correlation is ensured, not merely read. GatewayCorrelationMiddleware
(MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Gateway/GatewayCorrelationMiddleware.cs) declares
X-Correlation-ID as a constant (:34), and when the header is absent it mints one from
Activity.Current?.TraceId, falling back to HttpContext.TraceIdentifier (:48-55), the same
precedence ADR-041's service middleware uses. The mechanism that makes it one id is that the value is
written back onto the request headers before forwarding: the downstream service's own middleware
then finds a header already present and adopts it instead of minting its own. The response echo is
registered through Response.OnStarting (:58-62), and the registration extension is
UseGatewayCorrelation() (:82).
The middleware is context-free, and that is a constraint rather than an accident of scope. Its only
constructor dependency is the RequestDelegate (:27): no HttpContext.Items, no logger, no scoped
service. The service-tier version sets a scoped ICorrelationContext that the CQRS logging decorators
read, and the Aspire package cannot reference the Application layer that declares that abstraction. The
edge version is therefore a deliberately smaller thing than its namesake, not a copy of it, and it
composes with a host that has no DI graph beyond YARP.
2. Rate limiting at the edge counts anonymous callers, and chains a global concurrency cap behind
it. AddGatewayRateLimiting installs a per-client-IP fixed window partitioned on
Connection.RemoteIpAddress with no authentication exemption of any kind
(.../Gateway/GatewayRateLimitingExtensions.cs:90, limiter at :98), chained through
PartitionedRateLimiter.CreateChained (:181) with a process-wide concurrency limiter (:124-125),
rejecting overage with 429 (:176). The two answer different failures: the window answers one noisy
source, and the concurrency cap answers total in-flight work regardless of how many sources produced
it, which is the failure a per-IP window structurally cannot see. Defaults live in
GatewayRateLimitingSettings (section "GatewayRateLimiting",
.../Gateway/GatewayRateLimitingSettings.cs:42): PermitLimit 120 (:51) per WindowSeconds 60
(:55), GlobalConcurrencyLimit 200 (:65).
The settings are validated twice, because there are two ways in. The configuration overload binds
through AddOptions().Bind(section).ValidateDataAnnotations().ValidateOnStart()
(GatewayRateLimitingExtensions.cs:148-151), so a host with an out-of-range value refuses to boot,
which is ADR-070's contract exactly. That alone would not be
enough here: the limiter closes over an eagerly-bound copy rather than resolving IOptions per
request, and a caller can hand settings straight to the object overload without passing through the
options pipeline at all. So the overload every path funnels into runs
Validator.ValidateObject(settings, ..., validateAllProperties: true) at registration (:172, with
the reasoning stated inline at :169-171). The [Range] bounds on the three numeric settings
(GatewayRateLimitingSettings.cs:50, :54, :64) are therefore load-bearing on both paths: an
invalid PermitLimit throws where it is configured, not at the first throttled request.
Bypasses are two-tier, and the tiers are different kinds of thing. Infrastructure bypasses are
unconditional: /health, /alive and /.well-known are hard-coded
(GatewayRateLimitingExtensions.cs:47, matched by path segment, case-insensitively, :64), because
throttling them takes down probes and token validation (ADR-004's JWKS discovery) as a side effect of
throttling traffic. Application bypasses are configuration, through BypassPathPrefixes
(GatewayRateLimitingSettings.cs:74, empty by default), and each consumer sets its own list in the
gateway's appsettings.json beside the ReverseProxy route table that same file now declares
(ADR-089). The two entries are recorded here so they
are not rediscovered as incidents: Store's Stripe webhook route (/Payments/{**catch-all}, bypassed
by the "/Payments" prefix at
MMCA.Store/Source/Hosts/MMCA.Store.Gateway/appsettings.json:17, route at :57-60), because a 429
to Stripe is a retry and eventually a disabled endpoint, which silently stops every payment update
(ADR-084); and ADC's SignalR hub route (/hubs/{**catch-all},
bypassed by the "/hubs" prefix at
MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:18, route at :122-125), because a
negotiate-plus-reconnect storm from one office's shared address is exactly the pattern a per-IP window
misreads as abuse (ADR-039).
A request with no attributable client IP is not limited. It gets
RateLimitPartition.GetNoLimiter (GatewayRateLimitingExtensions.cs:90-95) rather than sharing one
bucket with every other unattributable request, which is the same fail-open posture ADR-019 chose for
auth-ip and for the global limiter's fallback key, for the same reason: a shared "unknown" bucket is
a single tripwire that one misbehaving caller pulls for everyone behind it.
3. Readiness reflects the downstreams; liveness does not.
AddGatewayDownstreamHealthChecks(params string[] serviceNames)
(.../Gateway/GatewayHealthCheckExtensions.cs:75) registers one check per named downstream with an
HttpClient whose BaseAddress is the Aspire service-discovery name http://{name} (:93-100),
deduplicated through a registry so a repeated name cannot double-probe (:80-88, :124-171). Each
check probes /alive (DownstreamServiceHealthCheck.cs:31) under a 2 second budget applied at both
the client and the registration (:29, GatewayHealthCheckExtensions.cs:99, :110), reports
Unhealthy on failure (:108) and carries the Ready tag (:109).
The tag is the whole design. The Aspire defaults map /alive to checks tagged Live
(MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:342-345) and /health/ready to
everything not tagged Live or Optional (:358-361), so a Ready-tagged downstream check reaches
readiness and never reaches liveness. Liveness must stay process-local, or a downstream outage restarts
a perfectly healthy Gateway and makes the outage worse. /alive answers "is this process wedged",
/health/ready answers "can this process do useful work", and only the second depends on anything
else. That is ADR-025's split, applied to a dependency rather than
to a startup task.
What the edge declines
Edge JWT pre-validation is deferred, and the deferral is the decision. The obvious next move is to validate the bearer token at the Gateway and reject an invalid one before it costs a forward. It is not being made. ADR-004 puts validation authority in the services, and a second validator does not add a check, it adds a second truth: two processes reading two JWKS caches can disagree across a key rotation, and the one that rejects is the one the caller sees. The Gateway would also acquire issuer and JWKS configuration it does not have today, making an Identity outage a Gateway outage, and it would have to decide what to do about ADR-022's cookie-carried browser sessions, which are not bearer tokens at all. The saving is one forward of a request the service was going to reject in microseconds anyway.
The trigger to revisit is measured rather than felt: when invalid-or-absent-token traffic becomes a material share of forwarded volume (visible in the edge limiter and downstream signals this record adds), pre-validation becomes a cost argument instead of a correctness argument and can be taken then, with the services still validating.
The limiter is in-memory, per replica. No Redis, no shared counter, and the type documents itself
that way (GatewayRateLimitingSettings.cs:10-21). With N Gateway replicas the effective ceiling is N
times PermitLimit, the same multiplication ADR-019 records for its own per-process limiters and only
partly retired there with its Redis option. It is accepted here rather than solved: the edge limiter
exists to bound a flood, not to meter a quota, and an approximate ceiling that needs no network call
and cannot fail is the right shape for the one process the whole fleet sits behind.
The kit adds no authorization, no request rewriting and no response shaping. Everything that depends on knowing who the caller is or what the payload means stays behind the proxy, which is what keeps the Gateway a transport concern and keeps ADR-008's extraction reversible.
Rationale
- The edge is the only place that sees a request exactly once. That is what makes ensure-at-the-edge correct and mint-per-service wrong: not that the service version is broken, but that it runs after the point where uniqueness is free.
- The two rate-limit postures differ because the traffic differs, not because one is a mistake. ADR-019 measured its exemption against a backend fronted by an output cache and a Blazor Server circuit. The Gateway pays for anonymous traffic before either can help. Stating both postures together is what keeps the second from reading as a contradiction of the first.
- A chained concurrency limiter answers the failure a window cannot. A per-IP window is blind to ten thousand distinct addresses each behaving politely; a concurrency cap is blind to one address being rude but never lets in-flight work exceed what the process can carry. Neither alone bounds the Gateway's load.
- Bypasses are split by kind because they fail differently. A throttled health probe or JWKS fetch is an immediate self-inflicted outage, so it is not a setting. A throttled webhook or hub is application-specific and its correct list differs per app, so it is.
Readyand notLivekeeps a dependency failure from becoming a restart loop. Wiring downstream probes into liveness is the classic version of this mistake, and it turns a recoverable downstream blip into a fleet-wide restart at the worst moment.- Declining JWT validation is a real decision with a cost. It is recorded because the alternative is that someone reads the edge kit, notices the obvious missing piece, and adds it without knowing ADR-004 already placed the authority elsewhere.
Trade-offs
- The limiter closes over an eagerly-bound copy of the settings
(
GatewayRateLimitingExtensions.cs:154, consumed at:164-172), so anIOptionsMonitorreload never reaches it. Validation is not the gap (both paths validate, see the Decision), but liveness of the value is: changing a limit is a restart, not a config push, which is the opposite of what "it is a configuration section" usually implies. The double validation is itself a consequence of that shape rather than belt-and-braces, so the two must stay in step: a future change that made the limiter resolveIOptionsper request would make the registration-time check redundant, and one that added a third construction path would need to route through the same overload to keep it. - Per-replica limits mean the configured number is not the enforced number. An operator reading
PermitLimit120 sees a per-replica figure; the fleet ceiling depends on how many Gateway replicas are running at that instant, so it rises exactly when the load that motivated it arrives. - Fail-open on an unknown IP is a hole with a name. A caller who can arrive without an attributable address is unlimited at the edge. The alternative (one shared bucket) is worse, and this is still a hole.
BypassPathPrefixesis a prefix match, so it is only as precise as the value given. A broad prefix exempts more than intended and nothing warns; the two entries named above are narrow, and a third added carelessly is an unlimited path through the only choke point.- Downstream health checks add fan-out and an all-or-nothing readiness. Every Gateway replica probes every named downstream on the health interval, and a slow-but-alive service can exceed the 2 second budget and mark the Gateway not-ready while it is still perfectly able to serve every other service's routes.
- Two correlation middlewares now exist with one header name written twice. The gateway type and the API type are separate, in separate packages, each declaring the literal. A rename in one is a silent break, the same duplicated-literal cost ADR-041 already records for the meter names in this same Aspire package.
- Nothing gates adoption. A gateway that never calls the three registrations behaves exactly as
before, and no fitness function names a gateway host. Both consumer gateways do call all three
today (ADC
Program.cs:65,:71,:112; StoreProgram.cs:74,:79,:136), but that is a wiring habit rather than an enforced invariant, which is the audit-the-inventory caveat ADR-005 and ADR-017 both record, now applied to the edge.
Related
ADR-008 (the record that made the Gateway the only entry point and gave it routing, CORS and auth forwarding; this is the first record to add cross-cutting behavior to it), ADR-019 (the three service-tier limiter layers this adds a fourth, edge-tier layer beside, and whose anonymous exemption the edge deliberately inverts), ADR-079 (the shared service pipeline the gateways sit outside of, which is what left these three behaviors unowned), ADR-041 (the correlation id extended one hop outward, and the duplicated-literal cost the second header-name declaration repeats), ADR-004 (the validation authority the edge declines to duplicate, and the JWKS discovery path the unconditional bypass protects), ADR-025 (the readiness model these downstream checks join, including why liveness stays process-local), ADR-070 (the fail-fast contract this kit's settings honor on both construction paths, the options pipeline and the registration-time check the closed-over copy requires), ADR-084 and ADR-039 (the two traffic shapes the configurable bypass list exists for), ADR-089 (the other half of this wave: what the Gateway routes, as opposed to what it does to a request on the way through).