Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
- Implemented MigrationCategoryTests to validate migration categorization for startup, release, seed, and data migrations. - Added tests for edge cases, including null, empty, and whitespace migration names. - Created StartupMigrationHostTests to verify the behavior of the migration host with real PostgreSQL instances using Testcontainers. - Included tests for migration execution, schema creation, and handling of pending release migrations. - Added SQL migration files for testing: creating a test table, adding a column, a release migration, and seeding data.
21 KiB
21 KiB
Step 28: Agent Process Guidelines
Overview
This document provides comprehensive guidelines for AI agents (Claude, Copilot, etc.) implementing the Stella Router. It establishes conventions, patterns, and decision frameworks to ensure consistent, high-quality implementations across all phases.
Goals
- Define clear coding standards and patterns for Router implementation
- Establish decision frameworks for common scenarios
- Provide checklists for implementation quality
- Document testing requirements and coverage expectations
- Define commit and PR conventions
Implementation Standards
Code Organization
src/Router/
├── StellaOps.Router.Core/ # Core abstractions and contracts
│ ├── Abstractions/ # Interfaces
│ ├── Configuration/ # Config models
│ ├── Extensions/ # Extension methods
│ └── Primitives/ # Value types
│
├── StellaOps.Router.Gateway/ # Gateway implementation
│ ├── Routing/ # Route matching
│ ├── Handlers/ # Route handlers
│ ├── Pipeline/ # Request pipeline
│ └── Middleware/ # Gateway middleware
│
├── StellaOps.Router.Transport/ # Transport implementations
│ ├── InMemory/ # In-process transport
│ ├── Tcp/ # TCP transport
│ └── Tls/ # TLS transport
│
├── StellaOps.Router.Microservice/ # Microservice SDK
│ ├── Hosting/ # Host builder
│ ├── Endpoints/ # Endpoint handling
│ └── Context/ # Request context
│
├── StellaOps.Router.Security/ # Security components
│ ├── Jwt/ # JWT validation
│ ├── Claims/ # Claim hydration
│ └── RateLimiting/ # Rate limiting
│
└── StellaOps.Router.Observability/ # Observability
├── Logging/ # Structured logging
├── Metrics/ # Prometheus metrics
└── Tracing/ # OpenTelemetry tracing
Naming Conventions
| Element | Convention | Example |
|---|---|---|
| Interfaces | I prefix, noun/adjective |
IRouteHandler, IConnectable |
| Classes | PascalCase, noun | JwtValidator, RouteTable |
| Async methods | Async suffix |
ValidateTokenAsync, SendAsync |
| Config classes | Options or Configuration suffix |
JwtValidationOptions |
| Event handlers | On prefix |
OnConnectionEstablished |
| Factory methods | Create prefix |
CreateHandler, CreateConnection |
| Boolean properties | Is/Has/Can prefix |
IsValid, HasExpired, CanRetry |
File Structure
// File: StellaOps.Router.Core/Abstractions/IRouteHandler.cs
// 1. License header (if required)
// 2. Using statements (sorted: System, Microsoft, Third-party, Internal)
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Router.Core.Configuration;
// 3. Namespace (one per file, matches folder structure)
namespace StellaOps.Router.Core.Abstractions;
// 4. XML documentation
/// <summary>
/// Handles requests for a specific route type.
/// </summary>
/// <remarks>
/// Implementations must be thread-safe and support concurrent request handling.
/// </remarks>
public interface IRouteHandler
{
// 5. Interface members (properties, then methods)
/// <summary>
/// Gets the handler type identifier.
/// </summary>
string HandlerType { get; }
/// <summary>
/// Determines if this handler can process the given route.
/// </summary>
bool CanHandle(RouteConfiguration route);
/// <summary>
/// Processes an incoming request.
/// </summary>
Task<ResponsePayload> HandleAsync(
RequestPayload request,
RouteConfiguration route,
CancellationToken cancellationToken = default);
}
Error Handling Patterns
// Pattern 1: Result types for expected failures
public readonly struct Result<T>
{
public T? Value { get; }
public Error? Error { get; }
public bool IsSuccess => Error == null;
private Result(T? value, Error? error)
{
Value = value;
Error = error;
}
public static Result<T> Success(T value) => new(value, null);
public static Result<T> Failure(Error error) => new(default, error);
public Result<TNext> Map<TNext>(Func<T, TNext> map) =>
IsSuccess ? Result<TNext>.Success(map(Value!)) : Result<TNext>.Failure(Error!);
public async Task<Result<TNext>> MapAsync<TNext>(Func<T, Task<TNext>> map) =>
IsSuccess ? Result<TNext>.Success(await map(Value!)) : Result<TNext>.Failure(Error!);
}
public record Error(string Code, string Message, Exception? Inner = null);
// Usage
public async Task<Result<JwtClaims>> ValidateTokenAsync(string token)
{
try
{
var claims = await _validator.ValidateAsync(token);
return Result<JwtClaims>.Success(claims);
}
catch (SecurityTokenExpiredException ex)
{
return Result<JwtClaims>.Failure(new Error("TOKEN_EXPIRED", "JWT has expired", ex));
}
catch (SecurityTokenInvalidSignatureException ex)
{
return Result<JwtClaims>.Failure(new Error("INVALID_SIGNATURE", "JWT signature invalid", ex));
}
}
// Pattern 2: Exceptions for unexpected failures
public class RouterException : Exception
{
public string ErrorCode { get; }
public int StatusCode { get; }
public RouterException(string errorCode, string message, int statusCode = 500)
: base(message)
{
ErrorCode = errorCode;
StatusCode = statusCode;
}
}
public class ConfigurationException : RouterException
{
public ConfigurationException(string message)
: base("CONFIG_ERROR", message, 500) { }
}
public class TransportException : RouterException
{
public TransportException(string message, Exception? inner = null)
: base("TRANSPORT_ERROR", message, 503) { }
}
Async Patterns
// Pattern 1: CancellationToken propagation
public async Task<ResponsePayload> HandleAsync(
RequestPayload request,
CancellationToken cancellationToken = default)
{
// Always check at start of long operations
cancellationToken.ThrowIfCancellationRequested();
// Propagate to all async calls
var validated = await _validator.ValidateAsync(request, cancellationToken);
var enriched = await _enricher.EnrichAsync(validated, cancellationToken);
var response = await _handler.ProcessAsync(enriched, cancellationToken);
return response;
}
// Pattern 2: Timeout handling
public async Task<T> WithTimeoutAsync<T>(
Func<CancellationToken, Task<T>> operation,
TimeSpan timeout,
CancellationToken cancellationToken = default)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(timeout);
try
{
return await operation(cts.Token);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
throw new TimeoutException($"Operation timed out after {timeout}");
}
}
// Pattern 3: Fire-and-forget with logging
public void FireAndForget(Func<Task> operation, ILogger logger, string operationName)
{
_ = Task.Run(async () =>
{
try
{
await operation();
}
catch (Exception ex)
{
logger.LogError(ex, "Fire-and-forget operation {Operation} failed", operationName);
}
});
}
Dependency Injection Patterns
// Pattern 1: Constructor injection with validation
public class JwtValidator : IJwtValidator
{
private readonly JwtValidationOptions _options;
private readonly IKeyProvider _keyProvider;
private readonly ILogger<JwtValidator> _logger;
public JwtValidator(
IOptions<JwtValidationOptions> options,
IKeyProvider keyProvider,
ILogger<JwtValidator> logger)
{
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_keyProvider = keyProvider ?? throw new ArgumentNullException(nameof(keyProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
ValidateOptions(_options);
}
private static void ValidateOptions(JwtValidationOptions options)
{
if (string.IsNullOrEmpty(options.Issuer))
throw new ConfigurationException("JWT issuer is required");
if (options.ClockSkew < TimeSpan.Zero)
throw new ConfigurationException("Clock skew cannot be negative");
}
}
// Pattern 2: Factory registration for complex objects
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddStellaRouter(
this IServiceCollection services,
Action<RouterOptions> configure)
{
services.Configure(configure);
// Core services
services.AddSingleton<IRouteTable, RouteTable>();
services.AddSingleton<IRequestPipeline, RequestPipeline>();
// Keyed services for handlers
services.AddKeyedSingleton<IRouteHandler, MicroserviceHandler>("microservice");
services.AddKeyedSingleton<IRouteHandler, GraphQLHandler>("graphql");
services.AddKeyedSingleton<IRouteHandler, ReverseProxyHandler>("proxy");
// Factory for route handler resolution
services.AddSingleton<IRouteHandlerFactory>(sp => new RouteHandlerFactory(
sp.GetServices<IRouteHandler>().ToDictionary(h => h.HandlerType)));
return services;
}
}
// Pattern 3: Scoped services for request context
public static class RequestScopeExtensions
{
public static IServiceCollection AddRequestScope(this IServiceCollection services)
{
services.AddScoped<IRequestContext, RequestContext>();
services.AddScoped(sp => sp.GetRequiredService<IRequestContext>().User);
services.AddScoped(sp => sp.GetRequiredService<IRequestContext>().CorrelationId);
return services;
}
}
Decision Framework
When to Create New Types vs. Reuse
| Scenario | Decision | Rationale |
|---|---|---|
| Similar data, different context | Create new type | Type safety, clear intent |
| Same data, same context | Reuse type | DRY, reduce cognitive load |
| Third-party type | Create wrapper | Abstraction, testability |
| Config vs. runtime | Separate types | Immutability guarantees |
// Example: Separate types for config vs runtime
public record RouteConfiguration(
string Path,
string Method,
string HandlerType,
Dictionary<string, string> Metadata);
public class CompiledRoute
{
public RouteConfiguration Config { get; }
public Regex PathPattern { get; }
public IRouteHandler Handler { get; }
// Runtime-computed fields
}
When to Use Interfaces vs. Abstract Classes
| Use Interface | Use Abstract Class |
|---|---|
| Multiple inheritance needed | Shared implementation |
| Contract-only definition | Template method pattern |
| Third-party implementation | Internal hierarchy only |
| Mocking/testing priority | Code reuse priority |
Logging Level Guidelines
| Level | When to Use | Example |
|---|---|---|
Trace |
Internal flow details | "Route matching attempt for {Path}" |
Debug |
Diagnostic information | "Cache hit for key {Key}" |
Information |
Significant events | "Request completed: {Method} {Path} → {Status}" |
Warning |
Recoverable issues | "Rate limit approaching: {Current}/{Max}" |
Error |
Failures requiring attention | "Failed to connect to Authority: {Error}" |
Critical |
System-wide failures | "Configuration invalid, router cannot start" |
// Structured logging patterns
_logger.LogInformation(
"Request processed: {Method} {Path} → {StatusCode} in {ElapsedMs}ms",
request.Method,
request.Path,
response.StatusCode,
stopwatch.ElapsedMilliseconds);
// Use LoggerMessage for high-performance paths
private static readonly Action<ILogger, string, string, int, long, Exception?> LogRequestComplete =
LoggerMessage.Define<string, string, int, long>(
LogLevel.Information,
new EventId(1001, "RequestComplete"),
"Request processed: {Method} {Path} → {StatusCode} in {ElapsedMs}ms");
// Usage
LogRequestComplete(_logger, method, path, statusCode, elapsed, null);
Implementation Checklists
Before Starting a Component
- Read the step documentation thoroughly
- Understand dependencies on previous steps
- Review related existing code patterns
- Identify configuration requirements
- Plan test coverage strategy
During Implementation
- Follow naming conventions
- Add XML documentation to public APIs
- Implement
IDisposable/IAsyncDisposablewhere needed - Add structured logging at appropriate levels
- Handle cancellation tokens throughout
- Use result types for expected failures
- Validate all configuration at startup
Before Marking Complete
- All public types have XML documentation
- Unit tests achieve >80% coverage
- Integration tests cover happy path + error cases
- No compiler warnings
- Code passes all linting rules
- Configuration is validated
- README/documentation updated if needed
Pull Request Checklist
- PR title follows convention:
feat(router): description - Description explains what and why
- All tests pass
- No unrelated changes
- Breaking changes documented
- Reviewable size (<500 lines preferred)
Testing Requirements
Unit Test Coverage Targets
| Component Type | Target Coverage |
|---|---|
| Core logic | 90% |
| Handlers | 85% |
| Middleware | 80% |
| Configuration | 75% |
| Extensions | 70% |
Test Structure
// Test file naming: {ClassName}Tests.cs
// Test method naming: {Method}_{Scenario}_{ExpectedResult}
public class JwtValidatorTests
{
private readonly JwtValidator _sut; // System Under Test
private readonly Mock<IKeyProvider> _keyProviderMock;
private readonly Mock<ILogger<JwtValidator>> _loggerMock;
public JwtValidatorTests()
{
_keyProviderMock = new Mock<IKeyProvider>();
_loggerMock = new Mock<ILogger<JwtValidator>>();
var options = Options.Create(new JwtValidationOptions
{
Issuer = "https://auth.example.com",
Audience = "stella-router"
});
_sut = new JwtValidator(options, _keyProviderMock.Object, _loggerMock.Object);
}
[Fact]
public async Task ValidateAsync_ValidToken_ReturnsSuccessWithClaims()
{
// Arrange
var token = GenerateValidToken();
_keyProviderMock
.Setup(x => x.GetSigningKeyAsync(It.IsAny<string>()))
.ReturnsAsync(TestKeys.ValidKey);
// Act
var result = await _sut.ValidateAsync(token);
// Assert
Assert.True(result.IsSuccess);
Assert.NotNull(result.Value);
Assert.Equal("test-user", result.Value.Subject);
}
[Fact]
public async Task ValidateAsync_ExpiredToken_ReturnsFailure()
{
// Arrange
var token = GenerateExpiredToken();
// Act
var result = await _sut.ValidateAsync(token);
// Assert
Assert.False(result.IsSuccess);
Assert.Equal("TOKEN_EXPIRED", result.Error!.Code);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task ValidateAsync_NullOrEmptyToken_ReturnsFailure(string? token)
{
// Act
var result = await _sut.ValidateAsync(token!);
// Assert
Assert.False(result.IsSuccess);
Assert.Equal("INVALID_TOKEN", result.Error!.Code);
}
}
Integration Test Patterns
public class RouterIntegrationTests : IClassFixture<RouterTestFixture>
{
private readonly RouterTestFixture _fixture;
public RouterIntegrationTests(RouterTestFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task EndToEnd_AuthenticatedRequest_ReturnsSuccess()
{
// Arrange
var client = _fixture.CreateAuthenticatedClient(claims: new()
{
["sub"] = "test-user",
["role"] = "admin"
});
// Act
var response = await client.GetAsync("/api/users/123");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var user = await response.Content.ReadFromJsonAsync<UserDto>();
Assert.NotNull(user);
Assert.Equal("123", user.Id);
}
}
// Test fixture
public class RouterTestFixture : IAsyncLifetime
{
private IHost? _gatewayHost;
private IHost? _microserviceHost;
public async Task InitializeAsync()
{
// Start microservice
_microserviceHost = await CreateMicroserviceHost();
await _microserviceHost.StartAsync();
// Start gateway
_gatewayHost = await CreateGatewayHost();
await _gatewayHost.StartAsync();
}
public async Task DisposeAsync()
{
if (_gatewayHost != null)
await _gatewayHost.StopAsync();
if (_microserviceHost != null)
await _microserviceHost.StopAsync();
_gatewayHost?.Dispose();
_microserviceHost?.Dispose();
}
public HttpClient CreateAuthenticatedClient(Dictionary<string, object> claims)
{
var token = GenerateTestToken(claims);
var client = new HttpClient
{
BaseAddress = new Uri("http://localhost:5000")
};
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
return client;
}
}
Git and PR Conventions
Branch Naming
feat/router-<step>-<description>
fix/router-<issue-number>
refactor/router-<description>
test/router-<description>
docs/router-<description>
Commit Messages
<type>(<scope>): <description>
[optional body]
[optional footer]
Types: feat, fix, refactor, test, docs, chore
Examples:
feat(router): implement JWT validation with per-endpoint keys
- Add JwtValidator with configurable key sources
- Support RS256 and ES256 algorithms
- Add JWKS endpoint caching with TTL
Closes #123
PR Template
## Summary
Brief description of what this PR does.
## Changes
- Change 1
- Change 2
- Change 3
## Testing
- [ ] Unit tests added/updated
- [ ] Integration tests added/updated
- [ ] Manual testing performed
## Checklist
- [ ] Code follows project conventions
- [ ] Documentation updated
- [ ] No breaking changes (or documented if any)
- [ ] All tests pass
Common Pitfalls to Avoid
Performance
// ❌ BAD: Allocating in hot path
public bool MatchRoute(string path)
{
var parts = path.Split('/'); // Allocation
// ...
}
// ✅ GOOD: Use Span for parsing
public bool MatchRoute(ReadOnlySpan<char> path)
{
// Zero-allocation parsing
foreach (var segment in path.Split('/'))
{
// ...
}
}
// ❌ BAD: Synchronous I/O blocking async context
public async Task ProcessAsync()
{
var config = File.ReadAllText("config.json"); // Blocking!
}
// ✅ GOOD: Async all the way
public async Task ProcessAsync()
{
var config = await File.ReadAllTextAsync("config.json");
}
Thread Safety
// ❌ BAD: Non-thread-safe collection
private readonly Dictionary<string, Route> _routes = new();
public void AddRoute(string key, Route route)
{
_routes[key] = route; // Not thread-safe!
}
// ✅ GOOD: Thread-safe collection
private readonly ConcurrentDictionary<string, Route> _routes = new();
public void AddRoute(string key, Route route)
{
_routes[key] = route; // Thread-safe
}
// ✅ GOOD: Immutable update
private ImmutableDictionary<string, Route> _routes =
ImmutableDictionary<string, Route>.Empty;
public void AddRoute(string key, Route route)
{
ImmutableInterlocked.AddOrUpdate(ref _routes, key, route, (_, _) => route);
}
Resource Management
// ❌ BAD: Not disposing resources
public async Task SendAsync(byte[] data)
{
var client = new TcpClient();
await client.ConnectAsync("host", 9100);
await client.GetStream().WriteAsync(data);
// client never disposed!
}
// ✅ GOOD: Proper disposal
public async Task SendAsync(byte[] data)
{
using var client = new TcpClient();
await client.ConnectAsync("host", 9100);
await using var stream = client.GetStream();
await stream.WriteAsync(data);
}
// ✅ GOOD: Connection pooling
public class ConnectionPool : IDisposable
{
private readonly Channel<TcpClient> _pool;
public async Task<TcpClient> RentAsync()
{
if (_pool.Reader.TryRead(out var client))
return client;
return await CreateNewConnectionAsync();
}
public void Return(TcpClient client)
{
if (!_pool.Writer.TryWrite(client))
client.Dispose();
}
}
Deliverables
| Artifact | Purpose |
|---|---|
| This document | Agent implementation guidelines |
| Code templates | Consistent starting points |
| Checklists | Quality gates |
| Test patterns | Consistent testing approach |