Architecture and Core Concepts
Pragmatic.Gateway is the reverse-proxy edge for Pragmatic.Design applications. Built on YARP, augmented with Agent-driven dynamic routing, tenant-aware proxying, maintenance mode, and per-cluster resilience.
Status: implemented preview subsystem. Functional inside the repo; operational surface still evolving.
The Problem
Section titled “The Problem”A multi-service Pragmatic deployment needs an edge:
- route
/api/booking/*to the booking hosts,/api/billing/*to billing - present a single public endpoint while services scale and move internally
- fail fast (circuit-break) when a backend is unhealthy, without taking the whole edge down
- support tenant-aware routing — different tenants to different backend pools
- handle maintenance mode — bleed traffic from hosts before deploy
- offer standard edge features (compression, CORS, JWT auth, rate limiting)
You can deploy nginx / Traefik / Envoy, and for many teams that’s fine. But you pay:
- a second configuration language (nginx.conf, Traefik labels) separate from your .NET code
- no compile-time coupling to your topology — config drift is a real thing
- limited access to your Pragmatic runtime types for custom middleware
Pragmatic.Gateway is the .NET-native alternative: same process model as your app hosts, driven by the same Pragmatic.Agent operational state, extensible with the full ASP.NET middleware stack.
Why YARP underneath
Section titled “Why YARP underneath”YARP is a Microsoft-built reverse-proxy toolkit for .NET. It handles:
- request forwarding with keep-alive pooling and HTTP/2
- response streaming without buffering
- load balancing (round-robin, random, least-request, power-of-two)
- SSE and WebSocket upgrades
- header transforms
Pragmatic.Gateway doesn’t re-invent these. It uses YARP for the transport and adds the layers YARP doesn’t cover: dynamic config from the Agent, maintenance middleware, tenant extraction, resilience policies built on Pragmatic.Resilience.
Pipeline
Section titled “Pipeline”incoming request │ ▼┌─────────────────────────────────────┐│ /health /ready endpoints │ ← short-circuit for Kubernetes probes├─────────────────────────────────────┤│ Response compression │├─────────────────────────────────────┤│ CORS │├─────────────────────────────────────┤│ Maintenance middleware │ ← drains if agent.gateway/maintenance=on├─────────────────────────────────────┤│ Rate limiting │ ← per client / per route├─────────────────────────────────────┤│ Authentication + Authorization │ ← JWT bearer, cookie, anonymous allow├─────────────────────────────────────┤│ Tenant routing │ ← extract tenant from header / subdomain├─────────────────────────────────────┤│ YARP reverse proxy │ ← resilience wrappers per cluster├─────────────────────────────────────┤│ forward to backend │└─────────────────────────────────────┘Every middleware is opt-in via GatewayOptions. A minimal gateway is “just the proxy”; a production one typically enables all of them.
Route sources
Section titled “Route sources”The route table can come from two places:
1. Static configuration
Section titled “1. Static configuration”{ "Gateway": { "HttpUrl": "http://*:8080", "Routes": [ { "RouteId": "booking", "Path": "/booking/{**catch-all}", "Backend": "http://localhost:5010" } ] }}Baked-in, loaded at startup, changes require a restart. Fine for simple deployments.
2. Dynamic from Pragmatic.Agent
Section titled “2. Dynamic from Pragmatic.Agent”When AgentSocketPath is set, the gateway subscribes to gateway/routes/* and gateway/clusters/* keys in the Agent’s KV store. When an operator updates a route via pragmatic-agent config set gateway/routes/booking ..., the gateway picks up the change within a gossip round (~seconds) and reconfigures YARP in-place — zero downtime.
Precedence: Agent routes override static routes with the same RouteId. Static routes act as a bootstrap and fallback.
Resilience — per-cluster, not global
Section titled “Resilience — per-cluster, not global”Each backend cluster gets its own resilience policy:
{ "Clusters": { "booking": { "Destinations": { "b1": "http://booking-1:80", "b2": "http://booking-2:80" }, "Resilience": { "TimeoutMs": 5000, "CircuitBreaker": { "FailureThreshold": 10, "BreakDurationMs": 30000, "MinimumThroughput": 20 } } }, "billing": { ... } }}Why per-cluster: if billing is unhealthy, booking should keep serving. A global circuit-breaker would trip for both.
The policies are the same Pragmatic.Resilience primitives you use inside your app — configured via the same shapes, with the same failure counting semantics.
Tenant routing
Section titled “Tenant routing”When TenantResolver is enabled, the gateway extracts the tenant before forwarding so:
- the downstream service sees a
X-Tenant-Idheader (or configured equivalent) - routing can use the tenant to pick a different backend cluster (
tenant=acme→cluster=acme-pool) - auditing captures the tenant on the edge, not 5 hops in
Resolution strategies available:
- Header (
X-Tenant-Id) - Subdomain (
acme.example.com→acme) - JWT claim (
tenant_idfrom the bearer token) - Route (prefix like
/t/acme/...)
Multiple strategies can be composed with a priority order.
Maintenance mode
Section titled “Maintenance mode”Agent key gateway/maintenance drives a gateway-wide or per-cluster drain:
pragmatic-agent config set gateway/maintenance "enabled:true,reason:deploy"The maintenance middleware then:
- accepts new requests on health paths (
/health,/ready) - returns 503 with
Retry-Afterfor application routes - allows in-flight requests to complete (configurable grace period)
To drain one cluster (typical before a rolling deploy):
pragmatic-agent config set gateway/maintenance/booking "enabled:true"Only the booking cluster returns 503; other clusters keep serving normally.
Authentication and authorization
Section titled “Authentication and authorization”The gateway itself handles JWT Bearer authentication — tokens are validated once at the edge rather than at every downstream service.
{ "Authentication": { "JwtBearer": { "Authority": "https://auth.example.com", "Audience": "api.example.com", "RequireHttpsMetadata": true } }}Validated principals are forwarded as trusted headers (X-User-Id, X-User-Roles) to downstream services, configured via YARP transforms.
For fully public routes, set AllowAnonymous on the route.
What the gateway does not do
Section titled “What the gateway does not do”- Global load balancing across regions — use a cloud provider’s global LB for that
- L4 TCP proxying — HTTP/1.1, HTTP/2, HTTP/3 only
- TLS termination with cert-manager integration — configure TLS via Kestrel as you would for any ASP.NET app
- WAF-style request inspection — deploy a WAF upstream
- Service mesh sidecar — this is an edge gateway, not a sidecar
For those, keep your existing tooling (Cloudflare, Istio, Envoy) and point it at the Pragmatic.Gateway as an origin.
Relationship to other modules
Section titled “Relationship to other modules”- Pragmatic.Agent — optional but important: dynamic route source, maintenance state, tenant map
- Pragmatic.Resilience — same policies used inside services are applied here
- Pragmatic.MultiTenancy — tenant resolvers are shared between gateway and app hosts
Related
Section titled “Related”- getting-started.md — minimal config, adding the first route
- common-mistakes.md
- troubleshooting.md — route / Agent / forwarding issues