Devlooped.CloudActors 1.0.0-alpha

Prefix Reserved
This is a prerelease version of Devlooped.CloudActors.
There is a newer prerelease version of this package available.
See the version list below for details.
dotnet add package Devlooped.CloudActors --version 1.0.0-alpha
                    
NuGet\Install-Package Devlooped.CloudActors -Version 1.0.0-alpha
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Devlooped.CloudActors" Version="1.0.0-alpha" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Devlooped.CloudActors" Version="1.0.0-alpha" />
                    
Directory.Packages.props
<PackageReference Include="Devlooped.CloudActors" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Devlooped.CloudActors --version 1.0.0-alpha
                    
#r "nuget: Devlooped.CloudActors, 1.0.0-alpha"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Devlooped.CloudActors@1.0.0-alpha
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Devlooped.CloudActors&version=1.0.0-alpha&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=Devlooped.CloudActors&version=1.0.0-alpha&prerelease
                    
Install as a Cake Tool

An opinionated, simplified and uniform Cloud Native actors' library that integrates with Microsoft Orleans.

EULA OSS GitHub

Open Source Maintenance Fee

To ensure the long-term sustainability of this project, users of this package who generate revenue must pay an Open Source Maintenance Fee. While the source code is freely available under the terms of the License, this package and other aspects of the project require adherence to the Maintenance Fee.

To pay the Maintenance Fee, become a Sponsor at the proper OSMF tier. A single fee covers all of Devlooped packages.

Overview

Rather than the RPC-style programming offered (and encouraged) out of the box by Orleans, Cloud Actors offers a message-passing style of programming with a uniform API to access actors: Execute and Query.

These uniform operations receive a message (a.k.a. command or query) and optionally return a result. Consumers always use the same API to invoke operations on actors, and the combination of the actor id and the message consitute enough information to route the message to the right actor.

Actors can be implemented as plain CLR objects, with no need to inherit from any base class or implement any interface. The Orleans plumbing of grains and their activation is completely hidden from the developer.

Features

Rather than relying on dynamic dispatch, this implementation relies heavily on source generators to provide strong-typed routing of messages, while preserving a flexible mechanism for implementors.

In addition, this library makes the grains completely transparent to the developer. They don't even need to take a dependency on Orleans. In other words: the developer writes his business logic as a plain CLR object (POCO).

The central abstraction of the library is the actor bus:

public interface IActorBus
{
    Task ExecuteAsync(string id, IActorCommand command);
    Task<TResult> ExecuteAsync<TResult>(string id, IActorCommand<TResult> command);
    Task<TResult> QueryAsync<TResult>(string id, IActorQuery<TResult> query);
}

Actors receive messages to process, which are typically plain records such as:

public partial record Deposit(decimal Amount) : IActorCommand;  // 👈 marker interface for void commands

public partial record Withdraw(decimal Amount) : IActorCommand;

public partial record Close(CloseReason Reason = CloseReason.Customer) : IActorCommand<decimal>; // 👈 marker interface for value-returning commands

public enum CloseReason
{
    Customer,
    Fraud,
    Other
}

public partial record GetBalance() : IActorQuery<decimal>; // 👈 marker interface for queries (a.k.a. readonly methods)

We can see that the only thing that distinguishes a regular Orleans parameter from an actor message, is that it implements the IActorCommand or IActorQuery interface. You can see the three types of messages supported by the library:

  • IActorCommand - a message that is sent to an actor to be processed, but does not return a result.
  • IActorCommand<TResult> - a message that is sent to an actor to be processed, and returns a result.
  • IActorQuery<TResult> - a message that is sent to an actor to be processed, and returns a result. It differs from the previous type in that it is a read-only operation, meaning it does not mutate the state of the actor. This causes a Readonly method invocation on the grain.

The actor, for its part, only needs the [Actor] attribute to be recognized as such:

[Actor]
public partial class Account(string id)    // 👈 no need for parameterless constructor or inheriting anything by default
{
    public string Id { get; } = id;
    public decimal Balance { get; private set; }
    public bool IsClosed { get; private set; }
    public CloseReason Reason { get; private set; }

