Skip to main content

Command Palette

Search for a command to run...

Implementing the Actor Model in .NET without Akka

Updated
4 min read
Implementing the Actor Model in .NET without Akka
P
Senior Software Engineer specialising in cloud architecture, distributed systems, and modern .NET development, with over two decades of experience designing and delivering enterprise platforms in financial, insurance, and high-scale commercial environments. My focus is on building systems that are reliable, scalable, and maintainable over the long term. I’ve led modernisation initiatives moving legacy platforms to cloud-native Azure architectures, designed high-throughput streaming solutions to eliminate performance bottlenecks, and implemented secure microservices environments using container-based deployment models and event-driven integration patterns. From an architecture perspective, I have strong practical experience applying approaches such as Vertical Slice Architecture, Domain-Driven Design, Clean Architecture, and Hexagonal Architecture. I’m particularly interested in modular system design that balances delivery speed with long-term sustainability, and I enjoy solving complex problems involving distributed workflows, performance optimisation, and system reliability. I enjoy mentoring engineers, contributing to architectural decisions, and helping teams simplify complex systems into clear, maintainable designs. I’m always open to connecting with other engineers, architects, and technology leaders working on modern cloud and distributed system challenges.

The actor model has been a proven way of achieving high throughput, fault isolation, and scalability since Erlang popularised it in telecoms decades ago. While libraries such as Akka.NET exist, many people hesitate to adopt them because of external dependencies, steep learning curves, or concerns about lock-in.

The good news is that the fundamental concepts behind actors are not magic, they can be implemented directly with the primitives that the .NET runtime provides. Below Ill show an example of how to build a lightweight actor system in .NET 8/9 using just System.Threading.Channels, Tasks, and a little orchestration.

What is the Actor Model?

The actor model defines three principles:

  1. Encapsulation of state: each actor owns its state exclusively, no external thread can mutate it.

  2. Message-passing concurrency: actors communicate only by sending messages, not by sharing memory.

  3. Isolation and supervision: failures in one actor should not crash the entire system. Supervisors can restart or replace failing actors.

The result is a programming model where race conditions and shared state deadlocks disappear, replaced by message handling.

Actor Skeleton

Start with a minimal actor implementation:

public interface IActorMessage { }

public sealed class Actor
{
    private readonly Channel<IActorMessage> _mailbox;
    private readonly Func<IActorMessage, Task> _onMessage;
    private readonly CancellationTokenSource _cts = new();

    public Actor(Func<IActorMessage, Task> onMessage)
    {
        _mailbox = Channel.CreateUnbounded<IActorMessage>(
            new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });

        _onMessage = onMessage;

        _ = Task.Run(RunAsync);
    }

    public async Task SendAsync(IActorMessage message) =>
        await _mailbox.Writer.WriteAsync(message);

    private async Task RunAsync()
    {
        try
        {
            await foreach (var message in _mailbox.Reader.ReadAllAsync(_cts.Token))
            {
                await _onMessage(message);
            }
        }
        catch (OperationCanceledException) { }
    }

    public void Stop() => _cts.Cancel();
}

Defining Messages and Behaviour

Actors are most powerful when their responsibilities are explicit. Next, build a CounterActor that supports increment, decrement, and state queries.

public record Increment(int Amount) : IActorMessage;
public record Decrement(int Amount) : IActorMessage;
public record GetValue(TaskCompletionSource<int> ReplyTo) : IActorMessage;

public sealed class CounterActor
{
    private int _count = 0;

    public Actor Ref { get; }

    public CounterActor()
    {
        Ref = new Actor(OnMessageAsync);
    }

    private Task OnMessageAsync(IActorMessage msg)
    {
        switch (msg)
        {
            case Increment inc:
                _count += inc.Amount;
                break;

            case Decrement dec:
                _count -= dec.Amount;
                break;

            case GetValue g:
                g.ReplyTo.SetResult(_count);
                break;
        }

        return Task.CompletedTask;
    }
}

Usage:

var counter = new CounterActor();

await counter.Ref.SendAsync(new Increment(5));
await counter.Ref.SendAsync(new Decrement(2));

var tcs = new TaskCompletionSource<int>();
await counter.Ref.SendAsync(new GetValue(tcs));
Console.WriteLine(await tcs.Task); // prints 3

Notice how queries (GetValue) are modelled by attaching a reply channel (TaskCompletionSource).

Restarting Failed Actors

We need fault tolerance so next add a simple supervisor that watches children.

public sealed class Supervisor
{
    private readonly List<Func<Actor>> _actorFactories = new();
    private readonly List<Actor> _actors = new();

    public Actor CreateActor(Func<IActorMessage, Task> onMessage)
    {
        Func<Actor> factory = () => new Actor(onMessage);
        _actorFactories.Add(factory);
        var actor = factory();
        _actors.Add(actor);
        return actor;
    }

    public void RestartAll()
    {
        foreach (var actor in _actors) actor.Stop();
        _actors.Clear();

        foreach (var factory in _actorFactories)
        {
            _actors.Add(factory());
        }
    }
}

This is crude but shows the idea, supervision trees can be constructed by grouping actors and restarting them together when failures occur. More advanced supervisors can apply backoff policies, exponential retries, or selective restarts, all of which are available from Microsoft Resilience.

Pattern options - Ask vs Tell

  • Tell: fire and forget message (SendAsync(new Increment(1))).

  • Ask: message + reply (GetValue with a TaskCompletionSource).

This pattern avoids synchronous calls across actors and ensures decoupled message driven communication.

Scaling Actors

Actors can be distributed across threads or even machines. A simple router balances messages across multiple actors.

public sealed class RoundRobinRouter
{
    private readonly Actor[] _routees;
    private int _next = 0;

    public RoundRobinRouter(Func<IActorMessage, Task> onMessage, int count)
    {
        _routees = Enumerable.Range(0, count)
            .Select(_ => new Actor(onMessage))
            .ToArray();
    }

    public Task SendAsync(IActorMessage msg)
    {
        var actor = _routees[Interlocked.Increment(ref _next) % _routees.Length];
        return actor.SendAsync(msg);
    }
}

With this, you can process work in parallel while still isolating state per actor.

Comparison to Akka.NET

FeatureDIY Actor (our impl)Akka.NET
Lightweight mailboxesYES (Channels)YES
Supervision strategiesBasicAdvanced
Remote actors / clusteringNOYES
Persistence / shardingNOYES
Performance overheadMinimalModerate

If you don’t need clustering, distributed persistence, or fancy message routers, a DIY actor system can be leaner and more transparent.

The key is understanding the primitives that the runtime gives you, Task, Channel, async/await and then using them to construct concurrency models that are transparent, testable, and customisable. Even a lightweight actor runtime can unlock new ways of modelling concurrent systems without falling back on weak locking schemes. And because you control the code, you can adapt it for supervision trees, routers, persistence, and clustering as you need to.