Here’s a practical way to build an API Gateway in .NET with load-balancing, + multiple implementation choices

What we’ll typically need

  • Gateway: YARP (recommended) or Ocelot; or a managed gateway (Azure API Management) in front of your services.
  • Load balancing: round-robin / least-requests / power-of-two choices (YARP), or Ocelot’s built-ins. Health checks to avoid sending traffic to bad instances.
  • Service discovery (optional): static destinations in config, or Consul/Kubernetes to auto-discover.
  • Cross-cutting: JWT auth, rate limiting, caching, circuit-breaker (Polly), logging/tracing (OpenTelemetry).
  • Stateless services: so you don’t need sticky sessions (use them only if you must).

Option A (recommended): .NET YARP (Reverse Proxy) as API Gateway

1) Install

dotnet add package Yarp.ReverseProxy
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

2) Program.cs (Minimal API)

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;

var builder = WebApplication.CreateBuilder(args);
// JWT auth (adapt authority/audience for your IdP)
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(opts =>
{
opts.Authority = "https://our-idp.example.com/";
opts.Audience = "api-gateway";
opts.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true
};
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("JwtPolicy", policy => policy.RequireAuthenticatedUser());
});
// YARP from config
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
var app = WebApplication.Create(builder);
// Order matters
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
// Optional: global rate limit (use middleware or third-party package if needed)
// app.UseIpRateLimiting(); // if using AspNetCoreRateLimit
// Expose a health endpoint for gateway itself
app.MapGet("/healthz", () => Results.Ok("ok"));
app.MapReverseProxy();
app.Run();

3) appsettings.json (routes, clusters, load-balancing + health checks)

{
"ReverseProxy": {
"Routes": [
{
"RouteId": "products",
"ClusterId": "products-cluster",
"Match": { "Path": "/products/{**catch-all}" },
"AuthorizationPolicy": "JwtPolicy" // enforce JWT on this route
},
{
"RouteId": "orders",
"ClusterId": "orders-cluster",
"Match": { "Path": "/orders/{**catch-all}" }
}
],
"Clusters": {
"products-cluster": {
"LoadBalancingPolicy": "RoundRobin", // or LeastRequests, PowerOfTwoChoices
"SessionAffinity": { "Enabled": false }, // enable only if you truly need sticky sessions
"HealthCheck": {
"Active": { "Enabled": true, "Interval": "00:00:10", "Timeout":"00:00:03", "Path": "/health" },
"Passive": { "Enabled": true, "ReactivationPeriod": "00:00:30" }
},
"Destinations": {
"p1": { "Address": "http://products-1:5001/" },
"p2": { "Address": "http://products-2:5002/" }
}
},
"orders-cluster": {
"LoadBalancingPolicy": "LeastRequests",
"HealthCheck": {
"Active": { "Enabled": true, "Path": "/health" },
"Passive": { "Enabled": true }
},
"Destinations": {
"o1": { "Address": "http://orders-1:6001/" },
"o2": { "Address": "http://orders-2:6002/" },
"o3": { "Address": "http://orders-3:6003/" }
}
}
}
}
}

Notes

  • Each microservice should expose GET /health (returns 200 when healthy).
  • YARP will actively probe /health and passively eject failing instances.
  • Put shared concerns (auth, headers, transforms, request/response limits) at the route/cluster level.

4) Local multi-instance demo with Docker Compose

# docker-compose.yml
version: "3.9"
services:
gateway:
build: ./Gateway
ports: ["8080:8080"]
depends_on: [products-1, products-2]
products-1:
build: ./ProductsService
environment: [ ASPNETCORE_URLS=http://+:5001 ]
products-2:
build: ./ProductsService
environment: [ ASPNETCORE_URLS=http://+:5002 ]

Products microservice (minimal) should have:

var app = WebApplication.Create(args);
app.MapGet("/health", () => Results.Ok());
app.MapGet("/products", () => new[] { "A", "B" });
app.Run();

Test:

curl http://localhost:8080/products
# hit repeatedly and watch responses alternate across instances (round-robin)

Option B: Ocelot as API Gateway (with Consul or static)

1) Install

dotnet add package Ocelot
dotnet add package Consul

2) Program.cs