    //public void Execute(Deposit command)      // 👈 methods can be overloads of message types
    //{
    //    // validate command
    //    // decrease balance
    //}

    // Showcases that operations can have a name that's not Execute
    public Task DepositAsync(Deposit command)   // 👈 but can also use any name you like
    {
        // validate command
        Balance += command.Amount;
        return Task.CompletedTask;
    }

    // Showcases that operations don't have to be async
    public void Execute(Withdraw command)       // 👈 methods can be sync too
    {
        // validate command
        Balance -= command.Amount;
    }

    // Showcases value-returning operation with custom name.
    // In this case, closing the account returns the final balance.
    // As above, this can be async or not.
    public decimal Close(Close command)
    {
        var balance = Balance;
        Balance = 0;
        IsClosed = true;
        Reason = command.Reason;
        return balance;
    }

    // Showcases a query that doesn't change state
    public decimal Query(GetBalance _) => Balance;  // 👈 becomes [ReadOnly] grain operation
}

NOTE: no attributes are needed anywhere for state persistence — only the [Actor] attribute on the class itself is required. The source generator automatically includes in persisted state: all properties that have a setter (regardless of accessibility — public, private, etc.), and all non-const, non-static, non-readonly instance fields. Get-only properties and readonly fields are excluded, as they are expected to be initialized via the constructor or derived from other state.

On the hosting side, an AddCloudActors extension method is provided to register the automatically generated grains to route invocations to the actors:

var builder = WebApplication.CreateSlimBuilder(args);

builder.Host.UseOrleans(silo =>
{
    silo.UseLocalhostClustering();
});

// 👇 registers generated grains, actor bus and activation features
builder.Services.AddCloudActors(); 

State Deserialization

The above Account class only provides a single constructor receiving the account identifier. After various operations are performed on it, however, the state will be changed via private property setters (or direct field mutation). When you annotate a class with the [Actor] attribute, a source generator will create an inner class to hold all state properties (and fields), and implement (explicitly) an IActor<TState> interface to allow getting/setting the instance state.

This provides seamless integration with Orleans' recommended IPersistentState<T> injection mechanism used by the generated grain.

The generator produces a nested ActorState record for the above Account actor, capturing its mutable state as an Orleans-serializable snapshot:

[GeneratedCode("Devlooped.CloudActors")]
[GenerateSerializer]
public partial class ActorState : IActorState<Account>
{
    [Id(0)] public decimal Balance;
    [Id(1)] public bool IsClosed;
    [Id(2)] public CloseReason Reason;
}

This is a sort of typed Memento pattern which allows the Orleans state persistence mechanisms to read and write the actor state without requiring any additional code from the developer.

The generated ActorState is always in sync with the actor's mutable members because it is regenerated at compile time. State is read from storage on activation and written after each successful command — with an automatic rollback (re-read) if the write fails.

Event Sourcing

Quite a natural extension of the message-passing style of programming for these actors, is to go full event sourcing. The library provides an interface IEventSourced for that:

public interface IEventSourced
{
    IReadOnlyList<object> Events { get; }
    void AcceptEvents();
    void LoadEvents(IEnumerable<object> history);
}

The sample Streamstone-based grain storage will invoke LoadEvents with the events from the stream (if found), and AcceptEvents will be invoked after the grain is saved, so it can clear the events list.

Optimistic concurrency is implemented by exposing the stream version as the IGrainState.ETag and parsing it when persisting to ensure consistency.

Users are free to implement this interface in any way they deem fit, but the library provides a default implementation if the interface is inherited but not implemented. The generated implementation provides a Raise<T>(@event) method for the actor's methods to raise events, and invokes provided Apply(@event) methods to apply the events to mutate state. The generator assumes this convention, using the single parameter to every Apply method on the actor as the switch to route events (either when raised or loaded from storage).

For example, if the above Account actor was converted to an event-sourced actor, it would look like this:

[Actor]
public partial class Account : IEventSourced  // 👈 interface is *not* implemented by user!
{
    public Account(string id) => Id = id;

