api-gateway-yarp
API Gateway (YARP)
The problem: a browser should not talk to five services
The Angular SPA needs accounts from one service (.NET/PostgreSQL) and transfers from another (NestJS/MongoDB). Without a gateway the browser would have to know every service's host and port, every service would have to implement CORS, JWT validation, rate limiting and correlation-id handling independently, and the public surface would be "all of them." That is a lot of duplicated cross-cutting code and a lot of attack surface.
The pattern
An API gateway is a single reverse proxy that is the one public entry point. The browser talks only to http://localhost:8080; the gateway routes /api/accounts/* to account-service and /api/transfers/* to transfer-service, and it owns the edge concerns once, in one place:
┌─────────────────────────────────────────┐
browser ──/api/*──► │ Gateway (YARP) │
│ • CORS • JWT validation │
│ • rate limiting • correlation-id │
│ • path rewrite • trace start │
└───────┬───────────────────────┬──────────┘
/api/accounts │ /api/transfers
(strip /api) ▼ (strip /api) ▼
account-service transfer-service
We use YARP (Yet Another Reverse Proxy), Microsoft's library for building a reverse proxy as a .NET app — config-driven routes/clusters, but with the full ASP.NET middleware pipeline available for auth, rate limiting and custom middleware. The alternative would be a packaged gateway (Kong, nginx, Traefik); YARP keeps the edge in the same language and mental model as account-service, which is the right call for a learning project where the gateway also does custom JWT/correlation logic.
Where it lives in our code
- Routes & clusters:
gateway/src/Gateway/appsettings.json→ReverseProxysection. Each route matches a path (/api/accounts/{**catch-all}), appliesPathRemovePrefix: /api(downstream services don't know about the/apiprefix), and pins anAuthorizationPolicy+RateLimiterPolicy. Cluster destination addresses are overridden by compose env for in-network DNS (http://account-service:8080/). - The pipeline:
gateway/src/Gateway/Program.cs—AddReverseProxy().LoadFromConfig(...), JWT bearer, theper-callerfixed-window rate limiter, CORS for the SPA origin, andMapReverseProxy(). - Correlation born at the edge:
gateway/src/Gateway/Middleware/CorrelationIdMiddleware.csmints anX-Correlation-Idif the caller didn't send one and stamps it on both the forwarded request (so downstream logs share it) and the response (so the SPA can show "trace this transfer"). See correlation-and-logging.md.
Trade-offs
| With a gateway (this project) | Without (direct-to-service) | |
|---|---|---|
| Edge concerns | once, at the gateway | duplicated in every service |
| Public surface | one host/port | every service exposed |
| Coupling | SPA knows one base URL | SPA knows N services |
| Failure mode | gateway is a single choke point (must be HA in prod) | no single choke point |
| Latency | one extra network hop | none |
The honest cost is the extra hop and the fact that the gateway is a single point of failure — in production it runs as multiple replicas behind a load balancer. For a demo, one instance is fine.
Zero-trust note: the gateway validating the JWT does not excuse the services from validating it too. See oidc-keycloak-zero-trust.md — the gateway is a convenience and a policy-enforcement point, not a security boundary the services can lean on.
Interview Q&A
Q: Gateway vs. service mesh — what's the difference? A gateway handles north-south traffic (clients → cluster) and is application-aware: routing by path, auth, rate limits. A service mesh (Istio, Linkerd) handles east-west traffic (service → service) via sidecars: mTLS, retries, traffic-shifting, transparent to the app. They're complementary; we have the north-south half.
Q: Why strip the /api prefix?
So services stay ignorant of their public mount point. account-service exposes /accounts; the public path /api/accounts is a gateway concern. If we re-mounted the service under /v2/banking/accounts tomorrow, only the gateway route changes — the service is untouched.
Q: Where should rate limiting live — gateway or service?
At the gateway for coarse per-caller protection (a runaway client hammering the saga endpoints), because it sees all traffic and can reject before it costs a downstream hop. Services may still rate-limit internally for their own protection, but the cheap first line is the edge. Ours partitions a fixed window by sub claim (or IP before auth).
Q: The gateway already checked the token. Why check again downstream? Defense in depth. The gateway is reachable from the SPA, but inside the cluster a misconfigured caller, a compromised pod, or a future internal service could hit account-service directly. Trusting "the gateway must have checked" is exactly the assumption zero-trust rejects.
Q: How does YARP differ from nginx as a reverse proxy? nginx is a battle-tested C proxy configured declaratively; YARP is a library you embed in a .NET app, so the proxy and your custom C# middleware (JWT, correlation, metrics) share one pipeline and one deployment. You trade nginx's raw throughput and operational maturity for keeping edge logic in your own language and test suite.
Try to break it
curl http://localhost:8080/api/accountswith no token → 401 (the gateway'sauthenticatedpolicy rejects before any proxying). Add-H "Authorization: Bearer $TOKEN"→ 200, request proxied with/apistripped.curl -i http://localhost:8080/api/transfers -H "Authorization: Bearer $TOKEN"and watch the response carry anX-Correlation-Idheader even though you didn't send one — minted at the edge. Send your own and watch it be preserved.- Hammer an endpoint past 100 requests / 10s from one token and watch the gateway return 429 before the request ever reaches a service.
docker compose stop account-service, thencurl .../api/accounts→ the gateway returns a 502/503 (no healthy destination), while/api/transfersstill works — the blast radius is one cluster, not the whole API.