using Ocelot.DependencyInjection;
using Ocelot.Middleware;

var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddJsonFile("ocelot.json", optional: false, reloadOnChange: true);
builder.Services.AddOcelot();
var app = builder.Build();
await app.UseOcelot();
app.Run();

3) ocelot.json (load balancing + service discovery)

{
"Routes": [
{
"DownstreamPathTemplate": "/{everything}",
"DownstreamScheme": "http",
"ServiceName": "products", // logical name from Consul
"UpstreamPathTemplate": "/products/{everything}",
"UpstreamHttpMethod": [ "Get", "Post" ],
"LoadBalancerOptions": { "Type": "RoundRobin" },
"QoSOptions": { "ExceptionsAllowedBeforeBreaking": 3, "DurationOfBreak": 1000, "TimeoutValue": 3000 }
}
],
"GlobalConfiguration": {
"ServiceDiscoveryProvider": {
"Type": "Consul",
"Host": "consul",
"Port": 8500
}
}
}

Register our service in Consul from .NET (example)

using Consul;
var consul = new ConsulClient(c => c.Address = new Uri("http://consul:8500"));
await consul.Agent.ServiceRegister(new AgentServiceRegistration
{
ID = "products-5001",
Name = "products",
Address = "products-1",
Port = 5001,
Check = new AgentServiceCheck { HTTP = "http://products-1:5001/health", Interval = TimeSpan.FromSeconds(10) }
});

(We can also skip Consul and list DownstreamHostAndPorts statically.)

Option C: Azure-native (fully managed)

  • Azure API Management (APIM) as Gateway (policies, JWT, quotas, caching).
  • Azure Front Door (global anycast + WAF) in front for global LB.
  • App Service or AKS for services; health probes do instance LB.
  • You configure load-balancing at App Service/AKS or via Front Door’s backend pool with health probes. APIM adds routing, transforms, and security.

Option D: Kubernetes (YARP inside cluster)

  • Run YARP as a Deployment; point clusters to K8s Services (ClusterIP).
  • K8s Service already round-robins across pods; YARP adds auth, routing, rate-limit, and edge features.
  • External access via Ingress (Nginx/Traefik) or a cloud LB → Gateway Service.

Example K8s Service for products:

apiVersion: v1
kind: Service
metadata: { name: products }
spec:
selector: { app: products }
ports:
- port: 80
targetPort: 5001

YARP cluster destination becomes http://products/ (DNS within cluster).

Common add-ons (with YARP)

Rate limiting

  • Quick start: AspNetCoreRateLimit package for IP/Client-ID limits at gateway.

Caching

  • Response caching middleware for cacheable GETs, or put a Redis layer (OutputCache / custom policy) at gateway.

Resilience

  • Wrap outbound calls with Polly via a delegating handler:
builder.Services.AddHttpClient("yarp-forward")
.AddTransientHttpErrorPolicy(p => p.CircuitBreakerAsync(5, TimeSpan.FromSeconds(30)))
.AddTransientHttpErrorPolicy(p => p.WaitAndRetryAsync(3, i => TimeSpan.FromMilliseconds(200 * i)));

(In YARP 2.x, we can attach handlers per cluster via HttpClient transforms or use proxies’ built-in policies.)

Observability

  • Add OpenTelemetry (traces + metrics) at gateway and services for end-to-end visibility.

When to choose what

  • YARP: fastest path in pure .NET, rich LB/health checks, easy config, first-class in ASP.NET Core.
  • Ocelot: simple, mature; good if you already use it, or want Consul-style discovery out of the box.
  • Managed (APIM): enterprise governance, policies, portal, analytics; pair with Front Door for global LB.
  • Kubernetes: if you’re already on K8s; let Services do LB, keep YARP for gateway concerns.