    public string Id { get; }
    public decimal Balance { get; private set; }
    public bool IsClosed { get; private set; }

    public void Execute(Deposit command)
    {
        if (IsClosed)
            throw new InvalidOperationException("Account is closed");

        // 👇 Raise<T> is generated when IEventSourced is inherited
        Raise(new Deposited(command.Amount));
    }

    public void Execute(Withdraw command)
    {
        if (IsClosed)
            throw new InvalidOperationException("Account is closed");
        if (command.Amount > Balance)
            throw new InvalidOperationException("Insufficient funds.");

        Raise(new Withdrawn(command.Amount));
    }

    public decimal Execute(Close command)
    {
        if (IsClosed)
            throw new InvalidOperationException("Account is closed");

        var balance = Balance;
        Raise(new Closed(Balance, command.Reason));
        return balance;
    }

    public decimal Query(GetBalance _) => Balance;

    // 👇 generated generic Apply(object) dispatches to each based on event type with no reflection

    partial void Apply(Deposited @event) => Balance += @event.Amount;

    partial void Apply(Withdrawn @event) => Balance -= @event.Amount;

    partial void Apply(Closed @event)
    {
        Balance = 0;
        IsClosed = true;
        Reason = @event.Reason;
    }
}

By generating the partial Apply methods, the generator allows users to implement only the event types they care about, without needing to provide an empty implementation for the rest.

When IEventSourced is inherited without being implemented, the generator provides the full wiring: Raise<T>(event) / Raise<T>() methods that apply the event and record it in the pending events list, a type-switched Apply(object) dispatcher, and partial void declarations for each event type raised. There is also an optional hook for post-raise callbacks:

// Invoked after every Raise<T>(event) call — implement to react to raised events.
partial void OnRaised<T>(T @event) where T : notnull;

Note how there's no dynamic dispatch here 💯.

An important corollary of this project is that the design of a library and particularly its implementation details, will vary greatly if it can assume source generators will play a role in its consumption. In this particular case, many design decisions were different initially before I had the generators in place, and the result afterwards was a simplification in many aspects, with less base types in the main library/interfaces project, and more incremental behavior added as users opt-in to certain features.

Typed Actor IDs

Instead of identifying actors with plain strings, you can use strongly-typed IDs.

Primitive IDs

When the first constructor parameter is a primitive BCL value type (e.g. long, Guid), the generator produces a nested {Actor}Id wrapper struct and a NewId factory method:

[Actor]
public partial class Order(long id)
{
    public long Id => id;
    // ...
}

// Generated:
//   public readonly record struct OrderId(long Id);
//   public static OrderId NewId(long id) => new(id);

For Guid IDs a parameterless NewId() is also generated, using Guid.CreateVersion7() on .NET 9+ or Guid.NewGuid() on earlier runtimes.

Structured IDs

Any strongly-typed ID library that generates IParsable<TSelf> and IFormattable on the ID type works out of the box. The most popular choices include:

  • StructId — single-line IStructId<T> declaration, source-generated
  • StronglyTypedId — attribute-driven, supports multiple backing types
  • Vogen — value-object generator with validation support

CloudActors detects these types automatically: if the actor's first constructor parameter implements IParsable<T>, it is treated as the typed ID and the generator produces typed IActorBus overloads for it — no extra configuration required.

Here is an example using StructId:

// Just declare the struct — StructId generates all the boilerplate
public readonly partial record struct ProductId : IStructId<Guid>;

[Actor]
public partial class Product(ProductId id)
{
    public ProductId Id => id;

    public decimal Price { get; private set; }

    public void Execute(SetPrice command) => Price = command.Price;

    public decimal Query(GetPrice _) => Price;
}
var id = new ProductId(Guid.CreateVersion7()); // or ProductId.New()

await bus.ExecuteAsync(id, new SetPrice(9.99m));
var price = await bus.QueryAsync(id, new GetPrice());

Typed Bus Overloads

For any actor with a typed ID (primitive or structured), the generator produces typed IActorBus extension overloads so you never have to format the ID string manually:

