Actor Model in ConnectSoft Microservice Template¶
Purpose & Overview¶
The Actor Model in the ConnectSoft Microservice Template provides a robust, distributed, and concurrency-safe programming model ideal for modeling complex business workflows with state, behavior, and isolation. It builds on the principles of Domain-Driven Design (DDD) and extends the platform's clean architecture with a virtualized, message-driven model.
Why Actor Model?¶
The Actor Model offers several key benefits for the template:
- Concurrency Safety: Each actor processes messages sequentially, eliminating race conditions and the need for explicit locks
- Stateful Workflows: Actors encapsulate state and behavior, enabling long-running processes without external state management
- Scalability: Virtual actors can be distributed across clusters automatically, scaling horizontally
- Fault Tolerance: Actors can be reactivated automatically after failures, preserving state through persistent storage
- Location Transparency: Actors are referenced by identity, not location, simplifying distributed systems
- DDD Alignment: Actors map naturally to aggregates, enabling rich domain models with encapsulated behavior
- AI Integration: Perfect for stateful AI agents and persona-based workflows
Actor Model Implementation
The template uses Microsoft Orleans as the primary actor runtime. Orleans provides virtual actors (grains) that abstract away clustering, activation, and persistence concerns, making it easier to build scalable stateful applications.
Architecture Overview¶
The Actor Model integrates at the execution layer of Clean Architecture:
API Layer (REST/gRPC/GraphQL)
↓
Application Layer (DomainModel)
├── Processors (Commands/Writes)
└── Retrievers (Queries/Reads)
↓ (Actor Invocation)
Orleans Cluster
├── Silo (Orleans Runtime)
├── Grains (Actor Instances)
│ ├── Domain Grains (BankAccountActor, etc.)
│ └── Health Check Grains
├── Persistent State (SQL/MongoDB/Redis)
└── Reminders (Scheduled Tasks)
↓
Storage Layer
└── Grain State Storage
Key Integration Points¶
| Layer | Component | Responsibility |
|---|---|---|
| ActorModel | IBankAccountActor |
Domain actor interface (framework-agnostic) |
| ActorModel.Orleans | IBankAccountGrain, BankAccountActor |
Orleans-specific grain implementation |
| ApplicationModel | OrleansExtensions | Silo configuration and clustering |
| DomainModel | Domain Logic | Business rules executed within actors |
| PersistenceModel | Grain State Storage | Persistent actor state via SQL/MongoDB/Redis |
Core Components¶
1. Service Registration¶
Orleans is registered via host builder extension:
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// Configure Orleans silo
builder.Host.UseMicroserviceOrleans(configuration);
// Map Orleans dashboard
var app = builder.Build();
app.MapOrleansDashboard();
await app.RunAsync();
2. Configuration Extension¶
The UseMicroserviceOrleans() extension method configures the Orleans silo:
// OrleansExtensions.cs
public static IHostBuilder UseMicroserviceOrleans(this IHostBuilder hostBuilder)
{
ArgumentNullException.ThrowIfNull(hostBuilder);
hostBuilder.UseOrleans(ConfigureSiloBuilder);
return hostBuilder;
}
private static void ConfigureSiloBuilder(HostBuilderContext context, ISiloBuilder siloBuilder)
{
// Configure localhost clustering (development)
siloBuilder.UseLocalhostClustering();
// Configure services
siloBuilder.ConfigureServices(services => ConfigureSiloServices(services));
// Add activity propagation for OpenTelemetry
siloBuilder.AddActivityPropagation();
// Configure grain persistence
// The persistence provider type is selected during template generation
// Options: AdoNet (SQL Server, PostgreSQL, MySQL/MariaDB, Oracle) or AzureBlobStorage
ConfigureGrainPersistence(context.Configuration, siloBuilder);
// Configure reminder service
siloBuilder.UseAdoNetReminderService(options =>
{
ConfigureAdoNetReminderService(options);
});
// Add Orleans Dashboard
siloBuilder.UseDashboard();
}
3. Domain Actor Interface¶
Actor interfaces are defined at the domain layer (framework-agnostic):
// IBankAccountActor.cs (ActorModel project)
namespace ConnectSoft.MicroserviceTemplate.ActorModel
{
using System.Threading.Tasks;
/// <summary>
/// Bank account actor interface.
/// </summary>
public interface IBankAccountActor
{
/// <summary>
/// Withdraw the given amount from the bank account.
/// </summary>
/// <param name="input">Withdraw input.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task<WithdrawOutput> Withdraw(WithdrawInput input);
}
}
4. Orleans Grain Interface¶
Orleans-specific grain interfaces extend the domain interface:
// IBankAccountGrain.cs (ActorModel.Orleans project)
namespace ConnectSoft.MicroserviceTemplate.ActorModel.Orleans
{
using global::Orleans;
/// <summary>
/// Bank account actor orleans specific adapting grain interface.
/// </summary>
public interface IBankAccountGrain : IGrainWithGuidKey, IBankAccountActor
{
}
}
Grain Identity Types¶
Orleans supports multiple identity types:
| Interface | Key Type | Example |
|---|---|---|
IGrainWithStringKey |
string |
"account-123" |
IGrainWithIntegerKey |
long |
12345 |
IGrainWithGuidKey |
Guid |
Guid.NewGuid() |
IGrainWithCompoundKey |
(long, string) |
(tenantId, "user-456") |
5. Grain Implementation¶
Grains implement both the grain interface and Orleans Grain base class:
// BankAccountActor.cs
namespace ConnectSoft.MicroserviceTemplate.ActorModel.Orleans
{
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using ConnectSoft.Extensions.Logging;
using ConnectSoft.MicroserviceTemplate.Metrics;
using global::Orleans;
using global::Orleans.Runtime;
using Microsoft.Extensions.Logging;
/// <summary>
/// Bank account actor orleans based implementation.
/// </summary>
public class BankAccountActor(
ILogger<BankAccountActor> logger,
BankAccountActorMetrics meters,
[PersistentState("bankAccountActorState", BankAccountActor.BankAccountActorsStoreName)]
IPersistentState<BankAccountState> accountState)
: Grain<Guid>, IBankAccountGrain, IRemindable
{
public const string BankAccountActorsStoreName = "bankAccountActorsStore";
public const string BankAccountActorReminderName = "bankAccountActorReminder";
private readonly ILogger<BankAccountActor> logger = logger;
private readonly BankAccountActorMetrics meters = meters;
private readonly IPersistentState<BankAccountState> accountState = accountState;
/// <inheritdoc/>
public async Task<WithdrawOutput> Withdraw(WithdrawInput input)
{
using (this.logger.BeginScope(
new Dictionary<string, object>(StringComparer.Ordinal)
{
["ApplicationFlowName"] = nameof(BankAccountActor) + "/" + nameof(this.Withdraw),
}))
{
try
{
Guid actorId = this.GetPrimaryKey();
string actorIdStr = actorId.ToString();
this.logger.LogInformation(
"Withdraw the given amount from the bank account with {ActorId} started...",
actorId);
this.meters.IncreaseTotalActors(actorId: actorIdStr);
var start = System.Diagnostics.Stopwatch.StartNew();
// Update state
this.accountState.State.Balance -= input.Amount;
// Persist state
await this.accountState.WriteStateAsync();
start.Stop();
this.meters.RecordWithdrawSuccess(
amount: input.Amount,
duration: start.Elapsed,
actorId: actorIdStr,
reason: "api");
this.logger.LogInformation(
"Withdraw the given amount from the bank account with {ActorId} successfully completed...",
actorId);
return await Task.FromResult(new WithdrawOutput { Amount = input.Amount });
}
catch (Exception ex)
{
var actorIdStr = this.GetPrimaryKey().ToString();
this.meters.RecordWithdrawFailure(
amount: input.Amount,
duration: null,
actorId: actorIdStr,
reason: "exception");
this.logger.LogError(ex, "Failed to execute the Withdraw operation");
throw;
}
finally
{
var actorIdStr = this.GetPrimaryKey().ToString();
this.meters.DecreaseTotalActors(actorId: actorIdStr);
}
}
}
/// <inheritdoc/>
public Task ReceiveReminder(string reminderName, TickStatus status)
{
using (this.logger.BeginScope(
new Dictionary<string, object>(StringComparer.Ordinal)
{
["ApplicationFlowName"] = nameof(BankAccountActor) + "/" + nameof(this.ReceiveReminder),
}))
{
this.logger.LogInformation(
"Receive reminder with {ReminderName} started...",
reminderName);
if (string.Equals(reminderName, BankAccountActorReminderName, StringComparison.Ordinal))
{
this.ProcessBankAccountActorReminder(status);
}
}
return Task.CompletedTask;
}
private void ProcessBankAccountActorReminder(TickStatus status)
{
this.logger.LogInformation(
"Process bank account actor reminder started with status : {Status}...",
status);
}
}
}
6. Grain State Persistence¶
Orleans provides two approaches for grain state persistence, each suited for different use cases:
Non-Transactional State (IPersistentState<T>)¶
Use IPersistentState<T> for traditional grain state persistence without distributed transaction support. This approach is suitable for:
- Single-grain operations
- Eventual consistency scenarios
- High-throughput scenarios where ACID transactions are not required
State Object:
State objects for non-transactional storage use the [Serializable] attribute:
// BankAccountState.cs
namespace ConnectSoft.MicroserviceTemplate.ActorModel.Orleans
{
using System;
/// <summary>
/// Bank account state.
/// </summary>
[Serializable]
public class BankAccountState
{
/// <summary>
/// Gets or sets a balance.
/// </summary>
public decimal Balance { get; set; }
}
}
Grain Implementation:
State is persisted using IPersistentState<T>:
// BankAccountActor.cs
public class BankAccountActor : Grain<Guid>, IBankAccountGrain
{
[PersistentState("bankAccountActorState", BankAccountActor.BankAccountActorsStoreName)]
private IPersistentState<BankAccountState> accountState;
public async Task<WithdrawOutput> Withdraw(WithdrawInput input)
{
// Modify state
this.accountState.State.Balance -= input.Amount;
// Explicitly persist state changes
await this.accountState.WriteStateAsync();
return new WithdrawOutput { Amount = input.Amount };
}
}
Key Characteristics:
- State is loaded automatically on grain activation
- Must call WriteStateAsync() to persist changes
- No automatic rollback on exceptions
- Suitable for single-grain operations
- Uses standard grain storage providers (SQL Server, MongoDB, Azure Table Storage, etc.)
Transactional State (ITransactionalState<T>)¶
Use ITransactionalState<T> for distributed ACID transactions across multiple grains. This approach is suitable for:
- Multi-grain operations requiring atomicity
- Distributed transactions across different silos
- Scenarios requiring ACID guarantees
- Operations that need automatic rollback on failures
State Object:
State objects for transactional storage must use Orleans serialization attributes:
// BankAccountState.cs
namespace ConnectSoft.MicroserviceTemplate.ActorModel.Orleans
{
using global::Orleans;
/// <summary>
/// Bank account state.
/// </summary>
/// <remarks>
/// This state class is used with Orleans transactional state and requires serialization attributes
/// for Orleans to properly serialize and copy the state during transactions.
/// </remarks>
[GenerateSerializer]
public class BankAccountState
{
/// <summary>
/// Gets or sets a balance.
/// </summary>
[Id(0)]
public decimal Balance { get; set; }
}
}
Key Requirements:
- Use [GenerateSerializer] attribute on the state class
- Use [Id(n)] attributes on properties for version-safe serialization
- State must be a class (not a struct)
Grain Implementation:
Transactional grains use ITransactionalState<T> and must be marked with [Reentrant]:
// BankAccountActor.cs
namespace ConnectSoft.MicroserviceTemplate.ActorModel.Orleans
{
using System;
using System.Threading.Tasks;
using global::Orleans;
using global::Orleans.Transactions.Abstractions;
using Microsoft.Extensions.Logging;
/// <summary>
/// Bank account actor orleans based implementation.
/// </summary>
[Reentrant]
public class BankAccountActor(
ILogger<BankAccountActor> logger,
[TransactionalState(nameof(accountState), "TransactionStore")]
ITransactionalState<BankAccountState> accountState)
: Grain, IBankAccountGrain
{
private readonly ITransactionalState<BankAccountState> accountState = accountState;
/// <inheritdoc/>
public async Task<WithdrawOutput> Withdraw(WithdrawInput input)
{
await this.accountState.PerformUpdate(balance =>
{
var amount = (decimal)input.Amount;
if (balance.Balance < amount)
{
throw new InvalidOperationException(
$"Withdrawing {amount} credits from account " +
$"\"{this.GetPrimaryKey()}\" would overdraw it. " +
$"This account has {balance.Balance} credits.");
}
balance.Balance -= amount;
});
return new WithdrawOutput { Amount = input.Amount };
}
/// <inheritdoc/>
public Task<decimal> GetBalance()
{
return this.accountState.PerformRead(balance => balance.Balance);
}
}
}
Key Characteristics:
- Use [Reentrant] attribute on transactional grains (required)
- Inject ITransactionalState<T> with [TransactionalState(name, storageName)] attribute
- Use PerformUpdate() for state modifications (automatically persisted)
- Use PerformRead() for read-only operations
- State changes are automatically rolled back if an exception is thrown
- Transaction context is automatically propagated to grain calls
- Requires separate transactional state storage configuration
- Supports distributed ACID transactions across multiple grains
Comparison:
| Feature | IPersistentState<T> |
ITransactionalState<T> |
|---|---|---|
| Transaction Support | Single-grain only | Distributed ACID transactions |
| State Serialization | [Serializable] |
[GenerateSerializer] + [Id(n)] |
| Persistence | Manual (WriteStateAsync()) |
Automatic (via PerformUpdate()) |
| Rollback | Manual | Automatic on exceptions |
| Grain Attribute | Optional | [Reentrant] required |
| Storage | Standard grain storage | Separate transactional storage |
| Use Case | Single-grain operations | Multi-grain atomic operations |
For detailed information on Orleans Transactions, including configuration and examples, see Orleans Transactions.
7. Surrogates for Serialization¶
Orleans requires types to be serializable. For complex domain types, use surrogates:
// WithdrawInputSurrogate.cs
namespace ConnectSoft.MicroserviceTemplate.ActorModel.Orleans
{
using global::Orleans;
/// <summary>
/// This is a surrogate class for the <see cref="WithdrawInput"/> class.
/// Surrogates are serialized in place of their target type and have functionality to convert to and from the target type.
/// </summary>
[GenerateSerializer]
[Alias("ConnectSoft.MicroserviceTemplate.ActorModel.Orleans.WithdrawInputSurrogate")]
public struct WithdrawInputSurrogate
{
/// <summary>
/// Gets or sets the amount.
/// </summary>
[Id(0)]
public int Amount { get; set; }
}
}
Surrogates use [Id(n)] attributes for version-safe serialization.
Configuration¶
appsettings.json¶
{
"Orleans": {
"OrleansEndpoints": {
"SiloPort": 11111,
"GatewayPort": 30000,
"ListenOnAnyHostAddress": false
},
"Connection": {
"ConnectionsPerEndpoint": 1,
"ConnectionRetryDelay": "00:00:05",
"OpenConnectionTimeout": "00:00:30"
},
"Cluster": {
"ClusterId": "dev",
"ServiceId": "ConnectSoft.MicroserviceTemplate"
},
"GrainPersistence": {
"AdoNet": {
"ConnectionString": "Server=localhost;Database=Orleans;Integrated Security=true;",
"Invariant": "Microsoft.Data.SqlClient"
}
},
"AdoNetGrainReminderTable": {
"ConnectionString": "Server=localhost;Database=Orleans;Integrated Security=true;",
"Invariant": "Microsoft.Data.SqlClient"
},
"Dashboard": {
"BasePath": "/dashboard",
"Host": "localhost",
"Port": 8080,
"HostSelf": true,
"Username": "admin",
"Password": "password",
"CounterUpdateIntervalMs": 1000,
"HistoryLength": 100,
"HideTrace": false
}
}
}
Environment-Specific Configuration¶
// appsettings.Development.json
{
"Orleans": {
"Cluster": {
"ClusterId": "dev",
"ServiceId": "ConnectSoft.MicroserviceTemplate"
},
"GrainPersistence": {
"AdoNet": {
"ConnectionString": "Server=localhost;Database=Orleans_Dev;Integrated Security=true;"
}
}
}
}
// appsettings.Production.json
{
"Orleans": {
"Cluster": {
"ClusterId": "prod",
"ServiceId": "ConnectSoft.MicroserviceTemplate"
},
"GrainPersistence": {
"AdoNet": {
"ConnectionString": "${ORLEANS_STORAGE_CONNECTION_STRING}"
}
},
"Dashboard": {
"HostSelf": false,
"Username": "${ORLEANS_DASHBOARD_USERNAME}",
"Password": "${ORLEANS_DASHBOARD_PASSWORD}"
}
}
}
Using Actors from Application Layer¶
Invoking Actors¶
Actors are accessed via IGrainFactory:
public class MicroserviceAggregateRootsProcessor : IMicroserviceAggregateRootsProcessor
{
private readonly IGrainFactory grainFactory;
private readonly ILogger<MicroserviceAggregateRootsProcessor> logger;
public MicroserviceAggregateRootsProcessor(
IGrainFactory grainFactory,
ILogger<MicroserviceAggregateRootsProcessor> logger)
{
this.grainFactory = grainFactory;
this.logger = logger;
}
public async Task<IMicroserviceAggregateRoot> CreateMicroserviceAggregateRoot(
CreateMicroserviceAggregateRootInput input,
CancellationToken token = default)
{
// Get or create actor instance
var actorId = input.ObjectId;
var grain = this.grainFactory.GetGrain<IBankAccountGrain>(actorId);
// Invoke actor method
var result = await grain.Withdraw(new WithdrawInput
{
Amount = 100
});
// Process result
this.logger.LogInformation(
"Withdrew {Amount} from account {ActorId}",
result.Amount,
actorId);
// Return domain entity
return /* ... */;
}
}
Grain Factory Registration¶
IGrainFactory is automatically registered when Orleans is configured:
// Automatically available via DI
services.AddSingleton<IGrainFactory>(sp =>
sp.GetRequiredService<IHostApplicationLifetime>().ApplicationServices.GetRequiredService<IGrainFactory>());
Reminders and Scheduled Tasks¶
Actors can schedule durable reminders for time-based operations:
public class BankAccountActor : Grain<Guid>, IBankAccountGrain, IRemindable
{
public override async Task OnActivateAsync()
{
// Register reminder on activation
await RegisterOrUpdateReminder(
BankAccountActorReminderName,
dueTime: TimeSpan.FromSeconds(30),
period: TimeSpan.FromHours(24));
}
public Task ReceiveReminder(string reminderName, TickStatus status)
{
if (string.Equals(reminderName, BankAccountActorReminderName, StringComparison.Ordinal))
{
// Process reminder (e.g., daily reconciliation)
this.ProcessBankAccountActorReminder(status);
}
return Task.CompletedTask;
}
}
Reminder Characteristics¶
- Durable: Survives silo restarts
- Reliable: Guaranteed at-least-once delivery
- Persistent: Stored in reminder table
- Cluster-Aware: Works across cluster nodes
Grain State Persistence¶
Storage Providers¶
Orleans supports multiple storage providers:
| Provider | Use Case |
|---|---|
| SQL Server | Production-ready, ACID-compliant |
| MongoDB | Flexible, document-based |
| Redis | Fast, cache-based |
| Azure Table Storage | Cloud-native, scalable |
| In-Memory | Testing only |
Storage Configuration¶
// Configure ADO.NET storage
siloBuilder.AddAdoNetGrainStorage(BankAccountActor.BankAccountActorsStoreName, storage =>
{
storage.ConnectionString = configuration["Orleans:AdoNetGrainStorage:ConnectionString"];
storage.Invariant = configuration["Orleans:AdoNetGrainStorage:Invariant"];
// Create database and tables if needed
if (string.Equals(storage.Invariant, "Microsoft.Data.SqlClient", StringComparison.Ordinal))
{
SqlServerDatabaseHelper databaseHelper = new();
databaseHelper.CreateIfNotExists(storage.ConnectionString);
// Execute Orleans SQL scripts
// SQLServer-Main.sql, SQLServer-Persistence.sql
}
});
State Lifecycle¶
| Event | Action |
|---|---|
| Grain activation | State loaded from storage automatically |
| State modification | Update accountState.State properties |
| Persistence | Call await accountState.WriteStateAsync() |
| Grain deactivation | State remains in storage |
Actor Model vs Other Patterns¶
When to Use Actors¶
| Scenario | Use Actor Model When... |
|---|---|
| Stateful Entities | Entity has long-lived state that benefits from in-memory access |
| Concurrency-Sensitive Logic | Need serialized message processing without explicit locks |
| Per-Entity State | State is scoped to a specific entity ID (e.g., account-123) |
| Long-Running Processes | Process spans multiple requests and needs state persistence |
| Workflow Orchestration | Coordinating multi-step processes with state |
| AI Agents | Stateful AI personas that maintain conversation/context |
When Not to Use Actors¶
| Scenario | Use Alternative When... |
|---|---|
| Simple CRUD | REST API + Repository pattern is simpler |
| Stateless Operations | REST/gRPC endpoints are more efficient |
| Query-Heavy Workloads | Read models with optimized queries perform better |
| Low Latency Requirements | Direct database access may be faster |
| No State Needed | Stateless services are simpler to scale |
Actor vs Saga vs Microservice¶
| Aspect | Actor | Saga | Microservice |
|---|---|---|---|
| State | Encapsulated in actor | Stored separately | External (database) |
| Identity | Actor ID (Guid/string) | Correlation ID | Service endpoint |
| Concurrency | Automatic (per-actor) | Manual coordination | Explicit locks |
| Lifecycle | Auto-activated/deactivated | Explicit creation | Process lifetime |
| Communication | Message-based (await grain.Method()) |
Event-based | REST/gRPC/Queue |
| Scope | Per-entity instance | Per-workflow instance | Per-deployment |
Observability¶
Logging¶
Actors use structured logging with ILogger<T>:
public class BankAccountActor : Grain<Guid>, IBankAccountGrain
{
private readonly ILogger<BankAccountActor> logger;
public async Task<WithdrawOutput> Withdraw(WithdrawInput input)
{
using (this.logger.BeginScope(
new Dictionary<string, object>
{
["ActorId"] = this.GetPrimaryKey(),
["Operation"] = nameof(Withdraw)
}))
{
this.logger.LogInformation(
"Withdraw {Amount} from account {ActorId}",
input.Amount,
this.GetPrimaryKey());
// ... operation ...
this.logger.LogInformation(
"Withdraw completed for account {ActorId}",
this.GetPrimaryKey());
}
}
}
Metrics¶
Actors can emit custom metrics:
public class BankAccountActor : Grain<Guid>, IBankAccountGrain
{
private readonly BankAccountActorMetrics meters;
public async Task<WithdrawOutput> Withdraw(WithdrawInput input)
{
var start = System.Diagnostics.Stopwatch.StartNew();
try
{
// ... operation ...
this.meters.RecordWithdrawSuccess(
amount: input.Amount,
duration: start.Elapsed,
actorId: this.GetPrimaryKey().ToString(),
reason: "api");
}
catch (Exception ex)
{
this.meters.RecordWithdrawFailure(
amount: input.Amount,
duration: start.Elapsed,
actorId: this.GetPrimaryKey().ToString(),
reason: "exception");
throw;
}
}
}
OpenTelemetry Integration¶
Orleans supports OpenTelemetry for distributed tracing:
// OpenTelemetryExtensions.cs
services.AddOpenTelemetry()
.WithTracing(tracerProviderBuilder =>
{
tracerProviderBuilder
.AddSource("Microsoft.Orleans.Runtime")
.AddSource("Microsoft.Orleans.Application")
.AddMeter("Microsoft.Orleans");
});
Orleans Dashboard¶
The Orleans Dashboard provides real-time visibility:
- Grain Activations: View active grains and their state
- Message Flow: Trace message routing and processing
- Performance Metrics: Monitor grain method execution times
- Cluster Health: View silo status and cluster membership
Accessible at: http://localhost:8080/dashboard (configurable)
Health Checks¶
Orleans integrates with ASP.NET Core health checks:
// HealthChecksExtensions.cs
builder.AddOrleansHealthChecks();
// Includes:
// - OrleansClusterHealthCheck
// - OrleansGrainHealthCheck
// - OrleansSiloHealthCheck
// - OrleansStorageHealthCheck
Health check endpoints:
- GET /health - Overall health
- GET /health/ready - Readiness probe
- GET /health/live - Liveness probe
Testing¶
Unit Testing Domain Logic¶
Test domain logic separately from Orleans infrastructure:
[TestMethod]
public void Withdraw_ValidAmount_DecreasesBalance()
{
// Arrange
var state = new BankAccountState { Balance = 100 };
var input = new WithdrawInput { Amount = 40 };
// Act
var account = new BankAccount(state);
var result = account.Withdraw(input);
// Assert
Assert.AreEqual(60, state.Balance);
Assert.AreEqual(40, result.Amount);
}
Testing Grains with TestCluster¶
Use Orleans TestCluster for integration testing:
[TestClass]
public class BankAccountActorTests : IClassFixture<ClusterFixture>
{
private readonly IClusterClient client;
public BankAccountActorTests(ClusterFixture fixture)
{
this.client = fixture.Client;
}
[TestMethod]
public async Task Withdraw_UpdatesBalance()
{
// Arrange
var actorId = Guid.NewGuid();
var grain = this.client.GetGrain<IBankAccountGrain>(actorId);
// Act
var result = await grain.Withdraw(new WithdrawInput { Amount = 50 });
// Assert
Assert.IsNotNull(result);
Assert.AreEqual(50, result.Amount);
}
}
public class ClusterFixture : IDisposable
{
public IClusterClient Client { get; }
public ClusterFixture()
{
var builder = new TestClusterBuilder();
builder.AddSiloBuilderConfigurator<TestSiloConfigurator>();
var cluster = builder.Build();
cluster.Deploy();
this.Client = cluster.Client;
}
public void Dispose() => this.Client.Close();
}
Acceptance Testing with Reqnroll¶
BDD-style tests for actor workflows:
[Given(@"a bank account with balance (.*)")]
public async Task GivenAccount(decimal balance)
{
var actorId = Guid.NewGuid();
var grain = this.client.GetGrain<IBankAccountGrain>(actorId);
// Initialize account with balance
// ...
}
[When(@"I withdraw (.*)")]
public async Task WhenIWithdraw(decimal amount)
{
var result = await this.grain.Withdraw(new WithdrawInput { Amount = (int)amount });
this.result = result;
}
[Then(@"the remaining balance should be (.*)")]
public async Task ThenBalanceShouldBe(decimal expected)
{
// Assert balance matches expected
// ...
}
Clustering and Deployment¶
Development Mode¶
Local development uses localhost clustering:
Single silo runs in-process with the application.
Production Clustering¶
Production clustering supports multiple modes:
SQL Server Clustering¶
siloBuilder.UseAdoNetClustering(options =>
{
options.ConnectionString = configuration["Orleans:Cluster:ConnectionString"];
options.Invariant = "Microsoft.Data.SqlClient";
});
Azure Table Storage¶
siloBuilder.UseAzureStorageClustering(options =>
{
options.ConnectionString = configuration["Orleans:Cluster:ConnectionString"];
});
Consul¶
Silo Configuration¶
// Configure cluster options
services.Configure<ClusterOptions>(cluster =>
{
cluster.ClusterId = "prod"; // Unique per cluster
cluster.ServiceId = "ConnectSoft.MicroserviceTemplate"; // Unique per service
});
// Configure endpoints
services.Configure<EndpointOptions>(endpoint =>
{
endpoint.SiloPort = 11111; // Silo-to-silo communication
endpoint.GatewayPort = 30000; // Client-to-silo gateway
endpoint.AdvertisedIPAddress = IPAddress.Loopback;
if (OptionsExtensions.OrleansOptions.OrleansEndpoints.ListenOnAnyHostAddress)
{
endpoint.SiloListeningEndpoint = new IPEndPoint(IPAddress.Any, endpoint.SiloPort);
endpoint.GatewayListeningEndpoint = new IPEndPoint(IPAddress.Any, endpoint.GatewayPort);
}
});
Best Practices¶
Do's¶
- Keep actors focused on single responsibility
- One actor per aggregate root or business entity
-
Actors should represent a single entity's lifecycle
-
Use persistent state for important data
- Always persist state after modifications
-
Use
WriteStateAsync()after state changes -
Handle errors gracefully
- Log errors with context
- Emit metrics for failures
-
Let Orleans handle retries for transient failures
-
Use structured logging
- Include actor ID in log context
- Use scoped logging for operations
-
Log state transitions and important events
-
Design for idempotency
- Actors may process messages multiple times
-
Ensure operations are safe to retry
-
Keep state objects simple
- Use POCOs for state
- Avoid complex object graphs
-
Use surrogates for complex domain types
-
Use reminders for scheduled tasks
- Reminders are durable and cluster-aware
-
Prefer reminders over timers for production
-
Test actors in isolation
- Test domain logic separately
- Use TestCluster for integration tests
- Mock grain factory for unit tests
Don'ts¶
- Don't block grain methods
- All grain methods must be async
- Avoid
ConfigureAwait(false)in grain methods -
Never use blocking operations (
.Result,.Wait()) -
Don't share state between actors
- Each actor has isolated state
-
Use messaging for actor-to-actor communication
-
Don't use actors for simple CRUD
- REST API + Repository is simpler for stateless operations
-
Use actors when state and behavior are important
-
Don't store large objects in state
- Keep state objects small
- Use external storage for large data
-
Consider claim check pattern for large payloads
-
Don't create too many actor instances
- Actors consume memory
- Consider whether stateful actors are needed
-
Use stateless grains or services when appropriate
-
Don't ignore grain lifecycle
- Handle activation/deactivation if needed
-
Clean up resources in
OnDeactivateAsync() -
Don't mix framework-specific types in domain interfaces
- Keep
IBankAccountActorframework-agnostic - Use
IBankAccountGrainfor Orleans-specific concerns
Common Scenarios¶
Scenario 1: Simple Stateful Actor¶
public interface ICounterActor
{
Task<int> IncrementAsync();
Task<int> GetValueAsync();
}
public class CounterActor : Grain, ICounterActor
{
private readonly IPersistentState<CounterState> state;
public CounterActor([PersistentState("counter", "default")] IPersistentState<CounterState> state)
{
this.state = state;
}
public async Task<int> IncrementAsync()
{
this.state.State.Value++;
await this.state.WriteStateAsync();
return this.state.State.Value;
}
public Task<int> GetValueAsync()
{
return Task.FromResult(this.state.State.Value);
}
}
Scenario 2: Actor with Reminders¶
public class SubscriptionActor : Grain, ISubscriptionActor, IRemindable
{
private readonly IPersistentState<SubscriptionState> state;
public override async Task OnActivateAsync()
{
// Schedule renewal reminder
await RegisterOrUpdateReminder(
"Renewal",
dueTime: TimeSpan.FromDays(30),
period: TimeSpan.FromDays(30));
}
public Task ReceiveReminder(string reminderName, TickStatus status)
{
if (reminderName == "Renewal")
{
return this.ProcessRenewalAsync();
}
return Task.CompletedTask;
}
private async Task ProcessRenewalAsync()
{
// Process subscription renewal
// ...
}
}
Scenario 3: Actor Coordination¶
// OrderActor coordinates with PaymentActor and InventoryActor
public class OrderActor : Grain, IOrderActor
{
private readonly IGrainFactory grainFactory;
private readonly IPersistentState<OrderState> state;
public async Task<OrderResult> ProcessOrderAsync(OrderInput input)
{
// Get related actors
var paymentActor = this.grainFactory.GetGrain<IPaymentActor>(input.PaymentId);
var inventoryActor = this.grainFactory.GetGrain<IInventoryActor>(input.ProductId);
// Coordinate workflow
var paymentResult = await paymentActor.ProcessPaymentAsync(new PaymentInput { Amount = input.Total });
if (paymentResult.Success)
{
var inventoryResult = await inventoryActor.ReserveAsync(new ReserveInput { Quantity = input.Quantity });
if (inventoryResult.Success)
{
this.state.State.Status = OrderStatus.Confirmed;
await this.state.WriteStateAsync();
return new OrderResult { Success = true };
}
else
{
// Compensate payment
await paymentActor.RefundAsync(new RefundInput { PaymentId = paymentResult.PaymentId });
}
}
return new OrderResult { Success = false };
}
}
Actor Model Implementations¶
The template primarily uses Microsoft Orleans, but the architecture supports multiple implementations:
Microsoft Orleans¶
- Status: Primary implementation
- Type: Virtual actors (grains)
- State: Persistent via storage providers
- Clustering: Built-in support for SQL, Azure, Consul
- Features: Reminders, streaming, transactions
Dapr Actors (Future)¶
- Status: Planned support
- Type: Virtual actors via sidecar
- State: Redis, Cosmos DB, etc.
- Clustering: Dapr runtime handles
- Features: Polyglot support, simpler deployment
Akka.NET (Future)¶
- Status: Exploration
- Type: Hierarchical actors
- State: Manual persistence
- Clustering: Akka Cluster
- Features: Supervision, complex routing
Summary¶
The Actor Model in the ConnectSoft Microservice Template provides:
- ✅ Concurrency Safety: Automatic message serialization per actor
- ✅ Stateful Workflows: Encapsulated state and behavior
- ✅ Scalability: Horizontal scaling across cluster
- ✅ Fault Tolerance: Automatic reactivation and state recovery
- ✅ DDD Alignment: Natural mapping to aggregates and domain entities
- ✅ Observability: Full OpenTelemetry and logging support
- ✅ Testing: Comprehensive test support with TestCluster
- ✅ Clean Architecture: Framework-agnostic interfaces with Orleans implementation
By following these patterns and best practices, the Actor Model becomes a powerful foundation for building scalable, reliable, and maintainable stateful microservices.