Skip to content

DotNet DLL Workers

The DotNetDllEngine (in-process, default as of LIBRARY_VERSION 0.0.59) loads pre-compiled .NET assemblies into an isolated AssemblyLoadContext inside the AOT-compiled WorkManager. Workers must implement the IWorker interface provided by the Virtufin.Worker.DevKit NuGet package.

Scope: Virtufin.Worker.DevKit is specifically for the DotNet DLL engine. It is not used by the PythonEngine, CSharpSourceEngine, or any other engine.

See In-Process DotNet DLL Workers for the architecture and trade-offs.

See Worker DevKit for the full hierarchy and codec-driven API contract.

Quick Start

1. Create a Worker Project

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Library</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <AssemblyName>MyWorker</AssemblyName>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="CloudNative.CloudEvents" Version="2.8.0" />
    <PackageReference Include="Virtufin.Worker.DevKit" Version="*" />
  </ItemGroup>
</Project>

2. Implement the Worker

CommandWorker<T> (JSON convenience over CommandWorker<TInput, TOutput, TCommand>) parses the CloudEvent data as JSON, extracts the "command" field, and dispatches to HandleCommandAsync. The handler returns a typed payload (JsonObject); the base class wraps it in the CloudEvent envelope with Id, Type, Source, correlationid, and time:

using System.Text.Json.Nodes;
using CloudNative.CloudEvents;
using Virtufin.Worker.DevKit;

public class MyWorker : CommandWorker<MyCommand>
{
    public MyWorker() : base(new Uri("urn:my-worker"), "my.response") { }

    protected override Task<JsonObject?> HandleCommandAsync(
        CloudEvent input, MyCommand command, JsonNode node)
        => command switch
        {
            MyCommand.hello => Task.FromResult<JsonObject?>(
                Response(input, command.ToString(), true, message: "world")),
            _ => Task.FromResult<JsonObject?>(null),
        };
}

public enum MyCommand { hello, goodbye }

Returning null from the handler signals "no reply"; returning an error payload uses ErrorPayload(message). The base class catches handler exceptions and wraps them as an error envelope.

Option B: Extend ApiWorker (no commands)

For workers that don't use a command field — extend WorkerBase<JsonNode, JsonObject> via the ApiWorker JSON convenience and override HandleAsync(CloudEvent, JsonNode):

using System.Text.Json.Nodes;
using CloudNative.CloudEvents;
using Virtufin.Worker.DevKit;

public class MyWorker : ApiWorker
{
    public MyWorker() : base(new Uri("urn:my-worker"), "my.response") { }

    protected override Task<JsonObject?> HandleAsync(
        CloudEvent input, JsonNode node)
    {
        var payload = node.ToJsonString();
        return Task.FromResult<JsonObject?>(
            Response(input, "process", true, message: $"Processed: {payload}"));
    }
}

Option C: Implement IWorker Directly

For full control, implement IWorker and skip the DevKit hierarchy. You build the CloudEvent envelope yourself:

using CloudNative.CloudEvents;
using Virtufin.Worker.DevKit;

public class MinimalWorker : IWorker
{
    public Task<CloudEvent?> ProcessAsync(CloudEvent input)
    {
        return Task.FromResult<CloudEvent?>(new CloudEvent
        {
            Type = "my.response",
            Source = new Uri("urn:my-worker"),
            DataContentType = "application/json",
            Data = "{\"status\":\"ok\"}"
        });
    }
}

3. Build and Package

The worker is shipped as a standard NuGet .nupkg so the engine can read its assembly list, target framework, and metadata via NuGet.Packaging. Use dotnet pack with NuspecProperties to inject the (optional) virtufin* extensions:

dotnet pack -c Release \
  -o . \
  -p:PackageId=Virtufin.Worker.MyWorker \
  -p:PackageVersion=0.0.1 \
  -p:NuspecProperties="virtufinAbiVersion=1;virtufinEntryPoint=Process;virtufinFreeResult=FreeResult"

The <id>.dll (your worker assembly) is placed under lib/<tfm>/ by dotnet pack (e.g. lib/net10.0/Virtufin.Worker.MyWorker.dll). The DotNetDll engine host picks the entry whose TFM best matches its own runtime, falling back to the first lib/ group if none match.

You can also pack manually if you need finer control — just produce the same <id>.nuspec + lib/<tfm>/<id>.dll layout and zip it into a .nupkg.