// Instead of:
await bus.ExecuteAsync("order/42", new PlaceOrder(...));

// You can use the typed overload:
await bus.ExecuteAsync(new OrderId(42), new PlaceOrder(...));

The ID is always stored and routed as "{actortype}/{id}" (e.g. "order/42", "product/...").

Telemetry and Monitoring

The core implementation of the IActorBus is instrumented with ActivitySource and Metric, providing out of the box support for Open Telemetry-based monitoring, as well as via dotnet trace and dotnet counters.

To export telemetry using Open Telemetry, for example:

using var tracer = Sdk
    .CreateTracerProviderBuilder()
    .SetResourceBuilder(ResourceBuilder.CreateDefault().AddService("ConsoleApp"))
    .AddSource(source.Name) // other app sources
    .AddSource("CloudActors")
    .AddConsoleExporter()
    .AddZipkinExporter()
    .AddAzureMonitorTraceExporter(o => o.ConnectionString = config["AppInsights"])
    .Build();

Collecting traces via dotnet-trace:

dotnet trace collect --name [PROCESS_NAME] --providers="Microsoft-Diagnostics-DiagnosticSource:::FilterAndPayloadSpecs=[AS]CloudActors,System.Diagnostics.Metrics:::Metrics=CloudActors"

Monitoring metrics via dotnet-counters:

dotnet counters monitor --process-id [PROCESS_ID] --counters CloudActors

How it works

The library uses source generators to generate the grain classes. It's easy to inspect the generated code by setting the EmitCompilerGeneratedFiles property to true in the project and inspecting the obj folder.

For each [Actor] class the generator produces a partial {Name}Grain : Grain class that:

  • Injects IPersistentState<ActorState> and reads it on activation
  • Routes incoming IActorCommand / IActorCommand<TResult> / IActorQuery<TResult> to the exact method you defined on your actor — preserving your method name and sync/async signature
  • After every command writes the new state to storage; on failure, rolls back by re-reading
  • Marks QueryAsync with [ReadOnly] so Orleans handles it as a concurrent read

Because the grain is partial, you can extend it with your own members in a separate file if needed.

Note how there's no dynamic dispatch 💯. Message routing is a compile-time switch on the concrete message type, generated by the source generator directly from your actor's methods.

Since source generators can't depend on other generated code, grain types are registered with Orleans through assembly-level attributes (ApplicationPartAttribute and GenerateCodeForDeclaringAssembly) emitted by the generator into the silo/host project — no manual grain type registration is needed.

The services.AddCloudActors() call (generated as an extension on IServiceCollection) registers:

  • IActorBusOrleansActorBus
  • IActorIdFactory → a compile-time factory that parses typed actor IDs without reflection
  • Replaces IPersistentStateFactory with a wrapper that passes the typed ID to the actor constructor

Finally, in order to improve discoverability for consumers of the IActorBus interface, extension method overloads are generated that surface the available actor messages as non-generic overloads, such as:

execute overloads

query overloads

Sponsors

Clarius Org MFB Technologies, Inc. Khamza Davletov SandRock DRIVE.NET, Inc. Keith Pickford Thomas Bolon Kori Francis Reuben Swartz Jacob Foshee alternate text is missing from this package README image Eric Johnson Jonathan Ken Bonny Simon Cropp agileworks-eu Zheyu Shen Vezel ChilliCream 4OTC domischell Adrian Alonso torutek mccaffers Seika Logiciel Andrew Grant Lars prime167

Sponsor this project

Learn more about GitHub Sponsors

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0-rc 129 4/17/2026
1.0.0-beta 130 4/16/2026
1.0.0-alpha 109 4/15/2026
0.5.0-rc.2 278 11/12/2025
0.5.0-rc.1 281 11/11/2025
0.5.0-rc 186 11/9/2025
0.5.0-beta 231 11/6/2025
0.4.0 1,644 6/14/2024
0.3.0 352 8/8/2023
0.2.2 291 8/7/2023
0.2.1 292 8/7/2023
0.2.0 298 8/7/2023