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

Architecture Decision Record

ADR-089: Gateway Topology Owned by Configuration

Status

Accepted (2026-08-18; revised 2026-08-23: ADC's table gained a 27th route, /Activities, on 2026-08-19; revised 2026-08-31: section 5 now states the activity timeout where it actually lives, declared once in the shared MmcaGateway ClusterRequestDefaults profile rather than copied into each cluster; revised 2026-09-19: the route counts below are recounted from the two appsettings.json tables, which now hold 33 routes in ADC and 15 in Store). Amends ADR-008: that record gave the Gateway the route-to-service map and expressed it as code (a list of MapForwarder calls). The map itself is unchanged; what changed is where it is written. YARP ReverseProxy configuration is now the single source of the route table, the per-route HTTP version policy lives in cluster configuration, and a route-map test is the drift gate in both consumer repositories. This is a consumer-side decision, and both consumers landed it on 2026-08-18. (Updated 2026-08-27: the framework does now ship a gateway package, MMCA.Common.Gateway, but it deliberately supplies behavior around the route table and never the table itself, leaving LoadFromConfig and MapReverseProxy to the host, so the decision below stays consumer-side; see ADR-088.) The Context below records the prior state that motivated the change; the Decision describes what each gateway does today.

Context

Before this record, both gateways built their route table by hand, in code. ADC made 26 MapForwarder calls and Store made 10. Neither host called AddReverseProxy or LoadFromConfig, and neither appsettings.json had a ReverseProxy section at all: YARP was present only as its forwarder primitive. Destinations were Aspire service-discovery names (http://identity, http://conference, http://catalog and so on) resolved through AddHttpForwarderWithServiceDiscovery, which is the part of the arrangement that was right and stays, now as AddServiceDiscoveryDestinationResolver (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:149, MMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:141).

The problem was not that the table was duplicated across deployment artifacts. It was that one table was described three times inside one repository, and the three already disagreed. ADC was the worked example:

  1. The code registered 15 conference forwarders.
  2. The comment above them said 16.
  3. The test pinned 23 of the 26 routes, leaving /Sponsors, /CheckIns and /Points unpinned, and its own doc comment described the set as "14 REST controllers + SessionSelection". The replacement test still names those three routes as the gap it exists to close (MMCA.ADC/Tests/Hosts/MMCA.ADC.Gateway.Tests/RouteMapTests.cs:32-34).

Three routes were live and ungated, a comment was off by one, and the test's description matched neither. None of that was caught, because nothing enumerated the table from a single place: a hand-written list of calls, a hand-written comment, and a hand-written test theory are three independent transcriptions of the same fact.

Store had the same shape with less coverage: MMCA.Store/Tests/Hosts/MMCA.Store.Gateway.Tests/ contained only graceful-shutdown and security-header suites, and a repository-wide search for MapForwarder, IHttpForwarder or RouteMap in its test tree returned nothing. Store's route table was pinned by no test at all.

What is genuinely elsewhere is not the route table, and that distinction is worth recording, because it is the duplication a reader assumes exists. The Aspire AppHost holds references and start-ordering (MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:353-361, MMCA.Store/Source/Hosting/MMCA.Store.AppHost/Program.cs:312-318) and the bicep templates hold services__<name>__http__0 environment variables on the gateway container app (MMCA.ADC/infra/main.bicep:2381-2384, MMCA.Store/infra/main.bicep:1933-1935). Both are address books: service name to URL, with no path prefix anywhere in them. They answer "where does conference resolve" and never "what reaches conference", so neither is a second route table and neither should become one.

Per-route behavior was hand-written too, and that is where the two repositories had silently diverged. ADC set ForwarderRequestConfig.ActivityTimeout from Gateway:ForwarderActivityTimeoutSeconds, defaulted to HttpResilienceDefaults.TotalRequestTimeout plus 10 seconds, so 100 seconds against the 90-second client budget (MMCA.Common/Source/Core/MMCA.Common.Shared/Resilience/HttpResilienceDefaults.cs:19), plus a one-hour Gateway:HubActivityTimeoutSeconds for the SignalR route. Store set no activity timeout anywhere. Both of its ForwarderRequestConfig constructions omitted it, so every Store route, the Stripe webhook forward included, ran on YARP's implicit default with no relationship to the resilience budget the rest of the stack is tuned to. The gap was total rather than partial, and it existed because a per-route setting written as a constructor argument in one repository is invisible from the other.

The HTTP version policy had the same shape. ADC sent HttpVersion.Version20 with RequestVersionExact, h2c prior knowledge, while deliberately leaving both unset on Notification's two routes because that host runs ADR-012's mixed-endpoint profile. Store applied Version20 plus RequestVersionExact to Catalog, Identity and .well-known, and left its Sales routes on YARP's negotiating defaults. The policy was real, per-destination and correct; it was just expressed as positional arguments at call sites rather than as a property of the destination it describes.

Decision

Make configuration the single source of the gateway route table, and pin it with a test.

1. ReverseProxy configuration is the route table

Each gateway calls AddReverseProxy().LoadFromConfig(builder.Configuration.GetSection("ReverseProxy")) and MapReverseProxy(), and the MapForwarder lists are deleted. ADC wires it at MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:146-149 and maps it at :221; Store at MMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:138-141 and :173. Routes and clusters live in the gateway's own appsettings.json: 33 routes over five clusters for ADC (8 identity, 18 conference, 5 engagement, 2 notification, at MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:78-259) and 15 routes over three clusters for Store (3 catalog, 8 identity, 4 sales, at MMCA.Store/Source/Hosts/MMCA.Store.Gateway/appsettings.json:51-144). Destinations stay Aspire service-discovery names, so nothing about ADR-008's transport-at-the-edge posture, the AppHost wiring or the bicep address book changes: what changed is that the path-prefix-to-cluster mapping is data rather than a sequence of calls.

Adding a controller to a service then means adding a route entry, not editing and rebuilding a separate deployable.

2. What stays where, stated so it stops being rediscovered

  • ReverseProxy configuration owns the routes. It is the only place a path prefix appears.
  • The AppHost owns dev orchestration. References and WaitFor ordering for local F5, nothing about paths.
  • bicep owns deployment topology. The services__<name>__http__0 variables that make the service-discovery names resolve in Azure Container Apps, nothing about paths.

The rule is that a path prefix belongs to exactly one file per gateway. An address book is not a route table, and the two must not converge.

3. RouteMapTests is the drift gate, in both repositories

ADC's RouteMapTests is the enumeration of the configured table rather than a hand-typed parallel list, and it covers every route rather than 23 of 26. A behavioral theory drives each pinned route through the real proxy pipeline (MMCA.ADC/Tests/Hosts/MMCA.ADC.Gateway.Tests/RouteMapTests.cs:178-210) with a recording fake substituted for IHttpForwarder in the factory's test services (:499-504), and two completeness facts read the host's loaded IProxyConfig and compare it against the same pinned table, so a route or cluster added to appsettings.json without a corresponding expectation fails, and an expectation with no route fails as well (:212, :234). That two-way check is what the previous one-way theory could not do, and it is the reason a configuration table is safe to adopt: configuration is not compile-checked, so the check has to be a test. /Sponsors, /CheckIns and /Points are pinned by it now.

Store gained the equivalent suite it had none of (MMCA.Store/Tests/Hosts/MMCA.Store.Gateway.Tests/RouteMapTests.cs:46): all fifteen route prefixes are pinned to their owning cluster (:102-119) and to its destination (:126-148, driven through the same recording forwarder at :201-218), with the forwarder budget and the per-cluster version settings asserted separately (:350-368, :370-389, :391-409). Updated 2026-08-27: Store's half is no longer one-way. ADC's two completeness facts are ported, so the loaded IProxyConfig is compared back against the pinned table in both directions: a route added to appsettings.json with no matching entry fails (:220-240, the reasoning stated inline at :234-236) and so does a cluster (:298-332). The same comparison now doubles as the drift gate on the shared MMCA.Common.Gateway cluster profile, asserting that no cluster declares an activity timeout of its own any more (:319-323).

4. The HTTP version policy is expressed in cluster configuration

Each cluster declares its own HttpRequest version and version policy beside the destination it applies to, which is where ADR-012's per-host profile actually belongs: the h2c-only hosts declare HTTP/2 with an exact version policy, and the mixed-endpoint hosts (ADC's Notification, Store's Sales) state neither, keeping the version-negotiating default their traffic needs: the SignalR websocket on ADC's Notification hub route, and HTTP/1.1-capable REST plus the Stripe webhook on Store's Sales, which has no websocket route (MMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:127-131). ADC's identity, conference and engagement clusters carry Version with RequestVersionExact (appsettings.json:265-268, :274-277, :283-286) and its two Notification clusters carry no HttpRequest block at all (:288-292, :293-297); Store's catalog and identity clusters carry them (appsettings.json:147-150, :156-159) and its sales cluster states neither (:164-168).

Resolving that profile is the framework's job, not each host's. GatewayClusterProfileConfigFilter in the MMCA.Common.Gateway package merges three sources per property rather than per block, so a cluster that states one value keeps inheriting the rest: the cluster's own HttpRequest, then MmcaGateway:ClusterRequestOverrides[clusterId], then MmcaGateway:ClusterRequestDefaults (MMCA.Common/Source/Hosting/MMCA.Common.Gateway/Configuration/GatewayClusterProfileConfigFilter.cs:59-74, precedence stated at :9-17). A GatewayClusterRequestProfile carries Version, VersionPolicy, ActivityTimeout and AllowResponseBuffering (.../MMCA.Common.Gateway/GatewaySettings.cs:58, :64, :67, :70), declared once in ClusterRequestDefaults (:22) and narrowed per cluster in ClusterRequestOverrides (:29). The version pair is text rather than typed, so a mistyped value fails at startup with a message naming the cluster instead of binding to a silent default (:76-111). Whatever the merge resolves reaches the forwarder as stated: dropping one cluster to HTTP/1.1 is a statement in that cluster's own profile, never a switch applied over the loaded table (GatewayClusterProfileConfigFilter.cs:18-22). Both gateways declare their shared profile in that section, ADC with the one-hour SignalR override its hub route needs (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:43-51, MMCA.Store/Source/Hosts/MMCA.Store.Gateway/appsettings.json:22-25), so the effective per-cluster policy is readable without opening Program.cs in either.

5. Store's timeout parity is fixed in the same move

Store's routes gain the activity timeout ADC already has, tied to the same HttpResilienceDefaults.TotalRequestTimeout budget, expressed as configuration rather than as a constructor argument. Store declares "ActivityTimeout": "00:01:40" exactly once, as MmcaGateway:ClusterRequestDefaults (MMCA.Store/Source/Hosts/MMCA.Store.Gateway/appsettings.json:22-25), and the shared cluster profile of section 4 merges those 100 seconds into all three clusters, none of which states a timeout of its own. ADC declares the same 100 seconds in the same place, with the one-hour override its hub cluster needs (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:43-51). The route-map test pins the resolved value on every route (MMCA.Store/Tests/Hosts/MMCA.Store.Gateway.Tests/RouteMapTests.cs:57, asserted at :350-368) and pins the absence of any per-cluster declaration beside it (:319-323), so a cluster that reintroduced its own would fail rather than silently opt out of the shared budget. This is folded into this record rather than filed separately because the reason Store never got it is exactly the reason this record exists: a per-route setting buried in a call site in one repository is not visible as a missing setting in the other. Moving both tables into the same declarative shape makes the difference a diff instead of an archaeology exercise.

Rationale

  • The drift already happened, in the repository that has the most gateway tests. ADC is the careful consumer, and it still carried three unpinned routes, an off-by-one comment and a test description matching neither. The argument for configuration is not aesthetic: a hand-maintained list of calls produces exactly this, and adding discipline has already been tried. The gate that replaced the hand-typed list closes the code-versus-test half of that and not the comment half: a comment is still an ungated description of the table. ADC's RouteMapTests label ("17 REST controllers + SessionSelection") currently agrees with its 18 conference entries (MMCA.ADC/Tests/Hosts/MMCA.ADC.Gateway.Tests/RouteMapTests.cs:146-164), but nothing holds it there, and Store's gateway comment describes the ReverseProxy section as "ten routes, three clusters" (MMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:115) above a table of 15 routes (MMCA.Store/Source/Hosts/MMCA.Store.Gateway/appsettings.json:51-144), so the off-by-N recurs, just no longer load-bearing.
  • A route table is data, and data belongs in configuration. Nothing in a forwarder registration is a decision the compiler can check anyway: the path is a string, the destination is a string, the cluster name is a string. Writing them as C# buys a build step and no verification.
  • Naming the AppHost and bicep as address books prevents the wrong fix. The instinct on hearing "the route table is duplicated" is to unify the AppHost, bicep and the gateway. That would put path prefixes into deployment templates, which is the actual multiple-descriptions problem this record is trying not to create.
  • Configuration without a test would be worse than code. A typo in a cluster name was a compile error before and is a runtime 502 now. The gate is what makes the trade net-positive, which is why it is part of the decision and not a follow-up.
  • Per-destination policy belongs beside the destination. An HTTP version policy that lives in the argument list of one of 26 calls is invisible; the same policy on the cluster is one line under the service it describes, and ADR-012's profiles become readable as configuration rather than inferable from argument omissions.
  • Folding the Store timeout in keeps the parity fix from being lost. It is a two-line difference that has survived several sweeps precisely because nothing put the two tables side by side.

Trade-offs

  • The compiler stopped helping. A misspelled cluster reference, a malformed path pattern or a route that shadows another is a runtime failure, discovered as a 404 or a 502, where the previous code would not have built. The route-map test is the only thing standing in that gap, so its coverage is now load-bearing in a way it never was.
  • Configuration in appsettings.json still ships with the image. Route changes remain a deploy; what disappears is the C# edit and the rebuild reasoning, not the release. Calling the table "configuration" invites the assumption that it can be changed live, and it cannot unless a future change moves it to an external configuration source.
  • Ordering and matching semantics changed. MapForwarder routes were ASP.NET Core endpoints matched by the routing table's own precedence; YARP config routes are matched by YARP's rules, with explicit Order available. Catch-all routes (/hubs/{**catch-all}, /Payments/{**catch-all}) are the ones most likely to behave subtly differently, and they are also the two routes whose failure is least visible: a websocket that will not upgrade and a webhook that Stripe silently retries.
  • YARP config exposes transforms the code path never used. A richer surface is available at the moment the table becomes easy to edit, and header or path transforms at the gateway are exactly the edge behavior ADR-088 declines to own. Nothing prevents one from appearing in a route entry. (2026-08-27: one framework transform now exists, stamping the selected route and cluster onto the forwarded request, and ADR-088 narrows its decline to name it as the one deliberate exception. The residual stands for route-entry transforms, which are still ungated.)
  • Two repositories must land the same shape or the divergence simply moves. The Store timeout gap was the proof that a per-gateway habit does not propagate. Both landed the shape, and one smaller difference survived it: Store's route-map suite had no IProxyConfig completeness check, closed by porting ADC's two completeness facts. What made it close is worth recording: the shared MMCA.Common.Gateway package gave the two hosts one settings shape to converge on, which is a stronger convergence force than either repo noticing the other's diff. Configuration still makes divergence easier to see rather than impossible, and the route table itself remains consumer-owned by design.
  • A JSON table reviews less well than a code diff. A reviewer reading ADC's 33 routes in appsettings.json, or Store's 15, has no types, no navigation and no compiler; the gain in editability is partly a loss in review signal, offset only by the test.

Revision (2026-10-01)

No decision changed. Two current-state statements were corrected. The Rationale example of an ungated comment drifting no longer holds for ADC, whose conference label now reads "17 REST controllers + SessionSelection" over 18 entries (MMCA.ADC/Tests/Hosts/MMCA.ADC.Gateway.Tests/RouteMapTests.cs:146-164); the example is now Store's "ten routes, three clusters" comment (MMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:115) over 15 configured routes. Section 4 no longer attributes websocket traffic to Store's Sales cluster, which keeps the negotiating default for HTTP/1.1-capable REST and the Stripe webhook (MMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:127-131). Stale citations were re-anchored: both gateways' Program.cs wiring and MapReverseProxy, both route and cluster tables and the shared MmcaGateway profile in appsettings.json, both AppHost gateway blocks, both bicep gateway address books, and both RouteMapTests suites.

ADR-008 (amended: the Gateway keeps the route-to-service map it was given, now expressed as configuration rather than as forwarder registrations), ADR-088 (the other half of this wave: what the Gateway does to a request passing through, as opposed to where it sends it, including the rate-limit bypasses that name two of the routes this table declares), ADR-012 (the per-host Kestrel profiles whose forwarding counterpart the cluster version policy now declares beside the destination), ADR-009 (the HttpResilienceDefaults.TotalRequestTimeout budget Store's missing activity timeout is being tied back to), ADR-084 and ADR-039 (the two catch-all routes whose matching semantics deserve the most care in the move), ADR-015 (the invariant-over-discipline posture the route-map gate applies to a table that configuration would otherwise leave unchecked), ADR-058 (the booted-host conformance model the route-map test follows: assert against the running configuration, not against a reflection of the source).