Documentation
adrs/031-api-gateway-pattern.md
ADR-031: API Gateway Pattern
Status
Accepted
Context
Currently, Dynaplex clients must know about and connect to multiple microservices directly. This creates challenges:
- Clients need to know multiple service URLs
- Each service handles its own authentication
- No centralized rate limiting or throttling
- Difficult to implement cross-cutting concerns
- Complex client-side service discovery
- No single entry point for monitoring
We need a simple, lightweight API gateway that provides a unified entry point without adding unnecessary complexity.
Decision
We propose implementing a lightweight API Gateway pattern using YARP (Yet Another Reverse Proxy) to provide a single entry point for all Dynaplex services.
Key principles:
- Keep it simple - just routing, auth, and rate limiting
- No business logic in the gateway
- Minimal transformation or aggregation
- Focus on operational concerns
- Use existing proven technology (YARP)
Consequences
Positive
- Single Entry Point: Clients only need one URL
- Centralized Auth: Authentication happens once
- Rate Limiting: Protect backend services
- Monitoring: Single point for metrics and logging
- Security: Hide internal service topology
- Flexibility: Easy to add/remove services
Negative
- Single Point of Failure: Gateway availability is critical
- Latency: Additional network hop
- Complexity: Another component to manage
- Bottleneck: Can become performance limiting
Neutral
- Technology Choice: YARP vs Ocelot vs Azure API Management
- Deployment: Needs high availability setup
- Configuration: Route configuration management
Proposed Implementation
YARP-based Gateway
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// Add YARP
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
.AddTransforms(builderContext =>
{
// Add authentication header
builderContext.AddRequestHeader("X-User-Id", context =>
{
return ValueTask.FromResult(context.HttpContext.User.FindFirst("sub")?.Value);
});
// Add correlation ID
builderContext.AddRequestHeader("X-Correlation-Id", context =>
{
return ValueTask.FromResult(Activity.Current?.Id ?? Guid.NewGuid().ToString());
});
});
// Add authentication
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = builder.Configuration["Auth:Authority"];
options.Audience = builder.Configuration["Auth:Audience"];
});
// Add rate limiting
builder.Services.AddRateLimiter(options =>
{
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
{
var userId = context.User.FindFirst("sub")?.Value ?? "anonymous";
return RateLimitPartition.GetTokenBucketLimiter(userId, _ =>
new TokenBucketRateLimiterOptions
{
TokenLimit = 100,
ReplenishmentPeriod = TimeSpan.FromMinutes(1),
TokensPerPeriod = 100,
AutoReplenishment = true
});
});
});
// Add health checks
builder.Services.AddHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy());
var app = builder.Build();
// Configure pipeline
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
// Map reverse proxy
app.MapReverseProxy(proxyPipeline =>
{
proxyPipeline.UseLoadBalancing();
proxyPipeline.UsePassiveHealthChecks();
});
app.MapHealthChecks("/health");
app.Run();
Route Configuration
{
"ReverseProxy": {
"Routes": {
"catalog-route": {
"ClusterId": "catalog-cluster",
"Match": {
"Path": "/api/catalog/{**catch-all}"
},
"Transforms": [
{ "PathPattern": "/api/catalog/{**catch-all}" }
],
"RateLimiterPolicy": "standard",
"AuthorizationPolicy": "authenticated"
},
"identity-route": {
"ClusterId": "identity-cluster",
"Match": {
"Path": "/api/identity/{**catch-all}"
},
"Transforms": [
{ "PathPattern": "/api/identity/{**catch-all}" }
],
"RateLimiterPolicy": "standard"
},
"workflow-route": {
"ClusterId": "workflow-cluster",
"Match": {
"Path": "/api/workflow/{**catch-all}"
},
"Transforms": [
{ "PathPattern": "/api/workflow/{**catch-all}" }
],
"RateLimiterPolicy": "standard",
"AuthorizationPolicy": "authenticated"
}
},
"Clusters": {
"catalog-cluster": {
"LoadBalancingPolicy": "RoundRobin",
"Destinations": {
"catalog1": {
"Address": "http://catalog-service:80"
},
"catalog2": {
"Address": "http://catalog-service-2:80"
}
},
"HealthCheck": {
"Active": {
"Enabled": true,
"Interval": "00:00:30",
"Timeout": "00:00:10",
"Policy": "ConsecutiveFailures",
"Path": "/health"
},
"Passive": {
"Enabled": true,
"Policy": "TransportFailureRate",
"ReactivationPeriod": "00:00:10"
}
}
}
}
}
}
Rate Limiting Policies
public static class RateLimitingPolicies
{
public static IServiceCollection AddApiGatewayRateLimiting(
this IServiceCollection services)
{
services.AddRateLimiter(options =>
{
// Standard policy for authenticated users
options.AddTokenBucketLimiter("standard", options =>
{
options.TokenLimit = 1000;
options.ReplenishmentPeriod = TimeSpan.FromHours(1);
options.TokensPerPeriod = 1000;
options.AutoReplenishment = true;
});
// Strict policy for sensitive operations
options.AddFixedWindowLimiter("strict", options =>
{
options.Window = TimeSpan.FromMinutes(1);
options.PermitLimit = 10;
options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
options.QueueLimit = 5;
});
// Relaxed policy for read operations
options.AddSlidingWindowLimiter("relaxed", options =>
{
options.Window = TimeSpan.FromMinutes(1);
options.PermitLimit = 1000;
options.SegmentsPerWindow = 4;
});
// Custom policy based on user tier
options.AddPolicy("tiered", context =>
{
var tier = context.User.FindFirst("tier")?.Value ?? "free";
return tier switch
{
"premium" => RateLimitPartition.GetNoLimiter("premium"),
"standard" => RateLimitPartition.GetTokenBucketLimiter("standard", _ =>
new TokenBucketRateLimiterOptions
{
TokenLimit = 500,
ReplenishmentPeriod = TimeSpan.FromMinutes(1),
TokensPerPeriod = 500
}),
_ => RateLimitPartition.GetFixedWindowLimiter("free", _ =>
new FixedWindowRateLimiterOptions
{
Window = TimeSpan.FromMinutes(1),
PermitLimit = 100
})
};
});
});
return services;
}
}
Request/Response Transformation
public class CustomTransformProvider : ITransformProvider
{
public void Apply(TransformBuilderContext context)
{
// Add request ID
context.AddRequestTransform(async transformContext =>
{
var requestId = Guid.NewGuid().ToString();
transformContext.ProxyRequest.Headers.Add("X-Request-Id", requestId);
transformContext.HttpContext.Items["RequestId"] = requestId;
});
// Add response headers
context.AddResponseTransform(async transformContext =>
{
transformContext.HttpContext.Response.Headers.Add(
"X-Gateway-Version", "1.0.0");
transformContext.HttpContext.Response.Headers.Add(
"X-Response-Time",
transformContext.HttpContext.Items["ResponseTime"]?.ToString() ?? "0");
});
// Strip internal headers from response
context.AddResponseTransform(async transformContext =>
{
transformContext.HttpContext.Response.Headers.Remove("X-Internal-Id");
transformContext.HttpContext.Response.Headers.Remove("Server");
});
}
}
Circuit Breaker
public class CircuitBreakerMiddleware
{
private readonly RequestDelegate _next;
private readonly ICircuitBreaker _circuitBreaker;
public async Task InvokeAsync(HttpContext context)
{
var cluster = context.GetReverseProxyFeature()?.Cluster;
if (cluster != null)
{
var breaker = _circuitBreaker.GetBreaker(cluster.ClusterId);
if (breaker.IsOpen)
{
context.Response.StatusCode = 503;
await context.Response.WriteAsync("Service temporarily unavailable");
return;
}
try
{
await _next(context);
breaker.RecordSuccess();
}
catch
{
breaker.RecordFailure();
throw;
}
}
else
{
await _next(context);
}
}
}
Logging and Monitoring
public class GatewayLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<GatewayLoggingMiddleware> _logger;
public async Task InvokeAsync(HttpContext context)
{
var stopwatch = Stopwatch.StartNew();
var requestId = context.Items["RequestId"]?.ToString() ?? Guid.NewGuid().ToString();
_logger.LogInformation("Gateway request started {RequestId} {Method} {Path}",
requestId, context.Request.Method, context.Request.Path);
try
{
await _next(context);
}
finally
{
stopwatch.Stop();
_logger.LogInformation("Gateway request completed {RequestId} {StatusCode} {Duration}ms",
requestId, context.Response.StatusCode, stopwatch.ElapsedMilliseconds);
// Record metrics
GatewayMetrics.RecordRequest(
context.Request.Path,
context.Response.StatusCode,
stopwatch.ElapsedMilliseconds);
}
}
}
Simple Response Caching
// Add response caching for GET requests
app.MapReverseProxy(proxyPipeline =>
{
proxyPipeline.Use((context, next) =>
{
if (context.Request.Method == HttpMethods.Get)
{
context.Response.Headers.CacheControl = "public, max-age=60";
}
return next();
});
proxyPipeline.UseLoadBalancing();
proxyPipeline.UsePassiveHealthChecks();
});
Security Headers
public class SecurityHeadersMiddleware
{
private readonly RequestDelegate _next;
public async Task InvokeAsync(HttpContext context)
{
// Add security headers
context.Response.Headers.Add("X-Content-Type-Options", "nosniff");
context.Response.Headers.Add("X-Frame-Options", "DENY");
context.Response.Headers.Add("X-XSS-Protection", "1; mode=block");
context.Response.Headers.Add("Referrer-Policy", "strict-origin-when-cross-origin");
context.Response.Headers.Add("Content-Security-Policy",
"default-src 'self'; script-src 'self' 'unsafe-inline';");
// Remove server header
context.Response.Headers.Remove("Server");
await _next(context);
}
}
High Availability Configuration
# Kubernetes deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-gateway
spec:
replicas: 3
selector:
matchLabels:
app: api-gateway
template:
spec:
containers:
- name: gateway
image: dynaplex/api-gateway:latest
ports:
- containerPort: 80
livenessProbe:
httpGet:
path: /health
port: 80
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /health/ready
port: 80
initialDelaySeconds: 5
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: api-gateway
spec:
type: LoadBalancer
ports:
- port: 443
targetPort: 80
selector:
app: api-gateway
What We're NOT Doing
- No Request Aggregation: Gateway doesn't combine multiple service calls
- No Business Logic: Gateway only handles routing and operational concerns
- No Data Transformation: Minimal changes to requests/responses
- No Protocol Translation: HTTP in, HTTP out
- No Service Orchestration: No complex workflows in gateway
Migration Plan
- Phase 1: Deploy gateway with single service
- Phase 2: Add authentication and rate limiting
- Phase 3: Migrate all services behind gateway
- Phase 4: Add monitoring and circuit breakers
- Phase 5: Implement caching if needed
Related ADRs
- ADR-010: JWT Bearer Authentication (gateway authentication)
- ADR-028: Role-Based Access Control (authorization at gateway)
- ADR-030: Distributed Caching (gateway-level caching)