Documentation
fsds/dplx-yarp-api-gateway-implementation-plan.md
YARP API Gateway Implementation Plan
Status: Pending Implementation
Created: 2025-10-18
Blocked By: Base component restructuring work in progress
Priority: High (Developer Experience)
Problem Statement
Currently, developers face friction when working with Aspire-orchestrated microservices:
- Dynamic Port Assignment: Aspire assigns different ports on each launch
- Manual Lookup Required: Developers must check the Aspire dashboard to find current ports
- HTTP Client Updates:
http-client.env.jsonfiles need constant updating with new ports - Poor Developer Experience: Breaks flow, wastes time, discourages API testing
Goal: Enable developers to use clean, memorable URLs like http://localhost:8080/identity/auth/login without ever checking ports.
Solution Architecture
High-Level Design
┌─────────────────────────────────────────────────────────────┐
│ Developer / Browser │
│ http://localhost:8080/identity/auth/login │
└──────────────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ YARP API Gateway │
│ (Aspire Managed Service) │
│ - Binds to fixed port: 8080 │
│ - Uses Aspire Service Discovery │
│ - Routes: /service-name/* → actual service │
└──────────────────────────┬──────────────────────────────────┘
│
├──────────────┬─────────────┬──────────────┐
▼ ▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Identity │ │CoreData │ │ Spatial │ │ Events │
│:dynamic │ │:dynamic │ │:dynamic │ │:dynamic │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
URL Scheme
Gateway URLs (used by developers/browsers):
http://localhost:8080/identity/*http://localhost:8080/core-data/*http://localhost:8080/spatial/*http://localhost:8080/system-environment/*- etc.
Service-to-Service (unchanged - uses Aspire Service Discovery):
// Services still talk directly via service discovery
await httpClient.GetAsync("https+http://identity/auth/me");
Why YARP?
- Built by Microsoft - First-class .NET support, maintained actively
- Aspire Integration - Works seamlessly with Aspire Service Discovery
- Production Ready - Same gateway pattern can deploy to cloud
- Auto-Discovery - No manual route configuration when adding services
- Flexible - Easy to add CORS, auth, rate limiting, etc.
Implementation Steps
Step 1: Create Gateway Project
Location: /engines/gateway/src/Acsis.Dynaplex.Engines.Gateway/
Structure:
engines/gateway/
├── src/
│ └── Acsis.Dynaplex.Engines.Gateway/
│ ├── Acsis.Dynaplex.Engines.Gateway.csproj
│ ├── Program.cs
│ ├── appsettings.json
│ └── Services/
│ └── RouteConfigProvider.cs
Dependencies:
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="9.0.0" />
Step 2: Implement Gateway Service
Program.cs:
using Acsis.Dynaplex.Engines.Gateway.Services;
var builder = WebApplication.CreateBuilder(args);
// Add Aspire Service Defaults (OpenTelemetry, health checks, etc.)
builder.AddServiceDefaults();
// Add Service Discovery
builder.Services.AddServiceDiscovery();
// Add YARP with custom route provider
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
.AddServiceDiscoveryDestinationResolver();
// Add route configuration provider
builder.Services.AddSingleton<RouteConfigProvider>();
var app = builder.Build();
app.MapReverseProxy();
app.Run();
appsettings.json:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Yarp": "Information"
}
},
"ReverseProxy": {
"Routes": {
"identity-route": {
"ClusterId": "identity-cluster",
"Match": {
"Path": "/identity/{**catch-all}"
},
"Transforms": [
{ "PathRemovePrefix": "/identity" }
]
},
"core-data-route": {
"ClusterId": "core-data-cluster",
"Match": {
"Path": "/core-data/{**catch-all}"
},
"Transforms": [
{ "PathRemovePrefix": "/core-data" }
]
},
"spatial-route": {
"ClusterId": "spatial-cluster",
"Match": {
"Path": "/spatial/{**catch-all}"
},
"Transforms": [
{ "PathRemovePrefix": "/spatial" }
]
},
"system-environment-route": {
"ClusterId": "system-environment-cluster",
"Match": {
"Path": "/system-environment/{**catch-all}"
},
"Transforms": [
{ "PathRemovePrefix": "/system-environment" }
]
},
"events-route": {
"ClusterId": "events-cluster",
"Match": {
"Path": "/events/{**catch-all}"
},
"Transforms": [
{ "PathRemovePrefix": "/events" }
]
},
"catalog-route": {
"ClusterId": "catalog-cluster",
"Match": {
"Path": "/catalog/{**catch-all}"
},
"Transforms": [
{ "PathRemovePrefix": "/catalog" }
]
},
"edge-route": {
"ClusterId": "edge-cluster",
"Match": {
"Path": "/edge/{**catch-all}"
},
"Transforms": [
{ "PathRemovePrefix": "/edge" }
]
},
"bbu-route": {
"ClusterId": "bbu-cluster",
"Match": {
"Path": "/bbu/{**catch-all}"
},
"Transforms": [
{ "PathRemovePrefix": "/bbu" }
]
},
"doc-web-route": {
"ClusterId": "doc-web-cluster",
"Match": {
"Path": "/docs/{**catch-all}"
},
"Transforms": [
{ "PathRemovePrefix": "/docs" }
]
},
"importer-route": {
"ClusterId": "importer-cluster",
"Match": {
"Path": "/importer/{**catch-all}"
},
"Transforms": [
{ "PathRemovePrefix": "/importer" }
]
}
},
"Clusters": {
"identity-cluster": {
"Destinations": {
"identity": {
"Address": "https+http://identity"
}
}
},
"core-data-cluster": {
"Destinations": {
"core-data": {
"Address": "https+http://core-data"
}
}
},
"spatial-cluster": {
"Destinations": {
"spatial": {
"Address": "https+http://spatial"
}
}
},
"system-environment-cluster": {
"Destinations": {
"system-environment": {
"Address": "https+http://system-environment"
}
}
},
"events-cluster": {
"Destinations": {
"events": {
"Address": "https+http://events"
}
}
},
"catalog-cluster": {
"Destinations": {
"catalog": {
"Address": "https+http://catalog"
}
}
},
"edge-cluster": {
"Destinations": {
"edge": {
"Address": "https+http://edge"
}
}
},
"bbu-cluster": {
"Destinations": {
"bbu": {
"Address": "https+http://bbu"
}
}
},
"doc-web-cluster": {
"Destinations": {
"doc-web": {
"Address": "https+http://doc-web"
}
}
},
"importer-cluster": {
"Destinations": {
"importer": {
"Address": "https+http://importer"
}
}
}
}
}
}
Key Concepts:
- Routes: Map URL paths to clusters (e.g.,
/identity/*→ identity-cluster) - Transforms: Remove the service prefix before forwarding (e.g.,
/identity/auth/login→/auth/login) - Clusters: Groups of destinations (usually just one per service)
- Destinations: Actual service addresses using Aspire service discovery format (
https+http://service-name)
Step 3: Add Gateway to EdgeLiteBuilder
Location: /projects/edge-lite/src/Acsis.Bases.EdgeLite/EdgeLiteBuilder.cs
Add method:
public EdgeLiteBuilder WithGateway<TGateway>()
where TGateway : IProjectMetadata, new() {
// Gateway should start first (or at least early) and bind to fixed port
Gateway = _builder.AddProject<TGateway>("gateway")
.WithHttpEndpoint(port: 8080, name: "http")
.WithAppInsightsIfAvailable(AppInsights)
.PublishAsAzureContainerApp((infra, app) => {
app.Name = $"ca-{GroupIdentifier}-gateway";
});
return this;
}
Add property:
public IResourceBuilder<ProjectResource>? Gateway { get; private set; }
Update AppHost to use it:
builder.AddEdgeLite()
.WithAppConfiguration()
.WithAppInsights()
.WithContainerAppEnvironment()
.WithDefaultDatabase()
.WithGateway<Acsis_Components_Gateway>() // Add gateway early
.WithCoreData<Acsis_Components_CoreData, Acsis_Components_CoreData_DbMigrator>()
.WithSystemEnvironment<Acsis_Components_SystemEnvironment, Acsis_Components_SystemEnvironment_DbMigrator>()
.WithIdentity<Acsis_Components_Identity, Acsis_Components_Identity_DbMigrator>()
// ... rest of services
.Build();
Step 4: Update HTTP Client Environment Files
Update all http-client.env.json files to use gateway URLs:
Before:
{
"dev": {
"baseUrl": "http://localhost:53573",
"comment": "Local development - Aspire managed port (update port from dashboard)"
}
}
After:
{
"dev": {
"baseUrl": "http://localhost:8080/identity",
"comment": "Routes through YARP API Gateway - no port updates needed!"
}
}
Files to update:
/engines/identity/resources/jetbrains-http-client/http-client.env.json/engines/system-environment/resources/jetbrains-http-client/http-client.env.json- Any other components with HTTP client files
Step 5: Add Health Checks
Update Gateway Program.cs:
builder.Services.AddHealthChecks()
.AddCheck("gateway", () => HealthCheckResult.Healthy("Gateway is running"));
app.MapDefaultEndpoints(); // Adds /health and /alive endpoints
Step 6: Add Logging and Diagnostics
Update appsettings.json:
{
"Logging": {
"LogLevel": {
"Yarp.ReverseProxy": "Information"
}
}
}
Add request logging middleware (optional but recommended):
app.Use(async (context, next) =>
{
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
logger.LogInformation("Gateway request: {Method} {Path} → {Service}",
context.Request.Method,
context.Request.Path,
context.Request.Path.Value?.Split('/').ElementAtOrDefault(1) ?? "unknown");
await next();
});
Testing Plan
Phase 1: Gateway Startup
- Start Aspire AppHost
- Verify Gateway appears in Aspire dashboard
- Verify Gateway binds to port 8080
- Check Gateway health endpoint:
http://localhost:8080/health
Phase 2: Route Verification
For each service, test the gateway route:
Identity:
# Via Gateway
curl http://localhost:8080/identity/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin"}'
# Should return JWT token (200 OK)
SystemEnvironment:
# Via Gateway
curl http://localhost:8080/system-environment/api/health
# Should return health status (200 OK)
Repeat for all services.
Phase 3: HTTP Client Testing
- Open JetBrains HTTP Client
- Execute "Login with admin credentials" from
AuthenticationApi.http - Verify token is captured
- Execute "Get current authenticated user information"
- Verify user data returned
Phase 4: Service-to-Service Communication
Verify services still communicate directly (not through gateway):
Check Aspire logs to confirm direct connections, not routed through gateway.
Phase 5: Error Handling
Test failure scenarios:
- Service not running → Gateway should return 503 Service Unavailable
- Invalid route → Gateway should return 404 Not Found
- Service returns error → Gateway should forward error response
Technical Details
Service Discovery Integration
YARP uses Aspire's service discovery via the https+http:// scheme:
{
"Destinations": {
"identity": {
"Address": "https+http://identity"
}
}
}
This tells YARP to:
- Query Aspire service discovery for "identity" service
- Resolve to actual endpoint (e.g.,
http://localhost:53573) - Forward requests to that endpoint
- Auto-update if service moves to different port
Path Transformation
The PathRemovePrefix transform strips the service name from URLs:
Request: http://localhost:8080/identity/auth/login
After Transform: /auth/login
Forwarded To: http://localhost:53573/auth/login
This keeps service APIs unchanged - they don't need to know about the gateway prefix.
Error Handling Strategy
YARP provides several error handling options:
{
"HttpRequest": {
"Timeout": "00:01:00",
"Version": "2",
"VersionPolicy": "RequestVersionOrLower"
},
"HealthCheck": {
"Active": {
"Enabled": true,
"Interval": "00:00:10",
"Timeout": "00:00:05",
"Policy": "ConsecutiveFailures",
"Path": "/health"
}
}
}
For this implementation, we'll use defaults initially and add health checks in a future iteration.
Port Assignments
Gateway
- Port: 8080 (fixed)
- URL:
http://localhost:8080
Services
- Ports: Dynamic (assigned by Aspire)
- Discovery: Accessed via gateway or service discovery
- No manual port management needed
Migration Guide
For Developers
Old workflow:
- Start Aspire
- Open Aspire dashboard
- Find Identity service port (e.g., 53573)
- Update
http-client.env.jsonwith new port - Run HTTP requests
New workflow:
- Start Aspire
- Run HTTP requests using
http://localhost:8080/identity/* - Done!
For HTTP Client Files
Update all existing baseUrl entries:
Pattern:
{
"dev": {
"baseUrl": "http://localhost:8080/{service-name}"
}
}
Service name mappings:
- Identity →
identity - CoreData →
core-data - SystemEnvironment →
system-environment - Spatial →
spatial - Events →
events - Catalog →
catalog - Edge →
edge - Bbu →
bbu - DocWeb →
docs(shorter, more friendly) - Importer →
importer
For Service-to-Service Communication
No changes required! Services continue using Aspire service discovery:
// This still works unchanged
var response = await httpClient.GetAsync("https+http://identity/auth/me");
Future Enhancements
Phase 2: Optional Caddy Layer for Subdomains
If developers want even nicer URLs (http://identity.localhost instead of http://localhost:8080/identity):
Caddyfile:
identity.localhost {
reverse_proxy localhost:8080/identity
}
core-data.localhost {
reverse_proxy localhost:8080/core-data
}
# etc.
Pros:
- Prettiest URLs possible
- Production-like domain structure
- Easy to remember
Cons:
- Another tool to install/run
- Optional enhancement, not required
Phase 3: CORS Configuration
Add CORS middleware to gateway for browser-based clients:
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.WithOrigins("http://localhost:3000") // Next.js UI
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
});
});
app.UseCors();
Phase 4: Rate Limiting
Add rate limiting to protect services:
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("fixed", opt =>
{
opt.Window = TimeSpan.FromSeconds(10);
opt.PermitLimit = 100;
});
});
app.UseRateLimiter();
Phase 5: Centralized Authentication
Add JWT validation at gateway level:
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
// Validate tokens at gateway
// Forward claims to backend services
});
This would allow services to trust gateway-validated tokens without re-validating.
Performance Considerations
Latency
- Expected overhead: 1-3ms per request (YARP is very fast)
- Service-to-service: Zero overhead (bypass gateway)
- Acceptable for development: Yes
Throughput
- YARP can handle 100k+ requests/second
- More than sufficient for local development
Resource Usage
- Memory: ~50-100MB
- CPU: Minimal (<1% idle, <5% under load)
Troubleshooting Guide
Gateway Not Starting
Symptom: Gateway doesn't appear in Aspire dashboard
Check:
- Project reference added to AppHost
.WithGateway<>()called in EdgeLiteBuilder- Build successful (no compilation errors)
Solution: Review AppHost configuration, check build output
Service Route Not Working
Symptom: 404 Not Found when accessing service via gateway
Check:
- Service is running (visible in Aspire dashboard)
- Route configured in appsettings.json
- Path prefix matches (e.g.,
/identity/not/Identity/)
Solution:
- Check Aspire dashboard - is service healthy?
- Verify appsettings.json route configuration
- Check YARP logs for routing decisions
Service Discovery Failing
Symptom: 503 Service Unavailable
Check:
- Service discovery configured correctly
- Service name matches exactly (case-sensitive)
- Service has HTTP endpoint registered
Solution:
- Check service name in Aspire dashboard
- Verify
https+http://service-nameformat - Review Gateway logs for discovery errors
Slow Requests
Symptom: Requests taking longer than expected
Check:
- Service itself slow (check direct access)
- Network issues (unlikely on localhost)
- YARP logging overhead (disable verbose logging)
Solution:
- Test service directly (bypass gateway)
- Reduce YARP logging level
- Check service health and performance
Documentation Updates Required
After implementation, update:
Developer Onboarding Guide
- Add section on gateway usage
- Update "Starting the application" instructions
- Add troubleshooting section
HTTP Client Usage Guide
- Document new URL format
- Provide examples for each service
- Explain when to use gateway vs service discovery
Architecture Documentation
- Add gateway to architecture diagrams
- Explain request flow
- Document service discovery integration
README.md
- Update "Running the Application" section
- Add gateway URL list
- Link to detailed documentation
Success Criteria
Implementation is complete when:
✅ Gateway service builds and runs
✅ Gateway appears in Aspire dashboard
✅ Gateway binds to port 8080
✅ All service routes configured
✅ HTTP client files updated
✅ Login flow works via gateway
✅ Protected endpoints work via gateway
✅ Service-to-service communication unchanged
✅ Health checks passing
✅ Documentation updated
✅ Team can use without port lookups
Estimated Effort
- Gateway Project Setup: 30 minutes
- YARP Configuration: 1 hour
- EdgeLiteBuilder Integration: 30 minutes
- HTTP Client Updates: 30 minutes
- Testing: 1 hour
- Documentation: 1 hour
- Total: ~4.5 hours
Dependencies
- Blocked By: Base component restructuring (currently in progress)
- Requires: .NET 9.0, Aspire 9.0, YARP 2.3.0
- Impacts: All developers, all HTTP client files
Notes
- This plan assumes the current Aspire setup remains largely unchanged
- If base restructuring changes service naming or organization, routes may need adjustment
- Gateway can be added incrementally - doesn't require all services at once
- Consider starting with just Identity and CoreData, then expanding
Questions to Resolve Before Implementation
- Should gateway support both HTTP and HTTPS?
- Do we want health check integration from day one?
- Should doc-web use
/docsor/doc-web? - Any services that should NOT be exposed via gateway?
- Do we want request/response logging enabled by default?