Worker Author Guide
Authoring a new worker in the recommended shape: three files, two csprojs, one shared handler logic, and an IGatewayClient for backend calls. This guide walks through the pieces using a fictional EchoWorker (which has three commands: start, stop, echo) and a real reference (the WebSocketManagerController worker).
The recommended shape
src/
├── Virtufin.Worker.EchoWorker.Managed/ # managed csproj
│ ├── Virtufin.Worker.EchoWorker.csproj
│ ├── EchoWorker.cs # managed shim
│ ├── EchoWorkerLogic.cs # shared static logic
│ └── Command.cs # command enum
└── Virtufin.Worker.EchoWorker.Native/ # AOT csproj
├── Virtufin.Worker.EchoWorker.Native.csproj
└── EchoWorkerNative.cs # AOT shim
Three files of source. The shared logic is 99% of the code; the two shims are 30 and 50 lines respectively.
Step 1: the command enum
Command.cs is shared between both targets and defines the wire command names. Lowercase identifier names are the wire strings; the WM's CommandWorker<T>.HandleCommandAsync parses them via Enum.TryParse<T>(command, ignoreCase: true, out _).
namespace Virtufin.Worker.EchoWorker;
public enum Command
{
start,
stop,
echo,
}
Step 2: the shared handler logic
EchoWorkerLogic.cs contains the bulk of the worker code. It's a static class with one entry point: HandleCommandAsync(input, command, node, gateway). The handler dispatches on the command enum and calls the gateway through IGatewayClient — the only transport-aware piece in the file.
using System.Text.Json.Nodes;
using CloudNative.CloudEvents;
using Virtufin.Worker.DevKit;
namespace Virtufin.Worker.EchoWorker;
public static class EchoWorkerLogic
{
// Worker-wide state. For a single-instance worker (the WM's engine
// model), this is safe: only one CloudEvent is in flight at a
// time per worker.
private static volatile bool _echoing = false;
public static async Task<JsonObject?> HandleCommandAsync(
CloudEvent input, Command command, JsonNode node, IGatewayClient gateway)
{
return command switch
{
Command.echo => await HandleEchoAsync(input, node, gateway),
Command.start => await HandleStartAsync(input),
Command.stop => await HandleStopAsync(input),
_ => ErrorPayload(input, $"Unhandled command: {command}")
};
}
private static async Task<JsonObject?> HandleEchoAsync(
CloudEvent input, JsonNode node, IGatewayClient gateway)
{
if (!_echoing) {
return Response(input, "echo", success: false,
message: "echoing is stopped; send 'start' first");
}
var message = (string?)node["message"] ?? "";
return Response(input, "echo", success: true, echo: message);
}
private static Task<JsonObject?> HandleStartAsync(CloudEvent input) {
_echoing = true;
return Task.FromResult(Response(input, "start", success: true, echoing: true));
}
private static Task<JsonObject?> HandleStopAsync(CloudEvent input) {
_echoing = false;
return Task.FromResult(Response(input, "stop", success: true, echoing: false));
}
// Response shape: { command, success, ..., extras }. Single source of
// truth for the response envelope. Subclasses can override.
internal static JsonObject Response(CloudEvent input, string command, bool success, ...)
=> new JsonObject {
["command"] = command,
["success"] = success,
// ... per-handler extras
};
internal static JsonObject ErrorPayload(CloudEvent input, string message)
=> new JsonObject {
["command"] = "error",
["success"] = false,
["message"] = message
};
}
The handler body is byte-identical for both managed and AOT targets. The IGatewayClient is the only abstraction; the rest is plain C# + System.Text.Json.
Step 3: the managed shim
EchoWorker.cs (managed csproj only). Extends ApiCommandWorker<Command>. The base class provides Gateway (returns ManagedGatewayAdapter(Api.Gateway)) and HandleCommandAsync (the engine-implemented entry point).
using System.Text.Json.Nodes;
using CloudNative.CloudEvents;
using Virtufin.Worker.DevKit;
namespace Virtufin.Worker.EchoWorker;
public class EchoWorker : ApiCommandWorker<Command>
{
public EchoWorker()
: base(source: new Uri("urn:com.virtufin.worker.echoworker"),
responseEventType: "response.echoworker") { }
protected override async Task<JsonObject?> HandleCommandAsync(
CloudEvent input, Command command, JsonNode node)
=> await EchoWorkerLogic.HandleCommandAsync(input, command, node, Gateway);
}
Step 4: the AOT shim
EchoWorkerNative.cs (AOT csproj only). Extends CommandWorker<Command> directly (no ApiCommandWorker, which depends on the managed Virtufin.Api.Client). Overrides Gateway to return the AOT adapter. Exports the C-ABI Process and FreeResult functions via [UnmanagedCallersOnly].
using System.Runtime.InteropServices;
using System.Text.Json.Nodes;
using CloudNative.CloudEvents;
using Virtufin.Worker.DevKit;
namespace Virtufin.Worker.EchoWorker;
/// <summary>
/// AOT shim. <see cref="AotNative{T}"/> sets
/// <c>CurrentContext</c> before invoking the [UnmanagedCallersOnly]
/// entry points; AotGatewayAdapter pulls host/port from there.
/// </summary>
public class EchoWorkerNative : CommandWorker<Command>
{
public EchoWorkerNative()
: base(source: new Uri("urn:com.virtufin.worker.echoworker"),
responseEventType: "response.echoworker") { }
protected override IGatewayClient Gateway
=> new AotGatewayAdapter(AotNative<EchoWorkerNative>.CurrentContext!);
protected override async Task<JsonObject?> HandleCommandAsync(
CloudEvent input, Command command, JsonNode node)
=> await EchoWorkerLogic.HandleCommandAsync(input, command, node, Gateway);
[UnmanagedCallersOnly(EntryPoint = "Process")]
public static unsafe int Process(
IntPtr host, IntPtr inBuf, int inLen, IntPtr outBuf, IntPtr outLen)
=> AotNative<EchoWorkerNative>.ProcessStatic(
host, inBuf, inLen, (IntPtr*)outBuf, (int*)outLen);
[UnmanagedCallersOnly(EntryPoint = "FreeResult")]
public static void FreeResult(IntPtr result)
=> AotNative<EchoWorkerNative>.FreeResultStatic(result);
}
Step 5: the two csproj files
Managed csproj (Virtufin.Worker.EchoWorker.csproj)
Inherits the same shape as the WebSocketManagerController managed csproj: <PackageId>Virtufin.Worker.EchoWorker</PackageId>, <PackageReference Include="Virtufin.Worker.DevKit" />, <Compile Include="EchoWorkerLogic.cs" Link="Logic.cs"/>, PackageWorker MSBuild target.
AOT csproj (Virtufin.Worker.EchoWorker.Native.csproj)
The shared EchoWorkerLogic.cs is included via relative path: <Compile Include="..\Virtufin.Worker.EchoWorker.Managed\EchoWorkerLogic.cs" Link="Logic.cs"/>. The AOT-specific flags (<PublishAot>true</PublishAot>, <RuntimeIdentifiers>linux-x64;linux-arm64</RuntimeIdentifiers>, per-RID <ObjCopyName>) are all included.
The AssemblyName for both csprojs is the same string (EchoWorker) — the on-disk filename the WM matches against virtufinLibrary. The AOT publish output (EchoWorkerNative.so) is renamed to libEchoWorker.so at build time per NuGet convention.
Step 6: build and verify
# Managed-only build
python3 scripts/build_managed.py
python3 scripts/publish.py
# AOT build (requires Docker)
python3 scripts/build_aot.py
python3 scripts/publish.py
# Smoke test the AOT nupkg
python3 scripts/smoke_test_aot.py
See also
- Engines — DotNetDll vs NativeDll, and the IGatewayClient adapter pattern in detail
- Packaging Modes — the three packaging modes and which to pick
- Build Script — the build_aot.py + build_managed.py + build_both.sh reference