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

  1. Phase 1: Deploy gateway with single service
  2. Phase 2: Add authentication and rate limiting
  3. Phase 3: Migrate all services behind gateway
  4. Phase 4: Add monitoring and circuit breakers
  5. Phase 5: Implement caching if needed
  • ADR-010: JWT Bearer Authentication (gateway authentication)
  • ADR-028: Role-Based Access Control (authorization at gateway)
  • ADR-030: Distributed Caching (gateway-level caching)