# Step 27: Reference Example & Migration Skeleton ## Overview This step provides a complete reference implementation demonstrating all router features, along with migration tooling and patterns for gradually transitioning existing monolithic services to the Stella Router microservice architecture. ## Goals 1. Create a fully-functional reference microservice with all features 2. Provide migration skeleton for existing ASP.NET Core services 3. Document step-by-step migration patterns 4. Provide code scaffolding tools for new services 5. Create compatibility shims for gradual adoption ## Reference Architecture ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ Reference Example Layout │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ src/ │ │ └── Examples/ │ │ └── StellaOps.Router.Examples/ │ │ ├── ReferenceService/ # Complete reference impl │ │ │ ├── Handlers/ # Endpoint handlers │ │ │ ├── Services/ # Business logic │ │ │ ├── Models/ # Domain models │ │ │ └── Program.cs # Host configuration │ │ │ │ │ ├── MigrationTemplates/ # Migration scaffolds │ │ │ ├── BasicMigration/ # Minimal migration │ │ │ ├── DualModeMigration/ # Parallel run mode │ │ │ └── GradualMigration/ # Incremental endpoint migration │ │ │ │ │ └── Scaffolding/ # Code generation tools │ │ ├── Templates/ # T4/Scriban templates │ │ └── Generator/ # CLI scaffolding tool │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ ``` ## Reference Service Implementation ### Complete Reference Microservice ```csharp // ReferenceService/Program.cs using StellaOps.Router.Microservice; using StellaOps.Router.Examples.ReferenceService; var builder = StellaMicroservice.CreateBuilder(args); // Configure all features builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Add handlers builder.AddHandler(); builder.AddHandler(); builder.AddHandler(); builder.AddHandler(); builder.AddHandler(); // Configure transports builder.UseTransport("tcp", options => { options.Host = "0.0.0.0"; options.Port = 9100; }); // Enable dual exposure for direct HTTP access builder.EnableDualExposure(http => { http.Port = 8080; http.DefaultClaims = new Dictionary { ["role"] = "internal-service", ["permissions"] = new[] { "read", "write" } }; }); // Configure resilience builder.ConfigureResilience(resilience => { resilience.CircuitBreaker.FailureThreshold = 5; resilience.CircuitBreaker.RecoveryTimeout = TimeSpan.FromSeconds(30); resilience.Retry.MaxAttempts = 3; resilience.Retry.BaseDelay = TimeSpan.FromMilliseconds(100); }); // Configure observability builder.ConfigureObservability(obs => { obs.EnableMetrics = true; obs.EnableTracing = true; obs.ServiceName = "reference-service"; obs.OtlpEndpoint = "http://otel-collector:4317"; }); var host = builder.Build(); await host.RunAsync(); ``` ### User Management Handler ```csharp // ReferenceService/Handlers/UserHandler.cs namespace StellaOps.Router.Examples.ReferenceService.Handlers; [StellaEndpoint(ServiceName = "users", Version = "v1")] public class UserHandler : EndpointHandler { private readonly IUserRepository _repository; private readonly ILogger _logger; public UserHandler(IUserRepository repository, ILogger logger) { _repository = repository; _logger = logger; } [StellaRoute("GET", "/users")] [StellaAuth(RequiredClaims = ["user:list"])] [StellaRateLimit(RequestsPerMinute = 100)] public async Task ListUsers( StellaRequestContext context, [FromQuery("page")] int page = 1, [FromQuery("size")] int pageSize = 20, [FromQuery("search")] string? search = null) { _logger.LogInformation("Listing users: page={Page}, size={Size}, search={Search}", page, pageSize, search); var (users, total) = await _repository.ListAsync(page, pageSize, search); return Ok(new UserListResponse { Users = users.Select(u => u.ToDto()).ToList(), Page = page, PageSize = pageSize, TotalCount = total, TotalPages = (int)Math.Ceiling((double)total / pageSize) }); } [StellaRoute("GET", "/users/{id}")] [StellaAuth(RequiredClaims = ["user:read"])] [StellaCache(Duration = 60, VaryByPath = true)] public async Task GetUser( StellaRequestContext context, [FromPath("id")] Guid id) { var user = await _repository.GetByIdAsync(id); if (user == null) { return NotFound(new ErrorResponse("USER_NOT_FOUND", $"User {id} not found")); } // Enrich response with claims-based data var canViewPrivate = context.HasClaim("user:admin") || context.GetClaim("user_id") == id; return Ok(user.ToDto(includePrivate: canViewPrivate)); } [StellaRoute("POST", "/users")] [StellaAuth(RequiredClaims = ["user:create"])] [StellaRateLimit(RequestsPerMinute = 10)] public async Task CreateUser( StellaRequestContext context, [FromBody] CreateUserRequest request) { // Validate request var validation = ValidateCreateRequest(request); if (!validation.IsValid) { return BadRequest(new ValidationErrorResponse(validation.Errors)); } // Check for duplicate email var existing = await _repository.GetByEmailAsync(request.Email); if (existing != null) { return Conflict(new ErrorResponse("EMAIL_EXISTS", "Email already registered")); } var user = new User { Id = Guid.NewGuid(), Email = request.Email, Name = request.Name, Role = request.Role ?? "user", CreatedAt = DateTime.UtcNow, CreatedBy = context.GetClaim("user_id") }; await _repository.CreateAsync(user); _logger.LogInformation("User created: {UserId} by {CreatedBy}", user.Id, user.CreatedBy); return Created($"/users/{user.Id}", user.ToDto()); } [StellaRoute("PUT", "/users/{id}")] [StellaAuth(RequiredClaims = ["user:update"])] public async Task UpdateUser( StellaRequestContext context, [FromPath("id")] Guid id, [FromBody] UpdateUserRequest request) { var user = await _repository.GetByIdAsync(id); if (user == null) { return NotFound(new ErrorResponse("USER_NOT_FOUND", $"User {id} not found")); } // Check authorization - users can update themselves, admins can update anyone var currentUserId = context.GetClaim("user_id"); var isAdmin = context.HasClaim("user:admin"); if (currentUserId != id && !isAdmin) { return Forbidden(new ErrorResponse("FORBIDDEN", "Cannot update other users")); } // Apply updates if (request.Name != null) user.Name = request.Name; if (request.Email != null) user.Email = request.Email; if (request.Role != null && isAdmin) user.Role = request.Role; user.UpdatedAt = DateTime.UtcNow; user.UpdatedBy = currentUserId; await _repository.UpdateAsync(user); return Ok(user.ToDto()); } [StellaRoute("DELETE", "/users/{id}")] [StellaAuth(RequiredClaims = ["user:admin"])] public async Task DeleteUser( StellaRequestContext context, [FromPath("id")] Guid id) { var user = await _repository.GetByIdAsync(id); if (user == null) { return NotFound(new ErrorResponse("USER_NOT_FOUND", $"User {id} not found")); } // Soft delete user.DeletedAt = DateTime.UtcNow; user.DeletedBy = context.GetClaim("user_id"); await _repository.UpdateAsync(user); _logger.LogInformation("User deleted: {UserId} by {DeletedBy}", id, user.DeletedBy); return NoContent(); } private ValidationResult ValidateCreateRequest(CreateUserRequest request) { var errors = new List(); if (string.IsNullOrWhiteSpace(request.Email)) errors.Add(new("email", "Email is required")); else if (!IsValidEmail(request.Email)) errors.Add(new("email", "Invalid email format")); if (string.IsNullOrWhiteSpace(request.Name)) errors.Add(new("name", "Name is required")); else if (request.Name.Length < 2 || request.Name.Length > 100) errors.Add(new("name", "Name must be 2-100 characters")); return new ValidationResult(errors); } private static bool IsValidEmail(string email) => System.Text.RegularExpressions.Regex.IsMatch(email, @"^[^@\s]+@[^@\s]+\.[^@\s]+$"); } ``` ### Product Catalog Handler ```csharp // ReferenceService/Handlers/ProductHandler.cs namespace StellaOps.Router.Examples.ReferenceService.Handlers; [StellaEndpoint(ServiceName = "products", Version = "v1")] public class ProductHandler : EndpointHandler { private readonly IProductCatalog _catalog; private readonly ILogger _logger; public ProductHandler(IProductCatalog catalog, ILogger logger) { _catalog = catalog; _logger = logger; } [StellaRoute("GET", "/products")] [StellaRateLimit(RequestsPerMinute = 200)] [StellaCache(Duration = 300, VaryByQuery = ["category", "brand"])] public async Task ListProducts( StellaRequestContext context, [FromQuery("category")] string? category = null, [FromQuery("brand")] string? brand = null, [FromQuery("minPrice")] decimal? minPrice = null, [FromQuery("maxPrice")] decimal? maxPrice = null, [FromQuery("page")] int page = 1, [FromQuery("size")] int pageSize = 20, [FromQuery("sort")] string sort = "name") { var filter = new ProductFilter { Category = category, Brand = brand, MinPrice = minPrice, MaxPrice = maxPrice }; var sortOptions = ParseSortOptions(sort); var (products, total) = await _catalog.SearchAsync(filter, sortOptions, page, pageSize); return Ok(new ProductListResponse { Products = products.Select(p => p.ToDto()).ToList(), Page = page, PageSize = pageSize, TotalCount = total, Facets = await _catalog.GetFacetsAsync(filter) }); } [StellaRoute("GET", "/products/{id}")] [StellaCache(Duration = 600, VaryByPath = true)] public async Task GetProduct( StellaRequestContext context, [FromPath("id")] string id) { var product = await _catalog.GetByIdAsync(id); if (product == null) { return NotFound(new ErrorResponse("PRODUCT_NOT_FOUND", $"Product {id} not found")); } // Track view for analytics (fire-and-forget) _ = _catalog.RecordViewAsync(id, context.CorrelationId); return Ok(product.ToDetailDto()); } [StellaRoute("POST", "/products")] [StellaAuth(RequiredClaims = ["product:create"])] [StellaRateLimit(RequestsPerMinute = 30)] public async Task CreateProduct( StellaRequestContext context, [FromBody] CreateProductRequest request) { var product = new Product { Id = GenerateProductId(request.Category), Name = request.Name, Description = request.Description, Category = request.Category, Brand = request.Brand, Price = request.Price, Sku = request.Sku, Inventory = request.InitialInventory, CreatedAt = DateTime.UtcNow }; await _catalog.CreateAsync(product); return Created($"/products/{product.Id}", product.ToDto()); } [StellaRoute("PUT", "/products/{id}/inventory")] [StellaAuth(RequiredClaims = ["product:inventory"])] public async Task UpdateInventory( StellaRequestContext context, [FromPath("id")] string id, [FromBody] UpdateInventoryRequest request) { var product = await _catalog.GetByIdAsync(id); if (product == null) { return NotFound(new ErrorResponse("PRODUCT_NOT_FOUND", $"Product {id} not found")); } var newQuantity = request.Operation switch { "set" => request.Quantity, "add" => product.Inventory + request.Quantity, "subtract" => product.Inventory - request.Quantity, _ => throw new ArgumentException($"Invalid operation: {request.Operation}") }; if (newQuantity < 0) { return BadRequest(new ErrorResponse("INSUFFICIENT_INVENTORY", $"Cannot reduce inventory below 0. Current: {product.Inventory}")); } product.Inventory = newQuantity; product.UpdatedAt = DateTime.UtcNow; await _catalog.UpdateAsync(product); return Ok(new { ProductId = id, NewInventory = newQuantity }); } private static string GenerateProductId(string category) { var prefix = category.ToUpperInvariant()[..3]; var suffix = Guid.NewGuid().ToString("N")[..8].ToUpperInvariant(); return $"{prefix}-{suffix}"; } private static SortOptions ParseSortOptions(string sort) { var descending = sort.StartsWith('-'); var field = descending ? sort[1..] : sort; return new SortOptions(field, descending); } } ``` ### Order Processing Handler ```csharp // ReferenceService/Handlers/OrderHandler.cs namespace StellaOps.Router.Examples.ReferenceService.Handlers; [StellaEndpoint(ServiceName = "orders", Version = "v1")] public class OrderHandler : EndpointHandler { private readonly IOrderService _orderService; private readonly IProductCatalog _catalog; private readonly INotificationService _notifications; private readonly ILogger _logger; public OrderHandler( IOrderService orderService, IProductCatalog catalog, INotificationService notifications, ILogger logger) { _orderService = orderService; _catalog = catalog; _notifications = notifications; _logger = logger; } [StellaRoute("POST", "/orders")] [StellaAuth(RequiredClaims = ["order:create"])] [StellaRateLimit(RequestsPerMinute = 20, Key = "user_id")] public async Task CreateOrder( StellaRequestContext context, [FromBody] CreateOrderRequest request) { var userId = context.GetClaim("user_id"); // Validate products and calculate total var lineItems = new List(); decimal total = 0; foreach (var item in request.Items) { var product = await _catalog.GetByIdAsync(item.ProductId); if (product == null) { return BadRequest(new ErrorResponse("INVALID_PRODUCT", $"Product {item.ProductId} not found")); } if (product.Inventory < item.Quantity) { return BadRequest(new ErrorResponse("INSUFFICIENT_INVENTORY", $"Product {item.ProductId} has only {product.Inventory} units available")); } lineItems.Add(new OrderLineItem { ProductId = product.Id, ProductName = product.Name, UnitPrice = product.Price, Quantity = item.Quantity, Subtotal = product.Price * item.Quantity }); total += product.Price * item.Quantity; } // Create order var order = new Order { Id = Guid.NewGuid(), UserId = userId, Items = lineItems, Subtotal = total, Tax = total * 0.08m, // 8% tax Total = total * 1.08m, Status = OrderStatus.Pending, ShippingAddress = request.ShippingAddress, CreatedAt = DateTime.UtcNow }; // Reserve inventory foreach (var item in request.Items) { await _catalog.ReserveInventoryAsync(item.ProductId, item.Quantity, order.Id); } await _orderService.CreateAsync(order); // Send notification (fire-and-forget) _ = _notifications.SendOrderConfirmationAsync(userId, order); _logger.LogInformation("Order created: {OrderId} for user {UserId}, total: {Total}", order.Id, userId, order.Total); return Created($"/orders/{order.Id}", order.ToDto()); } [StellaRoute("GET", "/orders")] [StellaAuth(RequiredClaims = ["order:list"])] public async Task ListOrders( StellaRequestContext context, [FromQuery("status")] string? status = null, [FromQuery("page")] int page = 1, [FromQuery("size")] int pageSize = 20) { var userId = context.GetClaim("user_id"); var isAdmin = context.HasClaim("order:admin"); // Non-admins can only see their own orders Guid? filterUserId = isAdmin ? null : userId; OrderStatus? filterStatus = status != null ? Enum.Parse(status, true) : null; var (orders, total) = await _orderService.ListAsync(filterUserId, filterStatus, page, pageSize); return Ok(new OrderListResponse { Orders = orders.Select(o => o.ToSummaryDto()).ToList(), Page = page, PageSize = pageSize, TotalCount = total }); } [StellaRoute("GET", "/orders/{id}")] [StellaAuth(RequiredClaims = ["order:read"])] public async Task GetOrder( StellaRequestContext context, [FromPath("id")] Guid id) { var order = await _orderService.GetByIdAsync(id); if (order == null) { return NotFound(new ErrorResponse("ORDER_NOT_FOUND", $"Order {id} not found")); } // Check authorization var userId = context.GetClaim("user_id"); var isAdmin = context.HasClaim("order:admin"); if (order.UserId != userId && !isAdmin) { return Forbidden(new ErrorResponse("FORBIDDEN", "Cannot access this order")); } return Ok(order.ToDetailDto()); } [StellaRoute("POST", "/orders/{id}/cancel")] [StellaAuth(RequiredClaims = ["order:cancel"])] public async Task CancelOrder( StellaRequestContext context, [FromPath("id")] Guid id, [FromBody] CancelOrderRequest request) { var order = await _orderService.GetByIdAsync(id); if (order == null) { return NotFound(new ErrorResponse("ORDER_NOT_FOUND", $"Order {id} not found")); } // Check authorization var userId = context.GetClaim("user_id"); var isAdmin = context.HasClaim("order:admin"); if (order.UserId != userId && !isAdmin) { return Forbidden(new ErrorResponse("FORBIDDEN", "Cannot cancel this order")); } // Check if cancellable if (order.Status is OrderStatus.Shipped or OrderStatus.Delivered or OrderStatus.Cancelled) { return BadRequest(new ErrorResponse("NOT_CANCELLABLE", $"Order with status {order.Status} cannot be cancelled")); } // Release inventory foreach (var item in order.Items) { await _catalog.ReleaseInventoryAsync(item.ProductId, item.Quantity, order.Id); } order.Status = OrderStatus.Cancelled; order.CancelledAt = DateTime.UtcNow; order.CancellationReason = request.Reason; await _orderService.UpdateAsync(order); // Send notification _ = _notifications.SendOrderCancellationAsync(order.UserId, order); return Ok(order.ToDto()); } } ``` ### Admin Handler ```csharp // ReferenceService/Handlers/AdminHandler.cs namespace StellaOps.Router.Examples.ReferenceService.Handlers; [StellaEndpoint(ServiceName = "admin", Version = "v1")] [StellaAuth(RequiredClaims = ["admin:access"])] public class AdminHandler : EndpointHandler { private readonly IUserRepository _users; private readonly IOrderService _orders; private readonly IProductCatalog _products; public AdminHandler( IUserRepository users, IOrderService orders, IProductCatalog products) { _users = users; _orders = orders; _products = products; } [StellaRoute("GET", "/admin/stats")] [StellaCache(Duration = 60)] public async Task GetStats(StellaRequestContext context) { var userCount = await _users.CountAsync(); var orderStats = await _orders.GetStatsAsync(); var productStats = await _products.GetStatsAsync(); return Ok(new AdminStatsResponse { Users = new UserStats { Total = userCount, ActiveLast30Days = await _users.CountActiveAsync(TimeSpan.FromDays(30)) }, Orders = orderStats, Products = productStats, GeneratedAt = DateTime.UtcNow }); } [StellaRoute("POST", "/admin/users/{id}/impersonate")] [StellaAuth(RequiredClaims = ["admin:impersonate"])] public async Task ImpersonateUser( StellaRequestContext context, [FromPath("id")] Guid id) { var user = await _users.GetByIdAsync(id); if (user == null) { return NotFound(new ErrorResponse("USER_NOT_FOUND", $"User {id} not found")); } var adminId = context.GetClaim("user_id"); // Generate impersonation token (normally would call Authority) var impersonationClaims = new Dictionary { ["user_id"] = id, ["impersonated_by"] = adminId, ["impersonation_started"] = DateTime.UtcNow.ToString("O") }; return Ok(new ImpersonationResponse { UserId = id, ImpersonatedBy = adminId, Message = "Impersonation token generated - integrate with Authority for actual token" }); } [StellaRoute("GET", "/admin/audit-log")] [StellaAuth(RequiredClaims = ["admin:audit"])] public async Task GetAuditLog( StellaRequestContext context, [FromQuery("startDate")] DateTime? startDate = null, [FromQuery("endDate")] DateTime? endDate = null, [FromQuery("action")] string? action = null, [FromQuery("userId")] Guid? userId = null, [FromQuery("page")] int page = 1, [FromQuery("size")] int pageSize = 50) { // This would integrate with actual audit logging system return Ok(new AuditLogResponse { Entries = new List(), Page = page, PageSize = pageSize, TotalCount = 0 }); } } ``` ## Migration Templates ### Basic Migration Template ```csharp // MigrationTemplates/BasicMigration/Program.cs // Minimal migration from ASP.NET Core controller to Stella Router /* * BEFORE: Traditional ASP.NET Core Controller * * [ApiController] * [Route("api/[controller]")] * public class UsersController : ControllerBase * { * [HttpGet("{id}")] * public async Task GetUser(Guid id) * { * var user = await _repository.GetByIdAsync(id); * return user == null ? NotFound() : Ok(user); * } * } */ // AFTER: Stella Router Handler using StellaOps.Router.Microservice; var builder = StellaMicroservice.CreateBuilder(args); builder.AddHandler(); builder.UseTransport("tcp", o => o.Port = 9100); var host = builder.Build(); await host.RunAsync(); [StellaEndpoint(ServiceName = "users")] public class MigratedUserHandler : EndpointHandler { private readonly IUserRepository _repository; public MigratedUserHandler(IUserRepository repository) { _repository = repository; } // Route pattern: GET /api/users/{id} → GET /users/{id} [StellaRoute("GET", "/users/{id}")] public async Task GetUser( StellaRequestContext context, [FromPath("id")] Guid id) { var user = await _repository.GetByIdAsync(id); return user == null ? NotFound() : Ok(user); } } ``` ### Dual Mode Migration Template ```csharp // MigrationTemplates/DualModeMigration/Program.cs // Run both old ASP.NET Core endpoints and new Stella endpoints simultaneously using StellaOps.Router.Microservice; using StellaOps.Router.Compatibility; var builder = WebApplication.CreateBuilder(args); // Configure both ASP.NET Core and Stella builder.Services.AddControllers(); builder.Services.AddStellaMicroservice(stella => { stella.AddHandler(); stella.UseTransport("tcp", o => o.Port = 9100); }); var app = builder.Build(); // Mount old controllers at legacy path app.MapControllers(); // /api/v1/users - old implementation // Mount Stella handlers app.UseStellaMicroservice(); // /users - new implementation // Compatibility middleware: route based on header or path version app.UseMiddleware(); await app.RunAsync(); // Version routing middleware for gradual migration public class VersionRoutingMiddleware { private readonly RequestDelegate _next; public VersionRoutingMiddleware(RequestDelegate next) { _next = next; } public async Task InvokeAsync(HttpContext context) { // Check for explicit version header if (context.Request.Headers.TryGetValue("X-API-Version", out var version)) { if (version == "2") { // Rewrite path to Stella endpoints context.Request.Path = context.Request.Path.Value? .Replace("/api/v1/", "/") .Replace("/api/", "/"); } } await _next(context); } } ``` ### Gradual Migration Template ```csharp // MigrationTemplates/GradualMigration/MigrationConfig.cs // Configure which endpoints use old vs new implementation namespace StellaOps.Router.Migration; public class MigrationConfig { public Dictionary Endpoints { get; set; } = new(); public enum EndpointMigrationState { Legacy, // Use old ASP.NET Core implementation Shadow, // Call both, compare results, return legacy Canary, // Route X% to new implementation Migrated // Use new Stella implementation } } // MigrationTemplates/GradualMigration/MigrationMiddleware.cs public class MigrationMiddleware { private readonly RequestDelegate _next; private readonly MigrationConfig _config; private readonly IStellaDispatcher _stellaDispatcher; private readonly ILogger _logger; public MigrationMiddleware( RequestDelegate next, MigrationConfig config, IStellaDispatcher stellaDispatcher, ILogger logger) { _next = next; _config = config; _stellaDispatcher = stellaDispatcher; _logger = logger; } public async Task InvokeAsync(HttpContext context) { var path = context.Request.Path.Value ?? ""; var state = GetMigrationState(path); switch (state) { case MigrationConfig.EndpointMigrationState.Legacy: await _next(context); break; case MigrationConfig.EndpointMigrationState.Shadow: await ExecuteShadowMode(context); break; case MigrationConfig.EndpointMigrationState.Canary: await ExecuteCanaryMode(context); break; case MigrationConfig.EndpointMigrationState.Migrated: await ExecuteMigratedMode(context); break; } } private async Task ExecuteShadowMode(HttpContext context) { // Execute both implementations var legacyTask = ExecuteLegacyAsync(context); var stellaTask = ExecuteStellaAsync(context); await Task.WhenAll(legacyTask, stellaTask); var legacyResult = await legacyTask; var stellaResult = await stellaTask; // Compare results for monitoring if (!ResultsMatch(legacyResult, stellaResult)) { _logger.LogWarning( "Shadow mode mismatch for {Path}: Legacy={LegacyStatus}, Stella={StellaStatus}", context.Request.Path, legacyResult.StatusCode, stellaResult.StatusCode); } // Return legacy result await WriteResponse(context, legacyResult); } private async Task ExecuteCanaryMode(HttpContext context) { var path = context.Request.Path.Value ?? ""; var canaryPercentage = GetCanaryPercentage(path); // Determine which implementation to use based on request hash var requestHash = ComputeRequestHash(context); var useStella = (requestHash % 100) < canaryPercentage; if (useStella) { await ExecuteMigratedMode(context); } else { await _next(context); } } private async Task ExecuteMigratedMode(HttpContext context) { var stellaResult = await ExecuteStellaAsync(context); await WriteResponse(context, stellaResult); } private MigrationConfig.EndpointMigrationState GetMigrationState(string path) { foreach (var (pattern, state) in _config.Endpoints) { if (PathMatchesPattern(path, pattern)) { return state; } } return MigrationConfig.EndpointMigrationState.Legacy; } private async Task ExecuteLegacyAsync(HttpContext context) { // Capture legacy response var originalBody = context.Response.Body; using var memoryStream = new MemoryStream(); context.Response.Body = memoryStream; try { await _next(context); memoryStream.Position = 0; var body = await new StreamReader(memoryStream).ReadToEndAsync(); return new MigrationResult { StatusCode = context.Response.StatusCode, Body = body, Headers = context.Response.Headers.ToDictionary(h => h.Key, h => h.Value.ToString()) }; } finally { context.Response.Body = originalBody; } } private async Task ExecuteStellaAsync(HttpContext context) { var request = await ConvertToStellaRequest(context); var response = await _stellaDispatcher.DispatchAsync(request); return new MigrationResult { StatusCode = response.StatusCode, Body = response.GetBodyAsString(), Headers = response.Headers.ToDictionary() }; } private static bool PathMatchesPattern(string path, string pattern) { // Simple glob matching var regex = "^" + Regex.Escape(pattern) .Replace("\\*\\*", ".*") .Replace("\\*", "[^/]*") + "$"; return Regex.IsMatch(path, regex); } private static bool ResultsMatch(MigrationResult legacy, MigrationResult stella) { return legacy.StatusCode == stella.StatusCode; // Could add body comparison with normalization } private static int ComputeRequestHash(HttpContext context) { var key = context.Request.Path + context.Request.QueryString; return Math.Abs(key.GetHashCode()) % 100; } private int GetCanaryPercentage(string path) => 10; // Default 10% private static async Task WriteResponse(HttpContext context, MigrationResult result) { context.Response.StatusCode = result.StatusCode; foreach (var (key, value) in result.Headers) { context.Response.Headers[key] = value; } await context.Response.WriteAsync(result.Body); } private static async Task ConvertToStellaRequest(HttpContext context) { using var reader = new StreamReader(context.Request.Body); var body = await reader.ReadToEndAsync(); return new StellaRequest { Method = context.Request.Method, Path = context.Request.Path.Value ?? "/", Headers = context.Request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString()), Body = Encoding.UTF8.GetBytes(body) }; } } public class MigrationResult { public int StatusCode { get; set; } public string Body { get; set; } = ""; public Dictionary Headers { get; set; } = new(); } ``` ## Code Scaffolding Tool ### CLI Scaffolding Generator ```csharp // Scaffolding/Generator/StellaScaffold.cs namespace StellaOps.Router.Scaffolding; public class StellaScaffold { private readonly ITemplateEngine _templateEngine; public StellaScaffold(ITemplateEngine templateEngine) { _templateEngine = templateEngine; } public async Task GenerateServiceAsync(ServiceScaffoldOptions options) { var context = new ScaffoldContext { ServiceName = options.ServiceName, Namespace = options.Namespace ?? $"StellaOps.{options.ServiceName}", OutputPath = options.OutputPath, Features = options.Features }; // Generate project structure await GenerateProjectFileAsync(context); await GenerateProgramAsync(context); // Generate handlers foreach (var handler in options.Handlers) { await GenerateHandlerAsync(context, handler); } // Generate models foreach (var model in options.Models) { await GenerateModelAsync(context, model); } // Generate configuration await GenerateConfigurationAsync(context); // Generate tests if requested if (options.Features.Contains("tests")) { await GenerateTestsAsync(context, options); } } private async Task GenerateProjectFileAsync(ScaffoldContext context) { var template = await _templateEngine.LoadAsync("project.csproj.scriban"); var content = await template.RenderAsync(context); var path = Path.Combine(context.OutputPath, $"{context.ServiceName}.csproj"); await File.WriteAllTextAsync(path, content); } private async Task GenerateProgramAsync(ScaffoldContext context) { var template = await _templateEngine.LoadAsync("Program.cs.scriban"); var content = await template.RenderAsync(context); var path = Path.Combine(context.OutputPath, "Program.cs"); await File.WriteAllTextAsync(path, content); } private async Task GenerateHandlerAsync(ScaffoldContext context, HandlerDefinition handler) { var template = await _templateEngine.LoadAsync("Handler.cs.scriban"); var handlerContext = new HandlerScaffoldContext { Base = context, Handler = handler }; var content = await template.RenderAsync(handlerContext); var dir = Path.Combine(context.OutputPath, "Handlers"); Directory.CreateDirectory(dir); var path = Path.Combine(dir, $"{handler.Name}Handler.cs"); await File.WriteAllTextAsync(path, content); } private async Task GenerateModelAsync(ScaffoldContext context, ModelDefinition model) { var template = await _templateEngine.LoadAsync("Model.cs.scriban"); var modelContext = new ModelScaffoldContext { Base = context, Model = model }; var content = await template.RenderAsync(modelContext); var dir = Path.Combine(context.OutputPath, "Models"); Directory.CreateDirectory(dir); var path = Path.Combine(dir, $"{model.Name}.cs"); await File.WriteAllTextAsync(path, content); } private async Task GenerateConfigurationAsync(ScaffoldContext context) { // Generate appsettings.json var appSettings = new { Stella = new { Transport = new { Type = "tcp", Host = "0.0.0.0", Port = 9100 }, DualExposure = new { Enabled = true, Port = 8080 } }, Logging = new { LogLevel = new { Default = "Information" } } }; var json = JsonSerializer.Serialize(appSettings, new JsonSerializerOptions { WriteIndented = true }); var path = Path.Combine(context.OutputPath, "appsettings.json"); await File.WriteAllTextAsync(path, json); } private async Task GenerateTestsAsync(ScaffoldContext context, ServiceScaffoldOptions options) { var testContext = new TestScaffoldContext { Base = context, Handlers = options.Handlers }; var testDir = Path.Combine(context.OutputPath, "..", $"{context.ServiceName}.Tests"); Directory.CreateDirectory(testDir); // Generate test project var testProjectTemplate = await _templateEngine.LoadAsync("test-project.csproj.scriban"); var testProjectContent = await testProjectTemplate.RenderAsync(testContext); await File.WriteAllTextAsync( Path.Combine(testDir, $"{context.ServiceName}.Tests.csproj"), testProjectContent); // Generate test fixtures var fixtureTemplate = await _templateEngine.LoadAsync("TestFixture.cs.scriban"); var fixtureContent = await fixtureTemplate.RenderAsync(testContext); await File.WriteAllTextAsync( Path.Combine(testDir, "TestFixture.cs"), fixtureContent); // Generate handler tests foreach (var handler in options.Handlers) { var handlerTestTemplate = await _templateEngine.LoadAsync("HandlerTests.cs.scriban"); var handlerTestContext = new { Base = testContext, Handler = handler }; var handlerTestContent = await handlerTestTemplate.RenderAsync(handlerTestContext); await File.WriteAllTextAsync( Path.Combine(testDir, $"{handler.Name}HandlerTests.cs"), handlerTestContent); } } } public class ServiceScaffoldOptions { public string ServiceName { get; set; } = ""; public string? Namespace { get; set; } public string OutputPath { get; set; } = ""; public List Features { get; set; } = new(); public List Handlers { get; set; } = new(); public List Models { get; set; } = new(); } public class HandlerDefinition { public string Name { get; set; } = ""; public string ResourceName { get; set; } = ""; public List Endpoints { get; set; } = new(); } public class EndpointDefinition { public string Method { get; set; } = "GET"; public string Path { get; set; } = ""; public string Name { get; set; } = ""; public List RequiredClaims { get; set; } = new(); public int? RateLimit { get; set; } } public class ModelDefinition { public string Name { get; set; } = ""; public List Properties { get; set; } = new(); } public class PropertyDefinition { public string Name { get; set; } = ""; public string Type { get; set; } = "string"; public bool Required { get; set; } } ``` ### Scriban Templates ``` {{# Templates/Handler.cs.scriban }} // Auto-generated by StellaScaffold using StellaOps.Router.Microservice; namespace {{ base.namespace }}.Handlers; [StellaEndpoint(ServiceName = "{{ handler.resource_name }}")] public class {{ handler.name }}Handler : EndpointHandler { private readonly ILogger<{{ handler.name }}Handler> _logger; public {{ handler.name }}Handler(ILogger<{{ handler.name }}Handler> logger) { _logger = logger; } {{~ for endpoint in handler.endpoints ~}} [StellaRoute("{{ endpoint.method }}", "{{ endpoint.path }}")] {{~ if endpoint.required_claims.size > 0 ~}} [StellaAuth(RequiredClaims = [{{ endpoint.required_claims | array.join '", "' | prepend '"' | append '"' }}])] {{~ end ~}} {{~ if endpoint.rate_limit ~}} [StellaRateLimit(RequestsPerMinute = {{ endpoint.rate_limit }})] {{~ end ~}} public async Task {{ endpoint.name }}(StellaRequestContext context) { _logger.LogInformation("{{ endpoint.name }} called"); // TODO: Implement {{ endpoint.name }} throw new NotImplementedException(); } {{~ end ~}} } ``` ## CLI Commands ```bash # Generate a new microservice stella-scaffold new MyService \ --namespace StellaOps.MyService \ --handler User:users \ --handler Product:products \ --features tests,docker # Generate a single handler stella-scaffold handler Order \ --resource orders \ --endpoint "GET /orders" \ --endpoint "POST /orders" \ --endpoint "GET /orders/{id}" # Generate migration shim stella-scaffold migrate \ --source Controllers/UsersController.cs \ --output Handlers/UserHandler.cs ``` ## YAML Configuration ```yaml # config/scaffold-config.yaml scaffolding: defaultNamespace: "StellaOps.Services" defaultFeatures: - tests - docker - healthchecks templates: path: "./templates" customTemplates: - name: "enterprise-handler" file: "custom/EnterpriseHandler.cs.scriban" conventions: handlerSuffix: "Handler" modelSuffix: "" testSuffix: "Tests" migration: defaultMode: "gradual" shadowModeEnabled: true canaryPercentage: 10 pathMappings: "/api/v1/": "/" "/api/": "/" endpoints: "/users/**": "shadow" "/products/**": "canary" "/orders/**": "legacy" ``` ## Testing ```csharp [Fact] public async Task ReferenceService_HandlesCompleteWorkflow() { // Arrange using var fixture = new ReferenceServiceFixture(); await fixture.StartAsync(); var client = fixture.CreateClient(); // Create user var createUserResponse = await client.PostAsync("/users", new CreateUserRequest { Email = "test@example.com", Name = "Test User" }); Assert.Equal(201, createUserResponse.StatusCode); var user = await createUserResponse.ReadAsAsync(); // Create product var createProductResponse = await client.PostAsync("/products", new CreateProductRequest { Name = "Test Product", Price = 99.99m, Category = "TEST", InitialInventory = 100 }); Assert.Equal(201, createProductResponse.StatusCode); var product = await createProductResponse.ReadAsAsync(); // Create order var createOrderResponse = await client.PostAsync("/orders", new CreateOrderRequest { Items = new[] { new OrderItemRequest { ProductId = product.Id, Quantity = 2 } }, ShippingAddress = new AddressDto { Street = "123 Main St", City = "Test City" } }); Assert.Equal(201, createOrderResponse.StatusCode); var order = await createOrderResponse.ReadAsAsync(); Assert.Equal(user.Id, order.UserId); Assert.Equal(199.98m * 1.08m, order.Total, 2); // 2 * 99.99 * 1.08 tax } [Fact] public async Task MigrationMiddleware_ShadowMode_ExecutesBothImplementations() { // Arrange var config = new MigrationConfig { Endpoints = new Dictionary { ["/users/**"] = MigrationConfig.EndpointMigrationState.Shadow } }; using var fixture = new MigrationTestFixture(config); await fixture.StartAsync(); // Act var response = await fixture.Client.GetAsync("/users/123"); // Assert Assert.Equal(200, response.StatusCode); Assert.True(fixture.LegacyWasCalled); Assert.True(fixture.StellaWasCalled); } [Fact] public async Task Scaffolding_GeneratesValidService() { // Arrange var options = new ServiceScaffoldOptions { ServiceName = "TestService", Namespace = "Test.Service", OutputPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()), Features = new List { "tests" }, Handlers = new List { new() { Name = "User", ResourceName = "users", Endpoints = new List { new() { Method = "GET", Path = "/users", Name = "ListUsers" }, new() { Method = "GET", Path = "/users/{id}", Name = "GetUser" } } } } }; var scaffold = new StellaScaffold(new ScribanTemplateEngine()); try { // Act await scaffold.GenerateServiceAsync(options); // Assert Assert.True(File.Exists(Path.Combine(options.OutputPath, "TestService.csproj"))); Assert.True(File.Exists(Path.Combine(options.OutputPath, "Program.cs"))); Assert.True(File.Exists(Path.Combine(options.OutputPath, "Handlers", "UserHandler.cs"))); // Verify generated code compiles (optional) var compilation = await CompileGeneratedCodeAsync(options.OutputPath); Assert.Empty(compilation.Errors); } finally { Directory.Delete(options.OutputPath, recursive: true); } } ``` ## Deliverables | Artifact | Path | |----------|------| | Reference Service | `src/Examples/StellaOps.Router.Examples/ReferenceService/` | | Migration Templates | `src/Examples/StellaOps.Router.Examples/MigrationTemplates/` | | Scaffolding Tool | `src/Examples/StellaOps.Router.Examples/Scaffolding/` | | Scriban Templates | `src/Examples/StellaOps.Router.Examples/Scaffolding/Templates/` | | Tests | `src/Examples/StellaOps.Router.Examples.Tests/` | ## Next Step [Step 28: Agent Process Guidelines →](28-Step.md)