Important: Do not include CloudNative.CloudEvents.dll in the package to avoid type identity conflicts with the WorkManager's own CloudEvent type. The Virtufin.Worker.DevKit.dll is automatically resolved by the engine host and does not need to be in the package.

4. Deploy to WorkManager

WORKER_ID=$(curl -s -X POST "http://localhost:5001/workers" \
  -H "Content-Type: application/json" \
  -d '{
    "mimeType": "application/x-dotnet-dll",
    "topic": "commands.my-worker",
    "group": "my-worker-group"
  }' | jq -r '.id')

curl -s -X PUT "http://localhost:5001/workers/$WORKER_ID/code" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @worker.nupkg

curl -s -X POST "http://localhost:5001/workers/$WORKER_ID/start"

Discovery

The engine scans all assemblies loaded in the isolated AssemblyLoadContext for a non-abstract, non-interface type implementing Virtufin.Worker.DevKit.IWorker.

If exactly one implementation is found: - Instance method (ProcessAsync is not static): A single instance is created via Activator.CreateInstance(). State is preserved across invocations (engine-specific, not shared between workers). - Static method: No instance is created. The method is invoked directly.

If no IWorker implementation is found, an InvalidOperationException is thrown with:

No IWorker implementation found in the worker assembly. Implement Virtufin.Worker.DevKit.IWorker on a public, non-abstract class.

No other discovery mechanism is used. Method-name scanning, convention matching, and attribute-based discovery have been replaced by the IWorker contract.

WorkerBase Helpers

WorkerBase<TInput, TOutput> is the generic root. JSON workers use WorkerBase<JsonNode, JsonObject> (via ApiWorker / CommandWorker<T>); non-JSON workers plug in their own ICloudEventCodec<TInput, TOutput>. Common members:

Member Description
Constructor (source, responseEventType, codec?) Sets the CloudEvent.Source and CloudEvent.Type used in all responses. The JSON convenience base classes pre-bake JsonCloudEventCodec.
Codec : ICloudEventCodec<TInput, TOutput> The codec instance that owns payload decode/encode.
ProcessAsync(CloudEvent) Interface implementation. Not sealed — subclasses override the typed ProcessAsync(CloudEvent, TInput) instead.
BuildResponse(input, TOutput payload) Wraps the typed payload in a CloudEvent envelope with the worker's Source/Type and auto-applies correlationid (from input) and time.
BuildResponse(input, object? data, string contentType) Overload for non-typed payloads (used by Error(...)).
Error(message) Shortcut for BuildResponse(input, codec.EncodeError(message)) — produces the standard {command:"error", success:false, message} payload for JSON.
CorrelationIdAttribute, ReplyTopicAttribute Const strings for the canonical extension attribute names.
CloudEvent.WithCorrelationId(input) Fluent extension — copies the correlationid extension from input. The base class applies this automatically.
CloudEvent.WithTimestamp(this, offset?) Fluent extension — sets the time extension. The base class applies this automatically.

CommandWorker

CommandWorker<TInput, TOutput, TCommand> extends WorkerBase<TInput, TOutput> with typed command dispatch. The JSON convenience CommandWorker<T> fixes TInput = JsonNode, TOutput = JsonObject, and pre-bakes JsonCloudEventCodec.

Member Description
HandleCommandAsync(CloudEvent, TCommand, TInput) → Task<TOutput?> Abstract — override to implement command handlers. Return the typed payload (JsonObject? for the JSON convenience); the base class wraps it in the envelope. Return null for unknown commands (no reply published).
ExtractCommand(CloudEvent, TInput) → TCommand Virtual — default impl reads data.command and Enum.TryParse<T>(...). Override to change the wire format.
Response(input, command, success, id?, message?) Convenience (JSON convenience only) — builds a standard JsonObject {command, success, id?, message?}. Propagates correlationid + time via the base envelope builder.
ErrorPayload(message) Convenience (JSON convenience only) — builds the standard error JsonObject.

The base class handles JSON parsing, command extraction, error wrapping, and correlation ID + timestamp propagation — the override only contains the dispatch logic.

ApiWorker and ApiCommandWorker

ApiWorker : WorkerBase<JsonNode, JsonObject> (JSON-specific) provides an ApiClient for backend service calls via the API Gateway. ApiCommandWorker<T> : CommandWorker<T> extends that with typed command dispatch.

ApiWorker

