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.
48 KiB
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
- Create a fully-functional reference microservice with all features
- Provide migration skeleton for existing ASP.NET Core services
- Document step-by-step migration patterns
- Provide code scaffolding tools for new services
- 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/ |