Skip to content

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:

siloBuilder.UseLocalhostClustering();

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

siloBuilder.UseConsulClustering(options =>
{
    options.Address = new Uri("http://consul:8500");
});

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

  1. Keep actors focused on single responsibility
  2. One actor per aggregate root or business entity
  3. Actors should represent a single entity's lifecycle

  4. Use persistent state for important data

  5. Always persist state after modifications
  6. Use WriteStateAsync() after state changes

  7. Handle errors gracefully

  8. Log errors with context
  9. Emit metrics for failures
  10. Let Orleans handle retries for transient failures

  11. Use structured logging

  12. Include actor ID in log context
  13. Use scoped logging for operations
  14. Log state transitions and important events

  15. Design for idempotency

  16. Actors may process messages multiple times
  17. Ensure operations are safe to retry

  18. Keep state objects simple

  19. Use POCOs for state
  20. Avoid complex object graphs
  21. Use surrogates for complex domain types

  22. Use reminders for scheduled tasks

  23. Reminders are durable and cluster-aware
  24. Prefer reminders over timers for production

  25. Test actors in isolation

  26. Test domain logic separately
  27. Use TestCluster for integration tests
  28. Mock grain factory for unit tests

Don'ts

  1. Don't block grain methods
  2. All grain methods must be async
  3. Avoid ConfigureAwait(false) in grain methods
  4. Never use blocking operations (.Result, .Wait())

  5. Don't share state between actors

  6. Each actor has isolated state
  7. Use messaging for actor-to-actor communication

  8. Don't use actors for simple CRUD

  9. REST API + Repository is simpler for stateless operations
  10. Use actors when state and behavior are important

  11. Don't store large objects in state

  12. Keep state objects small
  13. Use external storage for large data
  14. Consider claim check pattern for large payloads

  15. Don't create too many actor instances

  16. Actors consume memory
  17. Consider whether stateful actors are needed
  18. Use stateless grains or services when appropriate

  19. Don't ignore grain lifecycle

  20. Handle activation/deactivation if needed
  21. Clean up resources in OnDeactivateAsync()

  22. Don't mix framework-specific types in domain interfaces

  23. Keep IBankAccountActor framework-agnostic
  24. Use IBankAccountGrain for 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.