15 KiB
For this step, the goal is: a microservice that can:
- Start up with
AddStellaMicroservice(...) - Discover its endpoints from attributes
- Connect to the router (via InMemory transport)
- Send a HELLO with identity + endpoints
- Receive a REQUEST and return a RESPONSE
No streaming, no cancellation, no heartbeat yet. Pure minimal handshake & dispatch.
0. Preconditions
Before your agents start this step, you should have:
-
StellaOps.Router.Commoncontracts in place (enums,EndpointDescriptor,ConnectionState,Frame, etc.). -
The solution skeleton and project references configured.
-
A stub InMemory transport “router harness” (at least a place to park the future InMemory transport). Even if it’s not fully implemented, assume it will expose:
- A way for a microservice to “connect” and register itself.
- A way to deliver frames from router to microservice and back.
If InMemory isn’t built yet, the microservice code should be written against abstractions so you can plug it in later.
1. Define microservice public surface (SDK contract)
Project: __Libraries/StellaOps.Microservice
Owner: microservice SDK agent
Purpose: give product teams a stable way to define services and endpoints without caring about transports.
1.1 Options
Make sure StellaMicroserviceOptions matches the spec:
public sealed class StellaMicroserviceOptions
{
public string ServiceName { get; set; } = string.Empty;
public string Version { get; set; } = string.Empty;
public string Region { get; set; } = string.Empty;
public string InstanceId { get; set; } = string.Empty;
public IList<RouterEndpointConfig> Routers { get; set; } = new List<RouterEndpointConfig>();
public string? ConfigFilePath { get; set; }
}
public sealed class RouterEndpointConfig
{
public string Host { get; set; } = string.Empty;
public int Port { get; set; }
public TransportType TransportType { get; set; }
}
Routers is mandatory: without at least one router configured, the SDK should refuse to start later (that policy can be enforced in the handshake stage).
1.2 Public endpoint abstractions
Define:
- Attribute for endpoint identity:
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class StellaEndpointAttribute : Attribute
{
public string Method { get; }
public string Path { get; }
public StellaEndpointAttribute(string method, string path)
{
Method = method;
Path = path;
}
}
- Raw handler:
public sealed class RawRequestContext
{
public string Method { get; init; } = string.Empty;
public string Path { get; init; } = string.Empty;
public IReadOnlyDictionary<string,string> Headers { get; init; } =
new Dictionary<string,string>();
public Stream Body { get; init; } = Stream.Null;
public CancellationToken CancellationToken { get; init; }
}
public sealed class RawResponse
{
public int StatusCode { get; set; } = 200;
public IDictionary<string,string> Headers { get; } =
new Dictionary<string,string>();
public Func<Stream,Task>? WriteBodyAsync { get; set; } // may be null
}
public interface IRawStellaEndpoint
{
Task<RawResponse> HandleAsync(RawRequestContext ctx);
}
- Typed convenience interfaces (used later, but define now):
public interface IStellaEndpoint<TRequest,TResponse>
{
Task<TResponse> HandleAsync(TRequest request, CancellationToken ct);
}
public interface IStellaEndpoint<TResponse>
{
Task<TResponse> HandleAsync(CancellationToken ct);
}
At this step, you don’t need to implement adapters yet, but the signatures must be fixed.
1.3 Registration extension
Extend AddStellaMicroservice to wire options + a few internal services:
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddStellaMicroservice(
this IServiceCollection services,
Action<StellaMicroserviceOptions> configure)
{
services.Configure(configure);
services.AddSingleton<IEndpointCatalog, EndpointCatalog>(); // to be implemented
services.AddSingleton<IEndpointDispatcher, EndpointDispatcher>(); // to be implemented
services.AddHostedService<MicroserviceBootstrapHostedService>(); // handshake loop
return services;
}
}
This still compiles with empty implementations; you fill them in next steps.
2. Endpoint discovery (reflection only for now)
Project: StellaOps.Microservice
Owner: SDK agent
Goal: given the entry assembly, build:
- A list of
EndpointDescriptorobjects (from Common). - A mapping
(Method, Path) -> handler typeused for dispatch.
2.1 Internal types
Define an internal representation:
internal sealed class EndpointRegistration
{
public EndpointDescriptor Descriptor { get; init; } = default!;
public Type HandlerType { get; init; } = default!;
}
Define an interface for discovery:
internal interface IEndpointDiscovery
{
IReadOnlyList<EndpointRegistration> DiscoverEndpoints(StellaMicroserviceOptions options);
}
2.2 Implement reflection-based discovery
Create ReflectionEndpointDiscovery:
-
Scan the entry assembly (and optionally referenced assemblies) for classes that:
-
Have
StellaEndpointAttribute. -
Implement either:
IRawStellaEndpoint, orIStellaEndpoint<,>, orIStellaEndpoint<>.
-
-
For each
[StellaEndpoint]usage:-
Create
EndpointDescriptorwith:ServiceName=options.ServiceName.Version=options.Version.Method,Pathfrom attribute.DefaultTimeout= some sensible default (e.g.TimeSpan.FromSeconds(30); refine later).SupportsStreaming=false(for now).RequiringClaims= empty array (for now).
-
Create
EndpointRegistrationwithDescriptor+HandlerType.
-
-
Return the list.
Wire it into DI:
services.AddSingleton<IEndpointDiscovery, ReflectionEndpointDiscovery>();
3. Endpoint catalog & dispatcher (microservice internal)
Project: StellaOps.Microservice
Owner: SDK agent
Goal: presence of:
- A catalog holding endpoints and descriptors.
- A dispatcher that takes frames and calls handlers.
3.1 Endpoint catalog
Define:
internal interface IEndpointCatalog
{
IReadOnlyList<EndpointDescriptor> Descriptors { get; }
bool TryGetHandler(string method, string path, out EndpointRegistration endpoint);
}
internal sealed class EndpointCatalog : IEndpointCatalog
{
private readonly Dictionary<(string Method, string Path), EndpointRegistration> _map;
public IReadOnlyList<EndpointDescriptor> Descriptors { get; }
public EndpointCatalog(IEndpointDiscovery discovery,
IOptions<StellaMicroserviceOptions> optionsAccessor)
{
var options = optionsAccessor.Value;
var registrations = discovery.DiscoverEndpoints(options);
_map = registrations.ToDictionary(
r => (r.Descriptor.Method, r.Descriptor.Path),
r => r,
StringComparer.OrdinalIgnoreCase);
Descriptors = registrations.Select(r => r.Descriptor).ToArray();
}
public bool TryGetHandler(string method, string path, out EndpointRegistration endpoint) =>
_map.TryGetValue((method, path), out endpoint!);
}
You can refine path normalization later; for now, keep it simple.
3.2 Endpoint dispatcher
Define:
internal interface IEndpointDispatcher
{
Task<Frame> HandleRequestAsync(Frame requestFrame, CancellationToken ct);
}
Implement EndpointDispatcher with minimal behavior:
-
Decode
requestFrame.Payloadinto a small DTO carrying:- Method
- Path
- Headers (if you already have a format; if not, assume no headers in v0)
- Body bytes
For this step, you can stub decoding as:
- Payload = raw body bytes.
- Method/Path are carried separately in frame header or in a simple DTO; decide a minimal interim format and write it down.
-
Use
IEndpointCatalog.TryGetHandler(method, path, ...):-
If not found:
- Build a
RawResponsewith status 404 and empty body.
- Build a
-
-
If handler implements
IRawStellaEndpoint:-
Instantiate via DI (
IServiceProvider.GetRequiredService(handlerType)). -
Build
RawRequestContextwith:- Method, Path, Headers, Body (
new MemoryStream(bodyBytes)for now). CancellationToken=ct.
- Method, Path, Headers, Body (
-
Call
HandleAsync. -
Convert
RawResponseinto a response frame payload.
-
-
If handler implements
IStellaEndpoint<,>(typed):- For now, you can skip typed handling or wire a very simple JSON-based adapter if you want to unlock it early. The focus in this step is the raw path; typed adapters can come in the next iteration.
Return a Frame with:
Type = FrameType.ResponseCorrelationId=requestFrame.CorrelationIdPayload= encoded response (status + body bytes).
No streaming, no cancellation logic beyond passing ct through — router won’t cancel yet.
4. Minimal handshake hosted service (using InMemory)
Project: StellaOps.Microservice
Owner: SDK agent
This is where the microservice actually “talks” to the router.
4.1 Define a microservice connection abstraction
Your SDK should not depend directly on InMemory; define an internal abstraction:
internal interface IMicroserviceConnection
{
Task StartAsync(CancellationToken ct);
Task StopAsync(CancellationToken ct);
}
The implementation for this step will target the InMemory transport; later you can add TCP/TLS/RabbitMQ versions.
4.2 Implement InMemory microservice connection
Assuming you have or will have an IInMemoryRouter (or similar) dev harness, implement:
internal sealed class InMemoryMicroserviceConnection : IMicroserviceConnection
{
private readonly IEndpointCatalog _catalog;
private readonly IEndpointDispatcher _dispatcher;
private readonly IOptions<StellaMicroserviceOptions> _options;
private readonly IInMemoryRouterClient _routerClient; // dev-only abstraction
public InMemoryMicroserviceConnection(
IEndpointCatalog catalog,
IEndpointDispatcher dispatcher,
IOptions<StellaMicroserviceOptions> options,
IInMemoryRouterClient routerClient)
{
_catalog = catalog;
_dispatcher = dispatcher;
_options = options;
_routerClient = routerClient;
}
public async Task StartAsync(CancellationToken ct)
{
var opts = _options.Value;
// Build HELLO payload from options + catalog.Descriptors
var helloPayload = BuildHelloPayload(opts, _catalog.Descriptors);
await _routerClient.ConnectAsync(opts, ct);
await _routerClient.SendHelloAsync(helloPayload, ct);
// Start background receive loop
_ = Task.Run(() => ReceiveLoopAsync(ct), ct);
}
public Task StopAsync(CancellationToken ct)
{
// For now: ask routerClient to disconnect; finer handling later
return _routerClient.DisconnectAsync(ct);
}
private async Task ReceiveLoopAsync(CancellationToken ct)
{
await foreach (var frame in _routerClient.GetIncomingFramesAsync(ct))
{
if (frame.Type == FrameType.Request)
{
var response = await _dispatcher.HandleRequestAsync(frame, ct);
await _routerClient.SendFrameAsync(response, ct);
}
else
{
// Ignore other frame types in this minimal step
}
}
}
}
IInMemoryRouterClient is whatever dev harness you build for the in-memory transport; the exact shape is not important for this step’s planning, only that it provides:
ConnectAsyncSendHelloAsyncGetIncomingFramesAsync(async stream of frames)SendFrameAsyncfor responsesDisconnectAsync
4.3 Hosted service to bootstrap the connection
Implement MicroserviceBootstrapHostedService:
internal sealed class MicroserviceBootstrapHostedService : IHostedService
{
private readonly IMicroserviceConnection _connection;
public MicroserviceBootstrapHostedService(IMicroserviceConnection connection)
{
_connection = connection;
}
public Task StartAsync(CancellationToken cancellationToken) =>
_connection.StartAsync(cancellationToken);
public Task StopAsync(CancellationToken cancellationToken) =>
_connection.StopAsync(cancellationToken);
}
Wire IMicroserviceConnection to InMemoryMicroserviceConnection in DI for now:
services.AddSingleton<IMicroserviceConnection, InMemoryMicroserviceConnection>();
In a later phase, you’ll swap this to transport-specific connectors.
5. End-to-end smoke test (InMemory only)
Project: StellaOps.Microservice.Tests + a minimal InMemory router test harness
Owner: test agent
Goal: prove that minimal handshake & dispatch works in memory.
-
Build a trivial test microservice:
-
Define a handler:
[StellaEndpoint("GET", "/ping")] public sealed class PingEndpoint : IRawStellaEndpoint { public Task<RawResponse> HandleAsync(RawRequestContext ctx) { var resp = new RawResponse { StatusCode = 200 }; resp.Headers["Content-Type"] = "text/plain"; resp.WriteBodyAsync = stream => stream.WriteAsync( Encoding.UTF8.GetBytes("pong")); return Task.FromResult(resp); } }
-
-
Test harness:
-
Spin up:
-
An instance of the microservice host (generic HostBuilder).
-
An in-memory “router” that:
- Accepts HELLO from the microservice.
- Sends a single REQUEST frame for
GET /ping. - Receives the RESPONSE frame.
-
-
-
Assert:
- The HELLO includes the
/pingendpoint. - The REQUEST is dispatched to
PingEndpoint. - The RESPONSE has status 200 and body “pong”.
- The HELLO includes the
This verifies that:
AddStellaMicroservicewires discovery, catalog, dispatcher, bootstrap.- The microservice sends HELLO on connect.
- The microservice can handle at least one request via InMemory.
6. Done criteria for “minimal handshake & dispatch”
You can consider this step complete when:
-
StellaOps.Microserviceexposes:- Options.
- Attribute & handler interfaces (raw + typed).
AddStellaMicroserviceregistering discovery, catalog, dispatcher, and hosted service.
-
The microservice can:
- Discover endpoints via reflection.
- Build a
HELLOpayload and send it over InMemory on startup. - Receive a
REQUESTframe over InMemory. - Dispatch that request to the correct handler.
- Return a
RESPONSEframe.
Not yet required in this step:
- Streaming bodies.
- Heartbeats or health evaluation.
- Cancellation via CANCEL frames.
- Authority overrides for requiringClaims.
Those come in subsequent phases; right now you just want a working minimal vertical slice: InMemory microservice that says “HELLO” and responds to one simple request.