A lightweight, production-ready actor model framework for .NET with mailboxes, supervision trees, clustering support, and message persistence. Built on modern .NET 10 with async/await throughout.
- Project Overview
- Architecture
- Installation
- Quick Start
- Usage Examples
- API Reference
- Configuration Reference
- Message Types
- Supervision Strategies
- Monitoring & Metrics
- Persistence
- Clustering
- Troubleshooting
- Performance
- Related Projects
- Testing
- Contributing
- License
The DotNet Actor Framework brings the actor model pattern to .NET, providing a robust foundation for building distributed, fault-tolerant, and highly scalable systems. The actor model is a proven pattern used by frameworks like Akka and is ideal for systems that need to handle concurrent message processing with minimal resource overhead.
- Message-Driven: Actors communicate exclusively through asynchronous messages
- Stateful: Each actor maintains isolated internal state
- Resilient: Hierarchical supervision enables self-healing systems
- Observable: Built-in metrics collection and health monitoring
- Durable: Optional message and state persistence
- Distributed: Clustering support for multi-node deployments
- Type-Safe: Strongly-typed message inheritance hierarchy
The actor framework is ideal for:
- Real-time data processing pipelines
- Distributed job processing systems
- WebSocket/SignalR server implementations
- Game servers and MMO backends
- Event-driven microservices
- Long-running background workers
- Systems requiring self-healing capabilities
┌─────────────────────────────────────────────────────────┐
│ Actor System │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Actor │ │ Actor │ │ Supervisor │ │
│ │ Registry │ │ System │ │ Service │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Mailbox │ │ Message │ │ Persistence │ │
│ │ Service │ │ Dispatcher │ │ Layer │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Metrics │ │ Caching │ │ Event Bus │ │
│ │ Collector │ │ Service │ │ │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
| Component | Responsibility |
|---|---|
| ActorSystem | Overall system lifecycle and coordination |
| ActorRegistry | Actor creation, lookup, and lifecycle management |
| MailboxService | FIFO queue management per actor |
| MessageDispatcher | Message routing and delivery |
| SupervisionService | Failure detection and recovery strategies |
| ActorStateRepository | Snapshot persistence for actor state |
| MessagePersistenceRepository | Durable message log storage |
| ActorMetricsRepository | Performance metrics aggregation |
| ActorCacheService | In-memory caching of frequently accessed actors |
| EventBus | Pub/sub for system events |
Created → Initializing → Started ⟷ Suspended
↓
Stopping → Terminated
↑
Error (with recovery)
- Created: Actor instantiated but not yet initialized
- Initializing: OnInitializeAsync() in progress
- Started: Ready to process messages
- Suspended: Temporarily paused (e.g., during error recovery)
- Stopping: OnStopAsync() in progress
- Terminated: Shut down and removed from system
- Error: Encountered an unhandled exception
dotnet add package DotNetActorFrameworkOr through Visual Studio Package Manager:
Install-Package DotNetActorFramework
git clone https://github.com/sarmkadan/dotnet-actor-framework.git
cd dotnet-actor-framework
dotnet build
dotnet packdocker build -t dotnet-actor-framework .To start the actor system cluster with the database:
docker-compose up -ddocker run -it dotnet-actor-framework:latestusing Microsoft.Extensions.DependencyInjection;
using DotNetActorFramework.Configuration;
using DotNetActorFramework.Models;
// 1. Configure dependency injection
var services = new ServiceCollection();
services.AddActorFramework(options =>
{
options.SystemName = "MySystem";
options.MaxActorCount = 10000;
});
var serviceProvider = services.BuildServiceProvider();
// 2. Initialize the actor system
var config = ActivatorUtilities.CreateInstance<ActorSystemConfiguration>(serviceProvider);
var actorSystem = await config.InitializeAsync();
// 3. Create an actor
var path = new ActorPath("/user/worker");
var workerRef = await config.CreateActorAsync(path);
// 4. Send a message
var message = new ControlMessage("process", new Dictionary<string, object>
{
{ "data", "example" }
});
var dispatcher = serviceProvider.GetRequiredService<MessageDispatcher>();
await dispatcher.SendAsync(workerRef, message);
// 5. Graceful shutdown
await actorSystem.ShutdownAsync();public class EchoActor : Actor
{
public EchoActor(ActorPath path) : base(path) { }
public override async Task ReceiveAsync(Message message)
{
if (message is ControlMessage cm)
{
Console.WriteLine($"Echo: {cm.Command}");
await Task.CompletedTask;
}
}
}
// Usage
var path = new ActorPath("/user/echo");
var echoRef = await config.CreateActorAsync(path);
await dispatcher.SendAsync(echoRef, new ControlMessage("hello"));public class CalculatorActor : Actor
{
public CalculatorActor(ActorPath path) : base(path) { }
public override async Task ReceiveAsync(Message message)
{
if (message is ControlMessage cm && cm.Command == "add")
{
var a = (int)cm.Parameters["a"];
var b = (int)cm.Parameters["b"];
var result = a + b;
// Send response back to sender
if (message.Sender != null)
{
var response = new ResponseMessage(result, isSuccess: true);
// Response handling
}
}
await Task.CompletedTask;
}
}public class CounterActor : Actor
{
private int _count = 0;
public CounterActor(ActorPath path) : base(path) { }
public override async Task ReceiveAsync(Message message)
{
if (message is ControlMessage cm)
{
switch (cm.Command)
{
case "increment":
_count++;
break;
case "decrement":
_count--;
break;
case "get":
// Return current count
break;
}
}
await Task.CompletedTask;
}
}public class SupervisorActor : Actor
{
private List<ActorRef> _children = new();
public SupervisorActor(ActorPath path) : base(path) { }
public override async Task OnInitializeAsync()
{
// Create child actors
for (int i = 0; i < 5; i++)
{
var childPath = new ActorPath($"{Path}/worker-{i}");
var childRef = await ActorSystem.CreateActorAsync(childPath, this.Ref);
_children.Add(childRef);
}
}
public override async Task ReceiveAsync(Message message)
{
// Distribute work to children
if (message is ControlMessage cm && cm.Command == "process")
{
foreach (var child in _children)
{
await _dispatcher.SendAsync(child, message);
}
}
await Task.CompletedTask;
}
}services.AddActorFramework(options =>
{
options.SystemName = "RobustSystem";
options.DefaultSupervisionStrategy = SupervisionStrategy.Restart;
options.BackoffInitialDelayMs = 100;
options.BackoffMaxDelayMs = 10000;
});
public class ResilientActor : Actor
{
public ResilientActor(ActorPath path) : base(path) { }
public override async Task ReceiveAsync(Message message)
{
try
{
// Processing logic that might fail
await ProcessMessageAsync(message);
}
catch (Exception ex)
{
Metrics.RecordError(ex);
throw; // Supervision will handle recovery
}
}
private async Task ProcessMessageAsync(Message message)
{
// Implementation
await Task.CompletedTask;
}
}public class BatchProcessorActor : Actor
{
private readonly List<Message> _batch = new();
private readonly int _batchSize = 100;
private readonly Timer? _flushTimer;
public BatchProcessorActor(ActorPath path) : base(path) { }
public override async Task ReceiveAsync(Message message)
{
_batch.Add(message);
if (_batch.Count >= _batchSize)
{
await ProcessBatchAsync(_batch);
_batch.Clear();
}
}
private async Task ProcessBatchAsync(List<Message> batch)
{
// Bulk processing
await Task.CompletedTask;
}
}var stats = config.GetStatistics();
Console.WriteLine($"=== Actor System Health ===");
Console.WriteLine($"Total Actors: {stats.Health?.TotalActors}");
Console.WriteLine($"Running: {stats.Health?.RunningActors}");
Console.WriteLine($"Terminated: {stats.Health?.TerminatedActors}");
Console.WriteLine($"Health Percentage: {stats.Health?.GetHealthPercentage()}%");
Console.WriteLine($"Error Rate: {stats.Health?.GetErrorRate()}%");
Console.WriteLine($"\n=== Message Dispatcher ===");
Console.WriteLine($"Total Processed: {stats.DispatcherStats?.TotalProcessed}");
Console.WriteLine($"Success Rate: {stats.DispatcherStats?.SuccessRate}%");
Console.WriteLine($"Average Latency: {stats.DispatcherStats?.AverageLatency}ms");
Console.WriteLine($"\n=== Mailbox ===");
Console.WriteLine($"Total Enqueued: {stats.MailboxStats?.TotalEnqueued}");
Console.WriteLine($"Current Queue Size: {stats.MailboxStats?.CurrentQueueSize}");services.AddActorFramework(options =>
{
options.EnableMetricsCollection = true;
options.EnableLogging = true;
});
// Middleware is registered and automatically applied
// Built-in middleware:
// - LoggingMiddleware: logs all message activity
// - MetricsCollectionMiddleware: collects performance metrics
// - AuthenticationMiddleware: validates sender credentials
// - RateLimitingMiddleware: enforces message rate limits
// - ErrorHandlingMiddleware: catches exceptionsservices.AddActorFrameworkReliable("Server=localhost;Database=ActorFramework");
public class PersistentActor : Actor
{
private readonly ActorStatePersistence _persistence;
public PersistentActor(ActorPath path, ActorStatePersistence persistence)
: base(path)
{
_persistence = persistence;
}
public override async Task OnStopAsync()
{
// Save state before shutdown
var snapshot = new ActorSnapshot
{
ActorPath = Path,
State = GetState(),
Timestamp = DateTime.UtcNow
};
await _persistence.SaveSnapshotAsync(snapshot);
}
public override async Task OnInitializeAsync()
{
// Restore state from previous session
var snapshot = await _persistence.GetLatestSnapshotAsync(Path);
if (snapshot != null)
{
RestoreState(snapshot.State);
}
}
}services.AddActorFrameworkCluster(options =>
{
options.NodeId = "node-1";
options.BindAddress = "127.0.0.1";
options.BindPort = 8080;
options.SeedNodes = new[] { "127.0.0.1:8080" };
});
// Actors automatically participate in the cluster
var remoteActorPath = new ActorPath("/user/remote-actor@node-2");
var remoteRef = await config.ResolveActorAsync(remoteActorPath);
await dispatcher.SendAsync(remoteRef, message);public class ActorSystem
{
// Lifecycle
public Task StartAsync();
public Task ShutdownAsync();
// Queries
public ActorRef? GetActor(ActorPath path);
public IEnumerable<ActorRef> GetAllActors();
public Task<SystemStatistics> GetStatisticsAsync();
public HealthSummary GetHealthSummary();
}public interface IActorRegistry
{
Task<ActorRef> CreateActorAsync(ActorPath path, ActorRef? supervisor = null);
Task TerminateActorAsync(ActorRef actorRef);
ActorRef? GetActorByPath(ActorPath path);
IEnumerable<ActorRef> GetActorsByPath(ActorPath parentPath);
Task<ActorMetrics> GetActorMetricsAsync(ActorRef actorRef);
}public interface IMessageDispatcher
{
Task SendAsync(ActorRef recipient, Message message);
Task SendAsync(ActorRef recipient, Message message, ActorRef? sender);
Task<bool> TrySendAsync(ActorRef recipient, Message message, TimeSpan timeout);
Task PublishAsync(Message message);
}public interface ISupervisionService
{
Task ApplySupervisionStrategyAsync(ActorRef actor, Exception exception);
void RegisterSupervisionHandler(Func<ActorRef, Exception, Task> handler);
}public abstract record Message
{
public Guid Id { get; init; }
public DateTime CreatedAt { get; init; }
public ActorRef? Sender { get; init; }
}
public record ControlMessage(string Command,
Dictionary<string, object>? Parameters = null) : Message;
public record ResponseMessage(object? Data, bool IsSuccess,
string? Error = null) : Message;
public record FailureMessage(string Reason, Exception? Exception = null) : Message;public class ActorSystemOptions
{
// System identity
public string SystemName { get; set; } = "ActorSystem";
// Limits
public int MaxActorCount { get; set; } = 10000;
public int MaxMessageQueueSize { get; set; } = 100000;
// Persistence
public bool EnableMessagePersistence { get; set; } = false;
public string? ConnectionString { get; set; }
// Supervision
public SupervisionStrategy DefaultSupervisionStrategy { get; set; } =
SupervisionStrategy.Restart;
public int BackoffInitialDelayMs { get; set; } = 100;
public int BackoffMaxDelayMs { get; set; } = 30000;
// Monitoring
public bool EnableMetricsCollection { get; set; } = true;
public bool EnableLogging { get; set; } = true;
public int MetricsFlushIntervalMs { get; set; } = 5000;
// Clustering
public bool EnableClustering { get; set; } = false;
public string? ClusterNodeId { get; set; }
public string? ClusterBindAddress { get; set; }
}// Default: balanced configuration
services.AddActorFramework();
// High Performance: optimized for throughput
services.AddActorFrameworkHighPerformance();
// Reliable: emphasizes durability and fault-tolerance
services.AddActorFrameworkReliable("connection-string");
// Cluster: distributed multi-node setup
services.AddActorFrameworkCluster(options => {
options.NodeId = "node-1";
options.BindAddress = "0.0.0.0";
});
// Custom: full control
services.AddActorFramework(options => {
options.SystemName = "Custom";
options.EnableMessagePersistence = true;
// ... more options
});For general commands and parameters:
var msg = new ControlMessage("startProcessing", new Dictionary<string, object>
{
{ "dataPath", "/data/input" },
{ "batchSize", 1000 },
{ "timeout", 60 }
});For request-response patterns:
var response = new ResponseMessage(
data: new { Count = 42, Status = "complete" },
isSuccess: true
);
// Or error response
var errorResponse = new ResponseMessage(
data: null,
isSuccess: false,
error: "Processing failed"
);For exception propagation:
var failure = new FailureMessage(
reason: "Database connection timeout",
exception: ex
);public record OrderMessage(string OrderId, decimal Amount) : Message;
public record UserRegistrationMessage(string Email, string Name) : Message;
// Usage
var order = new OrderMessage("ORD-123", 99.99m);
await dispatcher.SendAsync(processorRef, order);Automatically restart the actor after a brief delay:
options.DefaultSupervisionStrategy = SupervisionStrategy.Restart;
options.BackoffInitialDelayMs = 100;
options.BackoffMaxDelayMs = 30000;Terminate the actor on failure (no restart):
options.DefaultSupervisionStrategy = SupervisionStrategy.Stop;Ignore the error and continue processing:
options.DefaultSupervisionStrategy = SupervisionStrategy.Resume;Forward the failure to the parent supervisor:
options.DefaultSupervisionStrategy = SupervisionStrategy.Escalate;Restart with exponential backoff delays:
options.DefaultSupervisionStrategy = SupervisionStrategy.Backoff;
options.BackoffInitialDelayMs = 100;
options.BackoffMaxDelayMs = 60000; // 1 minute maxvar health = config.GetHealthSummary();
health.TotalActors // Total actor count
health.RunningActors // Currently active
health.TerminatedActors // Shut down count
health.ErroredActors // In error state
health.SuspendedActors // Paused actors
health.GetHealthPercentage() // 0-100%
health.GetErrorRate() // 0-100%var stats = await config.GetStatisticsAsync();
// Actor Registry stats
stats.ActorRegistryStats.TotalCreated
stats.ActorRegistryStats.TotalTerminated
// Dispatcher stats
stats.DispatcherStats.TotalProcessed
stats.DispatcherStats.SuccessRate
stats.DispatcherStats.AverageLatency
stats.DispatcherStats.P95Latency
stats.DispatcherStats.P99Latency
// Mailbox stats
stats.MailboxStats.TotalEnqueued
stats.MailboxStats.CurrentQueueSize
stats.MailboxStats.AverageQueueLength
stats.MailboxStats.PeakQueueLength
// Supervision stats
stats.SupervisionStats.TotalRecoveries
stats.SupervisionStats.RestartCount
stats.SupervisionStats.StopCount
stats.SupervisionStats.EscalateCount// Get all metrics as JSON
var metricsJson = stats.ToJson();
// Export to file
await System.IO.File.WriteAllTextAsync(
"metrics.json",
metricsJson
);public class MyActor : Actor
{
private string _state;
public override async Task OnStopAsync()
{
var snapshot = new ActorSnapshot
{
ActorPath = Path,
State = new { State = _state },
Timestamp = DateTime.UtcNow
};
await persistence.SaveSnapshotAsync(snapshot);
}
public override async Task OnInitializeAsync()
{
var snapshot = await persistence.GetLatestSnapshotAsync(Path);
if (snapshot?.State is Dictionary<string, object> state)
{
_state = state["State"]?.ToString() ?? "";
}
}
}Messages are automatically persisted when enabled:
services.AddActorFrameworkReliable(
"Server=localhost;Database=ActorFramework"
);Store all state changes as events:
public class EventSourcingActor : Actor
{
private readonly Queue<object> _events = new();
public override async Task ReceiveAsync(Message message)
{
// Record event
_events.Enqueue(message);
// Process event
await ApplyEventAsync(message);
}
private async Task ApplyEventAsync(Message message)
{
// Update state based on event
await Task.CompletedTask;
}
}services.AddActorFramework();
var config = new ActorSystemConfiguration(...);
var system = await config.InitializeAsync();// Node 1
services.AddActorFrameworkCluster(options =>
{
options.NodeId = "node-1";
options.BindAddress = "192.168.1.10";
options.BindPort = 8080;
options.SeedNodes = new[] { "192.168.1.10:8080" };
});
// Node 2
services.AddActorFrameworkCluster(options =>
{
options.NodeId = "node-2";
options.BindAddress = "192.168.1.11";
options.BindPort = 8080;
options.SeedNodes = new[] { "192.168.1.10:8080" };
});// Resolve remote actor
var remotePath = new ActorPath("/user/service@node-2");
var remoteRef = await config.ResolveActorAsync(remotePath);
// Send message (works transparently)
var msg = new ControlMessage("process");
await dispatcher.SendAsync(remoteRef, msg);
// Response handling
var response = await dispatcher.SendAndWaitAsync(remoteRef, msg, timeout: 5000);Symptoms: Actors receive messages but don't process them
Solutions:
- Verify
OnInitializeAsync()completed successfully - Check actor state with
actor.State(should beStarted) - Review logs for middleware errors
- Ensure
ReceiveAsync()doesn't throw unhandled exceptions
public override async Task ReceiveAsync(Message message)
{
try
{
// Processing
}
catch (Exception ex)
{
// Log and handle
Console.WriteLine($"Error: {ex.Message}");
throw; // Allow supervision to handle
}
}Symptoms: Memory grows unbounded over time
Solutions:
- Reduce
MaxMessageQueueSizeoption - Implement message batching
- Enable periodic actor cleanup
- Monitor queue lengths with metrics
services.AddActorFramework(options =>
{
options.MaxMessageQueueSize = 50000; // Reduce from default
options.MaxActorCount = 5000;
});Symptoms: Actor enters error state and stays there
Solutions:
- Check supervision strategy configuration
- Verify
BackoffMaxDelayMsis not too high - Review exception logs
- Implement proper error handling in
ReceiveAsync()
services.AddActorFramework(options =>
{
options.DefaultSupervisionStrategy = SupervisionStrategy.Restart;
options.BackoffInitialDelayMs = 100;
options.BackoffMaxDelayMs = 10000;
});Symptoms: Latency is high, throughput is low
Solutions:
- Use high-performance configuration preset
- Implement message batching
- Reduce actor lifecycle overhead
- Profile with metrics collection
// Enable monitoring to identify bottleneck
var stats = await config.GetStatisticsAsync();
Console.WriteLine($"P99 Latency: {stats.DispatcherStats?.P99Latency}ms");Symptoms: Persistence operations fail, connection timeouts
Solutions:
- Verify connection string
- Check database accessibility
- Increase connection pool size
- Enable retry logic
services.AddActorFrameworkReliable(
"Server=localhost;Database=ActorFramework;" +
"Max Pool Size=100;Connection Timeout=30;"
);The framework is designed for high-throughput, low-latency message processing on modern .NET hardware.
To run the performance benchmarks, execute the following command from the project root:
dotnet run -c Release --project benchmarks/DotNetActorFramework.Benchmarks/DotNetActorFramework.Benchmarks.csprojMeasured on AMD EPYC-Rome Processor 2.45GHz, .NET 10.0
| Method | Mean | Error | StdDev | Allocated |
|---|---|---|---|---|
| CreateActorAsync | 4,016.39 ns | 79.030 ns | 110.788 ns | 1136 B |
| GetActorRef | 12.22 ns | 0.242 ns | 0.288 ns | 0 B |
| GetHealthSummary | 37.84 ns | 0.816 ns | 1.429 ns | 80 B |
- Use
AddActorFrameworkHighPerformance()for throughput-critical paths - Enable message batching for bulk operations (
MessageBatcher) - Pass identifiers in messages and load data inside actors rather than embedding large payloads
- Monitor
P95Latency/P99Latencyfrom dispatcher stats to detect bottlenecks early
- dotnet-event-bus - In-process and distributed event bus for .NET - pub/sub, request/reply, dead letter, polymorphic handlers
Actors emit results as domain events so downstream subscribers react without tight coupling:
public class OrderProcessorActor : Actor
{
private readonly IEventBus _eventBus;
public OrderProcessorActor(ActorPath path, IEventBus eventBus) : base(path)
=> _eventBus = eventBus;
public override async Task ReceiveAsync(Message message)
{
if (message is ControlMessage { Command: "process" } cm)
{
var orderId = cm.Parameters!["orderId"].ToString();
// ... process order ...
await _eventBus.PublishAsync(new OrderProcessedEvent(orderId!));
}
}
}Subscribe to external domain events and forward them into the actor system for stateful processing:
eventBus.Subscribe<PaymentReceivedEvent>(async evt =>
{
var actorRef = registry.GetActorByPath(new ActorPath("/user/payment-handler"));
var msg = new ControlMessage("handlePayment", new() { ["amount"] = evt.Amount });
await dispatcher.SendAsync(actorRef!, msg);
});# Run all tests
dotnet test
# Run with coverage
dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=lcov
# Run a specific test project
dotnet test tests/dotnet-actor-framework.Tests/dotnet-actor-framework.Tests.csproj
# Run with verbose output
dotnet test --logger "console;verbosity=detailed"The test suite covers actor lifecycle, path resolution, metrics collection, and the middleware pipeline. Target minimum 80% code coverage for contributions.
Contributions are welcome! Please follow these guidelines:
git clone https://github.com/sarmkadan/dotnet-actor-framework.git
cd dotnet-actor-framework
dotnet build
dotnet test- Follow C# naming conventions (PascalCase for classes, camelCase for fields)
- Write XML documentation comments for public APIs
- Keep methods under 30 lines when possible
- Use meaningful variable names
<type>: <subject>
<body>
<footer>
Types: feat, fix, docs, style, refactor, test, chore
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Make changes and write tests
- Commit with meaningful messages
- Push to your fork
- Open a Pull Request with clear description
MIT License - Copyright (c) 2026 Vladyslav Zaiets
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
Vladyslav Zaiets
- CTO & Software Architect
- Portfolio: https://sarmkadan.com
- GitHub: https://github.com/Sarmkadan
- Telegram: https://t.me/sarmkadan
Built by Vladyslav Zaiets \n- Supervision Guide