PortfolioTech Notebook
Menu
patterns

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.jsonReverseProxy section. Each route matches a path (/api/accounts/{**catch-all}), applies PathRemovePrefix: /api (downstream services don't know about the /api prefix), and pins an AuthorizationPolicy + RateLimiterPolicy. Cluster destination addresses are overridden by compose env for in-network DNS (http://account-service:8080/).
  • The pipeline: gateway/src/Gateway/Program.csAddReverseProxy().LoadFromConfig(...), JWT bearer, the per-caller fixed-window rate limiter, CORS for the SPA origin, and MapReverseProxy().
  • Correlation born at the edge: gateway/src/Gateway/Middleware/CorrelationIdMiddleware.cs mints an X-Correlation-Id if 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

  1. curl http://localhost:8080/api/accounts with no token → 401 (the gateway's authenticated policy rejects before any proxying). Add -H "Authorization: Bearer $TOKEN"200, request proxied with /api stripped.
  2. curl -i http://localhost:8080/api/transfers -H "Authorization: Bearer $TOKEN" and watch the response carry an X-Correlation-Id header even though you didn't send one — minted at the edge. Send your own and watch it be preserved.
  3. Hammer an endpoint past 100 requests / 10s from one token and watch the gateway return 429 before the request ever reaches a service.
  4. docker compose stop account-service, then curl .../api/accounts → the gateway returns a 502/503 (no healthy destination), while /api/transfers still works — the blast radius is one cluster, not the whole API.