Architecture Decision Record
ADR-012: gRPC-Host Transport Convention (Http2-only h2c vs. Http1AndHttp2 + ALPN)
Status
Accepted.
Update (2026-06-22): Store converged to Profile A
Store originally chose Profile B, but its cross-service gRPC failed in Azure Container Apps. With
Http1AndHttp2 Kestrel + transport: 'auto' ingress on a cleartext endpoint there is no ALPN, so
envoy delivered HTTP/1.1 to Catalog and Identity (which do serve inbound gRPC: Sales → Catalog,
Sales → Identity) and Kestrel rejected it with HTTP_1_1_REQUIRED (Sales AddItemCommand 500'd calling
IProductVariantService.ExistsAsync). The lesson: a "consumer-only / one-directional" edge topology
still has inbound cleartext gRPC servers, and ACA cleartext ingress cannot deliver HTTP/2 to them
under Profile B.
Fixed in commit 49b7283 (deployed green) by adopting Profile A for Store:
- Catalog + Identity run
Http2-only on cleartext (Kestrel:EndpointDefaults:Protocols=Http2), ACA ingresstransport: 'http2', with TCP startup/liveness probes (Http2-only Kestrel rejects the kubelet's HTTP/1.1httpGetprobes with GOAWAY). - Gateway forwards HTTP/2 (
ForwardHttp2=true,VersionPolicy=RequestVersionExact); Catalog/Identity routes carry HTTP/2, Sales routes stay HTTP/1.1. - Sales (no gRPC server) stays
Http1AndHttp2withtransport: 'http'. - JWKS authority differs by environment: prod ACA keeps the direct
http://identityauthority (the http2 ingress carries the HTTP/1.1 JwtBearer JWKS metadata fetch to the container), while the local-Aspire path was subsequently moved to the gateway-routedWithJwksDiscovery(identity, gateway)form (the D32 fix) — a single-arg localWithJwksDiscoverywould aim the HTTP/1.1 backchannel at the now-Http2-only Identity HTTPS endpoint and fail on the ALPN mismatch. So Store's local JWKS now matches Profile A's gateway-routed discovery; only prod uses the in-cluster direct authority.
Both consumers now use Profile A for their gRPC-serving edges. Http1AndHttp2 (Profile-B Kestrel)
survives on two non-gRPC-serving hosts for two different reasons: ADC's Notification service (its
WebSocket Upgrade handshake needs HTTP/1.1) and Store's Sales service (a consumer-only host that
serves no inbound cleartext gRPC, so it never needed Http2-only). Profile B is retained below as (a) the
original rationale for the split and (b) the still-valid rule for those no-inbound-gRPC hosts.
(Since 2026-07-09, ADC's Notification does serve one inbound gRPC edge, from a dedicated Http2-only
endpoint alongside its Http1AndHttp2 default endpoint: see the mixed-endpoint update below.)
Update (2026-07-09): ADC Notification adds a mixed-endpoint profile (per-endpoint protocols)
The live-channel push pipeline (ADR-039) gave ADC's Notification service an inbound cleartext gRPC
server (LiveChannelPushService.PushToChannel, called best-effort by Engagement command handlers)
while it still hosts the SignalR hub whose WebSocket transport needs the HTTP/1.1 Upgrade handshake.
That combination breaks the original per-host rule ("a WebSocket host cannot be a cleartext gRPC
server"): neither whole-host profile fits, because the constraint was only ever per endpoint, not
per host. The resolution is to split protocols across two Kestrel endpoints in one process:
- Kestrel (per-endpoint, not per-host): the default
httpendpoint (container port 8080) staysHttp1AndHttp2for REST, health probes, and the SignalR WebSocket Upgrade; a second namedgrpcendpoint (container port 8081) isHttp2-only for h2c prior-knowledge gRPC. Declared inKestrel:Endpointsin the service's appsettings (with distinct fixed ports in the dev profile). - Aspire (local): the AppHost wires
engagementService.WithReference(notificationService), which injectsservices__notification__grpc__0; the typed client registers against the named endpoint schemehttp://_grpc.notificationso service discovery resolves the gRPC port, not the default one. Deliberately noWaitFor: the publish path is best-effort and must not couple Engagement startup to Notification availability. - ACA (prod): the main ingress stays HTTP/1.1-capable for WebSockets; the gRPC port is exposed via
additionalPortMappingsas a dedicated internal TCP port mapping (TCP passthrough sidesteps the envoy single-transport limitation: one app cannot serve HTTP/1.1 WebSockets and end-to-end HTTP/2 on the same HTTP ingress). The Bicep injects the same discovery variable pointing athttp://<app>-notification:8081. - Probes and gateway: unchanged. The default endpoint still answers the kubelet's HTTP/1.1
httpGetprobes (no TCP-probe workaround needed, unlike full Profile A hosts), and the gateway never routes the gRPC endpoint (it is service-to-service only).
Rule refinement: a service that needs the HTTP/1.1 Upgrade handshake AND must serve inbound cleartext
gRPC uses this mixed-endpoint profile: Profile B protocols on the default endpoint, a Profile A
Http2-only named endpoint for gRPC, discovery via the named-endpoint scheme, and (in ACA) an
additional internal TCP port. It costs one extra port everywhere (appsettings, launch profile, Bicep)
and is only worth it when both constraints genuinely meet in one host.
Context
Once modules were extracted into separate service hosts (ADR-008) that call each other synchronously over gRPC (ADR-007), each service's Kestrel had to serve both REST traffic (HTTP/1.1 from the gateway and clients) and gRPC traffic (HTTP/2 from peer services). On a cleartext endpoint there is no TLS, so there is no ALPN to negotiate the protocol — Kestrel must be told up front which protocol(s) the cleartext port speaks. Two valid configurations exist, and the two downstream apps deliberately pick different ones because their cross-service topologies differ:
- MMCA.ADC has a bidirectional gRPC pair (Conference ↔ Engagement, plus Notification → Identity). A gRPC client over h2c must reach a server that speaks HTTP/2 on cleartext.
- MMCA.Store was originally assumed to have only one-directional, consumer-only gRPC edges (Sales → Catalog, Sales → Identity). That assumption proved wrong in Azure Container Apps — Catalog and Identity do serve inbound cleartext gRPC — which is why Store later converged on Profile A (see the Update above). This section preserves the original split as historical rationale.
The subtlety: a gRPC client using h2c prior knowledge sends an HTTP/2 preface with no upgrade
handshake. If the server's cleartext endpoint is Http1AndHttp2, Kestrel — lacking ALPN on
cleartext — answers HTTP/1.1 and the client fails with HTTP_1_1_REQUIRED. Forcing Http2-only on
cleartext fixes gRPC but then a default HttpClient (HTTP/1.1) — e.g. the JwtBearer JWKS backchannel
or the YARP forwarder — can no longer hit that endpoint directly. So the Kestrel choice forces
matching choices for gateway forwarding and JWKS discovery routing.
Decision
Pick one of two coherent transport profiles per app, and wire the gateway forwarder and JWKS discovery to match.
Profile A — ADC: Http2-only h2c + gateway-routed JWKS
Use when services must serve gRPC on cleartext (any bidirectional / inbound gRPC edge).
- Kestrel:
ConfigureEndpointDefaults(o => o.Protocols = HttpProtocols.Http2)(also"Kestrel:EndpointDefaults:Protocols": "Http2"in appsettings). The cleartext endpoint is HTTP/2-only (h2c prior knowledge), so peer gRPC clients negotiate without TLS/ALPN. - Gateway:
ForwardHttp2 = true→ YARP forwards REST as HTTP/2 (HttpVersion.Version20,VersionPolicy = RequestVersionExact).RequestVersionOrLowerwould silently downgrade to HTTP/1.1, which the Http2-only backend rejects withHTTP_1_1_REQUIRED, so the policy must be exact. In Azure Container Apps, ingress must betransport: http2. - JWKS discovery:
WithJwksDiscovery(identity, gateway). The default JwtBearer metadata backchannel is HTTP/1.1 and cannot reach the Http2-only Identity endpoint directly, so the authority is set to the gateway HTTPS origin; the gateway terminates TLS, speaks HTTP/1.1 + 2 via ALPN, and routes/.well-known/*on to Identity over HTTP/2 (ADR-004). - Exception: the Notification service keeps
Http1AndHttp2on its default endpoint because SignalR's WebSocket transport needs the HTTP/1.1 Upgrade handshake. Since 2026-07-09 it also serves one inbound gRPC edge from a dedicatedHttp2-only named endpoint (the mixed-endpoint profile in the update above), so "serves no inbound gRPC" no longer holds for the host, only for its default endpoint.
Profile B — Http1AndHttp2 + HTTPS/ALPN + ForwardHttp2=false + direct JWKS (Store's original choice; now retained only as the SignalR/WebSocket exception)
Use when no service needs to serve gRPC on cleartext (consumer-only / one-directional gRPC).
- Kestrel:
ConfigureEndpointDefaults(o => o.Protocols = HttpProtocols.Http1AndHttp2)(also"Protocols": "Http1AndHttp2"). The cleartext endpoint defaults to HTTP/1.1 (no ALPN); the HTTPS endpoint negotiates HTTP/1.1 or HTTP/2 via ALPN. gRPC clients use the HTTPS endpoint (the AppHost selects thehttpslaunch profile) so they get HTTP/2 through ALPN. - Gateway:
ForwardHttp2 = false(default) → YARP forwards REST as HTTP/1.1, which theHttp1AndHttp2backends accept on cleartext. In ACA, envoy ingress is plain HTTP/1.1. - JWKS discovery:
WithJwksDiscovery(identity)with no gateway argument. The default JwtBearer backchannel reaches Identity's HTTPS endpoint and ALPN negotiates HTTP/2, so no gateway hop is needed for discovery (the gateway still routes/.well-known/*so the canonical issuer origin serves the discovery doc for clients).
When to use which
- Any service that hosts an inbound gRPC server reachable over cleartext h2c (especially a
bidirectional pair) → Profile A. Cleartext h2c prior knowledge requires an
Http2-only endpoint; that in turn forcesForwardHttp2=trueand gateway-routed JWKS. - Only consumer-only / one-directional gRPC, with gRPC riding the HTTPS/ALPN endpoint → Profile B.
Keep
Http1AndHttp2,ForwardHttp2=false, and directWithJwksDiscovery(identity). - A service needing the HTTP/1.1 Upgrade handshake (SignalR WebSockets) must keep
Http1AndHttp2on that endpoint (ADC's Notification service). If it must also serve cleartext gRPC, do not flip the host profile: add a dedicatedHttp2-only named endpoint instead (the 2026-07-09 mixed-endpoint update above).
Rationale
- The Kestrel protocol choice is the root constraint; the gateway-forward mode and the JWKS
authority are downstream consequences, not independent knobs. Documenting them as a pair prevents
the half-configured failure modes (
HTTP_1_1_REQUIREDon gRPC, or a JwtBearer backchannel that can't fetch JWKS from an Http2-only endpoint). - Each app picks the minimum that its topology needs. ADC's bidirectional gRPC forced the
Http2-only profile (and the gateway-routed JWKS that comes with it) from the start. Store originally chose Profile B on the assumption its gRPC edges were consumer-only, but Catalog and Identity in fact serve inbound cleartext gRPC (Sales → Catalog, Sales → Identity), so it converged on Profile A (see the Update above). The only remaining Profile B case is a service that serves no inbound cleartext gRPC yet needs the HTTP/1.1 Upgrade handshake — ADC's Notification (SignalR).
Trade-offs
- Two profiles to keep straight. A service that gains an inbound gRPC edge must migrate from
Profile B to Profile A and flip
ForwardHttp2and the JWKS wiring together, or it breaks. - ACA ingress coupling. Profile A requires
transport: http2on the container app ingress; Profile B uses default HTTP/1.1 ingress. The Bicep must match the chosen profile. - Mixed profiles within one app are possible but sharp-edged (ADC's Notification and Store's Sales
each run
Http1AndHttp2Kestrel defaults inside an otherwise-Profile-A app); only do this for a service whose default endpoint serves no cleartext gRPC, and document why. - Mixed endpoints within one host cost a port. The Notification mixed-endpoint profile needs the
extra gRPC port declared consistently in appsettings, the dev launch profile, and the ACA
additionalPortMappings; a missing declaration in any one of them fails only at runtime (discovery resolves a port nothing listens on, or ACA never exposes it).
Related
- ADR-004 (cross-service token validation via JWKS / OIDC discovery), ADR-007 (gRPC cross-service calls), ADR-008 (monolith → services + gateway topology), ADR-039 (live-channel push: the pipeline that gave Notification its inbound gRPC edge and motivated the mixed-endpoint profile).