StellaOps Router Migration Guide
This guide describes how to migrate existing StellaOps.*.WebService projects to the new microservice pattern with the StellaOps Router.
Overview
The router provides a transport-agnostic communication layer between services, replacing direct HTTP calls with binary transports (Messaging over Valkey in the deployed estate, TLS, RabbitMQ; TCP and UDP are development-only). Benefits include:
- Performance: Binary framing vs HTTP overhead
- Streaming: First-class support for large payloads
- Cancellation: Propagated across service boundaries
- Claims: Authority-integrated authorization
- Health: Automatic heartbeat and failover
Historical rollout planning artifact (not current state):
- webservices-valkey-rollout-matrix.md - wave assignment and rollback switches recorded for the original gateway reverse-proxy service hosts.
Prerequisites
Before migrating, ensure:
- Router infrastructure is deployed (Gateway, transports)
- Authority is configured with endpoint claims
- Local development environment has router.yaml configured
Migration Strategies
Strategy A: In-Place Adaptation
Best for services that need to maintain HTTP compatibility during transition.
┌─────────────────────────────────────┐
│ StellaOps.*.WebService │
│ ┌─────────────────────────────────┐│
│ │ Existing HTTP Controllers ││◄── HTTP clients (legacy)
│ └─────────────────────────────────┘│
│ ┌─────────────────────────────────┐│
│ │ [StellaEndpoint] Handlers ││◄── Router (new)
│ └─────────────────────────────────┘│
│ ┌─────────────────────────────────┐│
│ │ Shared Domain Logic ││
│ └─────────────────────────────────┘│
└─────────────────────────────────────┘
Steps:
- Add
StellaOps.Microservicepackage reference - Create handler classes for each HTTP route
- Handlers call existing service layer
- Register with router alongside HTTP
- Test via router
- Shift traffic gradually
- Remove HTTP controllers when ready
Pros:
- Gradual migration
- No downtime
- Can roll back easily
Cons:
- Dual maintenance during transition
- May delay cleanup
Strategy B: Clean Split
Best for major refactoring or when HTTP compatibility is not needed.
┌─────────────────────────────────────┐
│ StellaOps.*.Domain │ ◄── Shared library
│ (extracted business logic) │
└─────────────────────────────────────┘
▲ ▲
│ │
┌─────────┴───────┐ ┌───────┴─────────┐
│ (Legacy) │ │ (New) │
│ *.WebService │ │ *.Microservice │
│ HTTP only │ │ Router only │
└─────────────────┘ └─────────────────┘
Steps:
- Extract domain logic to
.Domainlibrary - Create new
.Microserviceproject - Implement handlers using domain library
- Deploy alongside WebService
- Shift traffic to router
- Deprecate WebService
Pros:
- Clean architecture
- No legacy code in new project
- Clear separation of concerns
Cons:
- More upfront work
- Requires domain extraction
Strategy C: ASP.NET Endpoint Bridge (Recommended)
Best for services that want automatic Router registration from existing ASP.NET endpoints without code changes.
┌─────────────────────────────────────────────────────────────────┐
│ StellaOps.*.WebService │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ ASP.NET Endpoints (unchanged) ││
│ │ • Minimal APIs: app.MapGet("/api/...", handler) ││
│ │ • Controllers: [ApiController] with [HttpGet], etc. ││
│ │ • Route groups: app.MapGroup("/api").MapEndpoints() ││
│ └─────────────────────────────────────────────────────────────┘│
│ │ │
│ ▼ Auto-discovery │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ StellaOps.Microservice.AspNetCore ││
│ │ • Discovers all endpoints from EndpointDataSource ││
│ │ • Extracts authorization metadata automatically ││
│ │ • Registers with Router via HELLO ││
│ │ • Dispatches Router requests through ASP.NET pipeline ││
│ └─────────────────────────────────────────────────────────────┘│
│ │ │
│ ▼ Binary transport │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Router Gateway ││
│ │ • Routes external HTTP → internal microservice ││
│ │ • Aggregates OpenAPI ││
│ └─────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘
Steps:
- Add
StellaOps.Microservice.AspNetCorepackage reference - Configure bridge in
Program.cs:
using StellaOps.Microservice.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
// Add authorization (required for claim mapping)
builder.Services.AddAuthorization();
// Add the ASP.NET Endpoint Bridge
builder.Services.AddStellaRouterBridge(options =>
{
options.ServiceName = "my-service";
options.Version = "1.0.0";
options.Region = "us-east-1";
});
var app = builder.Build();
// Register ASP.NET endpoints as usual
app.MapGet("/api/health", () => "healthy");
app.MapGet("/api/items/{id}", (string id) => new { id, name = "Item " + id })
.RequireAuthorization("read-items");
app.MapPost("/api/items", (CreateItemRequest req) => new { id = Guid.NewGuid() })
.RequireAuthorization("write-items");
// Activate the bridge (discovers and registers endpoints)
app.UseStellaRouterBridge();
app.Run();
- (Optional) Add YAML overrides for security hardening:
# router.yaml
endpoints:
- path: "/api/admin/**"
requiringClaims:
- type: "Role"
value: "admin"
timeoutMs: 60000
- Test via Gateway
- Deploy to production
Pros:
- Zero code changes to existing endpoints
- Automatic authorization metadata extraction
- Full ASP.NET pipeline execution (filters, binding, validation)
- Deterministic endpoint ordering
- YAML overrides for security hardening without code changes
- Supports both minimal APIs and controllers
Cons:
- Slightly more runtime overhead (HttpContext synthesis)
- Some ASP.NET features not supported (see limitations below)
Authorization Mapping
The bridge automatically extracts authorization requirements:
| ASP.NET Source | Router Mapping |
|---|---|
[Authorize(Policy = "policy-name")] | Resolved via IAuthorizationPolicyProvider → RequiringClaims |
[Authorize(Roles = "admin,user")] | ClaimRequirement(Role, admin), ClaimRequirement(Role, user) |
.RequireAuthorization("policy") | Resolved via policy provider |
.RequireAuthorization(new AuthorizeAttribute { Roles = "..." }) | Direct role mapping |
[AllowAnonymous] | Empty RequiringClaims (public endpoint) |
Authorization Mapping Strategies
Configure how the bridge handles authorization:
builder.Services.AddStellaRouterBridge(options =>
{
options.ServiceName = "my-service";
options.Version = "1.0.0";
options.Region = "us-east-1";
// Strategy options:
// - YamlOnly: YAML claims replace code claims entirely
// - AspNetMetadataOnly: Code claims only, ignore YAML (default)
// - Hybrid: Merge code + YAML claims by type
options.AuthorizationMappingStrategy = AuthorizationMappingStrategy.Hybrid;
// Behavior when endpoint has no authorization:
// - RequireExplicit: Fail validation (secure default)
// - AllowAuthenticated: Require authentication but no specific claims
// - WarnAndAllow: Log warning, allow request (dev only)
options.MissingAuthorizationBehavior = MissingAuthorizationBehavior.RequireExplicit;
});
Route Filtering
Control which endpoints are bridged:
builder.Services.AddStellaRouterBridge(options =>
{
options.ServiceName = "my-service";
options.Version = "1.0.0";
options.Region = "us-east-1";
// Include only /api/* endpoints
options.IncludePathPatterns = ["/api/**"];
// Exclude internal endpoints
options.ExcludePathPatterns = ["/internal/**", "/metrics", "/health/**"];
});
Limitations
The ASP.NET Endpoint Bridge does not support:
| Feature | Alternative |
|---|---|
| SignalR/WebSocket | Use direct HTTP for real-time features |
| gRPC endpoints | Use separate gRPC channel |
| Streaming request bodies | Use IRawStellaEndpoint for streaming |
Custom route constraints ({id:guid}) | Constraints execute at dispatch, but not in discovery |
| Header/query-based API versioning | Use path-based versioning (/api/v1/...) |
Controller to Handler Mapping
Before (ASP.NET Controller)
[ApiController]
[Route("api/invoices")]
public class InvoicesController : ControllerBase
{
private readonly IInvoiceService _service;
[HttpPost]
[Authorize(Roles = "billing-admin")]
public async Task<IActionResult> Create(
[FromBody] CreateInvoiceRequest request,
CancellationToken ct)
{
var invoice = await _service.CreateAsync(request);
return Ok(new { invoice.Id });
}
[HttpGet("{id}")]
public async Task<IActionResult> Get(string id)
{
var invoice = await _service.GetAsync(id);
if (invoice == null) return NotFound();
return Ok(invoice);
}
}
After (Microservice Handler)
// Handler for POST /api/invoices
[StellaEndpoint("POST", "/api/invoices", RequiredClaims = ["invoices:write"])]
public sealed class CreateInvoiceEndpoint : IStellaEndpoint<CreateInvoiceRequest, CreateInvoiceResponse>
{
private readonly IInvoiceService _service;
public CreateInvoiceEndpoint(IInvoiceService service) => _service = service;
public async Task<CreateInvoiceResponse> HandleAsync(
CreateInvoiceRequest request,
CancellationToken ct)
{
var invoice = await _service.CreateAsync(request, ct);
return new CreateInvoiceResponse { InvoiceId = invoice.Id };
}
}
// Handler for GET /api/invoices/{id}
[StellaEndpoint("GET", "/api/invoices/{id}", RequiredClaims = ["invoices:read"])]
public sealed class GetInvoiceEndpoint : IStellaEndpoint<GetInvoiceRequest, GetInvoiceResponse>
{
private readonly IInvoiceService _service;
public GetInvoiceEndpoint(IInvoiceService service) => _service = service;
public async Task<GetInvoiceResponse> HandleAsync(
GetInvoiceRequest request,
CancellationToken ct)
{
var invoice = await _service.GetAsync(request.Id, ct);
return new GetInvoiceResponse
{
InvoiceId = invoice?.Id,
Found = invoice != null
};
}
}
CancellationToken Wiring
This is the #1 source of migration bugs. Every async operation must receive and respect the cancellation token.
Checklist
For each migrated handler, verify:
- [ ] Handler accepts CancellationToken parameter (automatic with IStellaEndpoint)
- [ ] Token passed to all database calls
- [ ] Token passed to all HTTP client calls
- [ ] Token passed to all file I/O operations
- [ ] Long-running loops check
ct.IsCancellationRequested - [ ] Token passed to
Task.Delay,WaitAsync, etc.
Example: Before (missing tokens)
public async Task<Invoice> CreateAsync(CreateInvoiceRequest request)
{
var invoice = new Invoice(request);
await _db.Invoices.AddAsync(invoice); // Missing token!
await _db.SaveChangesAsync(); // Missing token!
await _notifier.SendAsync(invoice); // Missing token!
return invoice;
}
Example: After (proper wiring)
public async Task<Invoice> CreateAsync(CreateInvoiceRequest request, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
var invoice = new Invoice(request);
await _db.Invoices.AddAsync(invoice, ct);
await _db.SaveChangesAsync(ct);
await _notifier.SendAsync(invoice, ct);
return invoice;
}
Streaming Migration
File Upload: Before
[HttpPost("upload")]
public async Task<IActionResult> Upload(IFormFile file)
{
using var stream = file.OpenReadStream();
await _storage.SaveAsync(stream);
return Ok();
}
File Upload: After
[StellaEndpoint("POST", "/upload", SupportsStreaming = true)]
public sealed class UploadEndpoint : IRawStellaEndpoint
{
private readonly IStorageService _storage;
public UploadEndpoint(IStorageService storage) => _storage = storage;
public async Task<RawResponse> HandleAsync(RawRequestContext ctx, CancellationToken ct)
{
// ctx.Body is already a stream - no buffering needed
var path = await _storage.SaveAsync(ctx.Body, ct);
return RawResponse.Ok($"{{\"path\":\"{path}\"}}");
}
}
File Download: Before
[HttpGet("download/{id}")]
public async Task<IActionResult> Download(string id)
{
var stream = await _storage.GetAsync(id);
return File(stream, "application/octet-stream");
}
File Download: After
[StellaEndpoint("GET", "/download/{id}", SupportsStreaming = true)]
public sealed class DownloadEndpoint : IRawStellaEndpoint
{
private readonly IStorageService _storage;
public DownloadEndpoint(IStorageService storage) => _storage = storage;
public async Task<RawResponse> HandleAsync(RawRequestContext ctx, CancellationToken ct)
{
var id = ctx.PathParameters["id"];
var stream = await _storage.GetAsync(id, ct);
return RawResponse.Stream(stream, "application/octet-stream");
}
}
Authorization Migration
Before: [Authorize] Attribute
[Authorize(Roles = "admin,billing-manager")]
public async Task<IActionResult> Delete(string id) { ... }
After: RequiredClaims
[StellaEndpoint("DELETE", "/invoices/{id}", RequiredClaims = ["invoices:delete"])]
public sealed class DeleteInvoiceEndpoint : IStellaEndpoint<...> { ... }
Claims are configured in Authority and enforced by the Gateway’s AuthorizationMiddleware.
Migration Checklist Template
Use this checklist for each service migration:
# Migration Checklist: [ServiceName]
## Inventory
- [ ] List all HTTP routes (Method + Path)
- [ ] Identify streaming endpoints
- [ ] Identify authorization requirements
- [ ] Document external dependencies
## Preparation
- [ ] Add StellaOps.Microservice package
- [ ] Add StellaOps.Router.Transport.* package(s)
- [ ] Configure router connection in Program.cs
- [ ] Set up local gateway for testing
## Per-Route Migration
For each route:
- [ ] Create [StellaEndpoint] handler class
- [ ] Define request/response record types
- [ ] Map path parameters
- [ ] Wire CancellationToken throughout
- [ ] Convert to IRawStellaEndpoint if streaming
- [ ] Add RequiredClaims
- [ ] Write unit tests
- [ ] Write integration tests
## Cutover
- [ ] Deploy alongside existing WebService
- [ ] Verify via router routing
- [ ] Shift percentage of traffic
- [ ] Monitor for errors
- [ ] Full cutover
- [ ] Remove WebService HTTP listeners
## Cleanup
- [ ] Remove unused controller code
- [ ] Remove HTTP pipeline configuration
- [ ] Update OpenAPI documentation
- [ ] Update client SDKs
Service Inventory
The services already registered with the Router are listed in webservice-integration-guide.md, WebServices Integration Status. The per-service migration inventory that stood here named hosts that have since been retired (Concelier, Excititor, Notifier, TimelineIndexer, Findings Ledger WebService, IssuerDirectory, PacksRegistry, TaskRunner, Scheduler and RiskEngine WebServices) and was removed as stale.
Testing During Migration
Unit Tests
Test handlers in isolation using mocked dependencies:
[Fact]
public async Task CreateInvoice_ValidRequest_ReturnsInvoiceId()
{
// Arrange
var mockService = new Mock<IInvoiceService>();
mockService.Setup(s => s.CreateAsync(It.IsAny<CreateInvoiceRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new Invoice { Id = "INV-123" });
var endpoint = new CreateInvoiceEndpoint(mockService.Object);
// Act
var response = await endpoint.HandleAsync(
new CreateInvoiceRequest { Amount = 100 },
CancellationToken.None);
// Assert
response.InvoiceId.Should().Be("INV-123");
}
Integration Tests
Use WebApplicationFactory for the Gateway and actual microservice instances:
public sealed class InvoiceTests : IClassFixture<GatewayFixture>
{
private readonly GatewayFixture _fixture;
[Fact]
public async Task CreateAndGetInvoice_WorksEndToEnd()
{
var createResponse = await _fixture.Client.PostAsJsonAsync("/api/invoices",
new { Amount = 100 });
createResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var created = await createResponse.Content.ReadFromJsonAsync<CreateInvoiceResponse>();
var getResponse = await _fixture.Client.GetAsync($"/api/invoices/{created.InvoiceId}");
getResponse.StatusCode.Should().Be(HttpStatusCode.OK);
}
}
Common Migration Issues
1. Missing CancellationToken Propagation
Symptom: Requests continue processing after client disconnects.
Fix: Pass CancellationToken to all async operations.
2. IFormFile Not Available
Symptom: Compilation error on IFormFile parameter.
Fix: Convert to IRawStellaEndpoint for streaming.
3. HttpContext Not Available
Symptom: Code references HttpContext for headers, claims.
Fix: Use RawRequestContext for raw endpoints, or inject claims via Authority.
4. Return Type Mismatch
Symptom: Handler returns IActionResult.
Fix: Define proper response record type, return that instead.
5. Route Parameter Not Extracted
Symptom: Path parameters like {id} not populated.
Fix: For IStellaEndpoint, add property to request type. For IRawStellaEndpoint, use ctx.PathParameters["id"].
Next Steps
- Choose a low-risk service for pilot migration (Scheduler recommended)
- Follow the Migration Checklist
- Document lessons learned
- Proceed with higher-priority services
- Eventually merge all to use router exclusively
See Also
- Router Architecture - System specification
- Schema Validation - JSON Schema validation
- OpenAPI Aggregation - OpenAPI document generation
