Files
git.stella-ops.org/docs/router/27-Step.md
master 75f6942769
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
Add integration tests for migration categories and execution
- 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.
2025-12-04 19:10:54 +02:00

48 KiB

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

// ReferenceService/Program.cs
using StellaOps.Router.Microservice;
using StellaOps.Router.Examples.ReferenceService;

var builder = StellaMicroservice.CreateBuilder(args);

// Configure all features
builder.Services.AddSingleton<IUserRepository, InMemoryUserRepository>();
builder.Services.AddSingleton<IProductCatalog, InMemoryProductCatalog>();
builder.Services.AddSingleton<IOrderService, OrderService>();
builder.Services.AddSingleton<INotificationService, NotificationService>();

// Add handlers
builder.AddHandler<UserHandler>();
builder.AddHandler<ProductHandler>();
builder.AddHandler<OrderHandler>();
builder.AddHandler<AdminHandler>();
builder.AddHandler<WebhookHandler>();

// 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<string, object>
    {
        ["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

// 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<UserHandler> _logger;

    public UserHandler(IUserRepository repository, ILogger<UserHandler> logger)
    {
        _repository = repository;
        _logger = logger;
    }

    [StellaRoute("GET", "/users")]
    [StellaAuth(RequiredClaims = ["user:list"])]
    [StellaRateLimit(RequestsPerMinute = 100)]
    public async Task<StellaResponse> 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<StellaResponse> 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<Guid>("user_id") == id;

        return Ok(user.ToDto(includePrivate: canViewPrivate));
    }

    [StellaRoute("POST", "/users")]
    [StellaAuth(RequiredClaims = ["user:create"])]
    [StellaRateLimit(RequestsPerMinute = 10)]
    public async Task<StellaResponse> 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<Guid>("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<StellaResponse> 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<Guid>("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<StellaResponse> 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<Guid>("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<ValidationError>();

        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

// 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<ProductHandler> _logger;

    public ProductHandler(IProductCatalog catalog, ILogger<ProductHandler> logger)
    {
        _catalog = catalog;
        _logger = logger;
    }

    [StellaRoute("GET", "/products")]
    [StellaRateLimit(RequestsPerMinute = 200)]
    [StellaCache(Duration = 300, VaryByQuery = ["category", "brand"])]
    public async Task<StellaResponse> 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<StellaResponse> 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<StellaResponse> 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<StellaResponse> 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

// 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<OrderHandler> _logger;

    public OrderHandler(
        IOrderService orderService,
        IProductCatalog catalog,
        INotificationService notifications,
        ILogger<OrderHandler> 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<StellaResponse> CreateOrder(
        StellaRequestContext context,
        [FromBody] CreateOrderRequest request)
    {
        var userId = context.GetClaim<Guid>("user_id");

        // Validate products and calculate total
        var lineItems = new List<OrderLineItem>();
        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<StellaResponse> ListOrders(
        StellaRequestContext context,
        [FromQuery("status")] string? status = null,
        [FromQuery("page")] int page = 1,
        [FromQuery("size")] int pageSize = 20)
    {
        var userId = context.GetClaim<Guid>("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<OrderStatus>(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<StellaResponse> 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<Guid>("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<StellaResponse> 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<Guid>("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

// 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<StellaResponse> 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<StellaResponse> 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<Guid>("user_id");

        // Generate impersonation token (normally would call Authority)
        var impersonationClaims = new Dictionary<string, object>
        {
            ["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<StellaResponse> 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<AuditEntry>(),
            Page = page,
            PageSize = pageSize,
            TotalCount = 0
        });
    }
}

Migration Templates

Basic Migration Template

// 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<IActionResult> 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<MigratedUserHandler>();
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<StellaResponse> GetUser(
        StellaRequestContext context,
        [FromPath("id")] Guid id)
    {
        var user = await _repository.GetByIdAsync(id);
        return user == null ? NotFound() : Ok(user);
    }
}

Dual Mode Migration Template

// 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<NewFeatureHandler>();
    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<VersionRoutingMiddleware>();

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

// MigrationTemplates/GradualMigration/MigrationConfig.cs
// Configure which endpoints use old vs new implementation

namespace StellaOps.Router.Migration;

public class MigrationConfig
{
    public Dictionary<string, EndpointMigrationState> 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<MigrationMiddleware> _logger;

    public MigrationMiddleware(
        RequestDelegate next,
        MigrationConfig config,
        IStellaDispatcher stellaDispatcher,
        ILogger<MigrationMiddleware> 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<MigrationResult> 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<MigrationResult> 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<StellaRequest> 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<string, string> Headers { get; set; } = new();
}

Code Scaffolding Tool

CLI Scaffolding Generator

// 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<string> Features { get; set; } = new();
    public List<HandlerDefinition> Handlers { get; set; } = new();
    public List<ModelDefinition> Models { get; set; } = new();
}

public class HandlerDefinition
{
    public string Name { get; set; } = "";
    public string ResourceName { get; set; } = "";
    public List<EndpointDefinition> 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<string> RequiredClaims { get; set; } = new();
    public int? RateLimit { get; set; }
}

public class ModelDefinition
{
    public string Name { get; set; } = "";
    public List<PropertyDefinition> 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<StellaResponse> {{ endpoint.name }}(StellaRequestContext context)
    {
        _logger.LogInformation("{{ endpoint.name }} called");

        // TODO: Implement {{ endpoint.name }}
        throw new NotImplementedException();
    }

{{~ end ~}}
}

CLI Commands

# 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

# 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

[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<UserDto>();

    // 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<ProductDto>();

    // 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<OrderDto>();

    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<string, MigrationConfig.EndpointMigrationState>
        {
            ["/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<string> { "tests" },
        Handlers = new List<HandlerDefinition>
        {
            new()
            {
                Name = "User",
                ResourceName = "users",
                Endpoints = new List<EndpointDefinition>
                {
                    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 →