Member Description
Constructor (source, responseEventType, apiHost?, apiPort?) Initializes the base WorkerBase<JsonNode, JsonObject> and (optionally) creates an ApiClient at apiHost:apiPort.
HandleAsync(CloudEvent, JsonNode) → Task<JsonObject?> Abstract — override to process events with pre-configured API access.
Api : ApiClient? The configured client for calling backend services; null until EnsureClient(...) is called if the lazy ctor was used.
using System.Text.Json.Nodes;
using CloudNative.CloudEvents;
using Virtufin.Api.Client;
using Virtufin.Worker.DevKit;

public class MyApiWorker : ApiWorker
{
    public MyApiWorker()
        : base(new Uri("urn:my-api-worker"), "my.response",
               apiHost: "localhost", apiPort: 5002) { }

    protected override async Task<JsonObject?> HandleAsync(
        CloudEvent input, JsonNode node)
    {
        var result = await Api!.Gateway.targetservice.SomeMethodAsync(
            new Dictionary<string, object?> { ["key"] = "value" });
        return Response(input, "process", true, message: $"Result: {result}");
    }
}

ApiCommandWorker

Member Description
Constructor (source, responseEventType, apiHost?, apiPort?) Initializes the base CommandWorker<T> and (optionally) ApiClient.
HandleCommandAsync(CloudEvent, T, JsonNode) → Task<JsonObject?> Abstract — same as CommandWorker<T>, with Api available.
Api : ApiClient? The configured client for calling backend services.
FieldApiHost, DefaultApiPort = 5002 Constants — api_host field name and default gateway port.
EnsureClient(host, port) / EnsureClientFromCommand(node) Lazy helpers — call from the handler if you didn't pre-bake the host/port in the ctor.
using System.Text.Json.Nodes;
using CloudNative.CloudEvents;
using Virtufin.Worker.DevKit;

public class MyApiCommandWorker : ApiCommandWorker<MyCommand>
{
    public MyApiCommandWorker()
        : base(new Uri("urn:my-api-command-worker"), "my.response",
               apiHost: "localhost", apiPort: 5002) { }

    protected override async Task<JsonObject?> HandleCommandAsync(
        CloudEvent input, MyCommand command, JsonNode node)
    {
        var result = await Api!.Gateway.targetservice
            .DispatchAsync(command.ToString(), node);
        return Response(input, command.ToString(), true, message: result?.ToString());
    }
}

public enum MyCommand { create, list, delete }

Migration: Manual ApiClient to ApiCommandWorker

Before (manual ApiClient):

public class MyWorker : CommandWorker
{
    private ApiClient _client;

    public MyWorker() : base("my.response", new Uri("urn:my-worker"))
    {
        _client = new ApiClient("localhost", 5002);
    }

    protected override async Task<CloudEvent?> HandleCommandAsync(
        CloudEvent input, string command, JsonNode node)
    {
        var result = await _client.InvokeAsync("backend", command, node);
        return Response(input, command, true, message: result?.ToString());
    }
}

After (ApiCommandWorker):

public class MyWorker : ApiCommandWorker<MyCommand>
{
    public MyWorker()
        : base(new Uri("urn:my-worker"), "my.response",
               apiHost: "localhost", apiPort: 5002) { }

    protected override async Task<JsonObject?> HandleCommandAsync(
        CloudEvent input, MyCommand command, JsonNode node)
    {
        var result = await Api!.Gateway.backend
            .DispatchAsync(command.ToString(), node);
        return Response(input, command.ToString(), true, message: result?.ToString());
    }
}

public enum MyCommand { create, list, delete }

Dependency Resolution

Worker assemblies may depend on third-party NuGet packages. Include all dependency .dll files in the ZIP. The engine's AssemblyLoadContext.Resolving handler resolves transitive dependencies by assembly name from the ZIP contents.

However, do not include these assemblies in the ZIP:

Assembly Reason
CloudNative.CloudEvents.dll Type identity conflict with the WorkManager's copy
Virtufin.Worker.DevKit.dll Resolved from the engine's copy via the Resolving handler

Error Handling

Scenario Behavior
No IWorker implementation InvalidOperationException on LoadCodeAsync
Worker throws exception Caught by WorkerBase.ProcessAsync, wrapped as codec.EncodeError(...) envelope, logged as error
ProcessAsync called before LoadCodeAsync InvalidOperationException
Dependency DLL missing from ZIP Resolving handler returns null, assembly load fails
Assembly load fails Caught and swallowed during pre-load phase

Performance

Phase Duration
LoadCodeAsync (ZIP extraction + assembly load) < 100ms typical
Per-message ProcessAsync invocation < 1ms