Skip to content

Microsoft Orleans in ConnectSoft Microservice Template

Purpose & Overview

Microsoft Orleans is a distributed virtual actor framework that serves as the core actor runtime in the ConnectSoft Microservice Template. Orleans provides a scalable, stateful, and concurrent execution model for building cloud-native microservices with built-in distributed system capabilities.

Orleans enables:

  • Virtual Actors: Grains (actors) that are automatically activated and deactivated on-demand
  • Stateful Services: Persistent actor state with automatic lifecycle management
  • Concurrency Safety: Message-based serialization ensures thread-safe grain execution
  • Distributed Execution: Actors can span clusters with automatic routing and failover
  • Scalability: Horizontal scaling with stateless workers and sharded grains
  • Cloud Readiness: Native Azure support with SQL Server, Redis, and Azure Table storage
  • Observability: Integrated logging, metrics, distributed tracing, and diagnostics dashboard
  • Testability: In-memory test clusters and mockable grain interfaces

Orleans Philosophy

Orleans abstracts away the complexity of distributed systems—grain lifecycle, instance management, message routing, and state persistence are handled automatically. Developers focus on business logic while Orleans ensures scalability, reliability, and consistency.

Architecture Overview

Orleans in ConnectSoft Architecture

Application Layer
    ├── 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 Server/Redis/MongoDB)
    └── Reminders (Durable 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, clustering, storage
DomainModel Domain Logic Business rules executed within actors
PersistenceModel Grain State Storage Persistent actor state via SQL/MongoDB/Redis
Infrastructure Health Checks, Metrics Observability and diagnostics

Hosting & Configuration

Program.cs Integration

Orleans is integrated via host builder extension:

// Program.cs
var builder = WebApplication.CreateBuilder(args);

#if UseOrleans
builder.Host.UseMicroserviceOrleans();
#endif

var app = builder.Build();

#if UseOrleans
app.MapOrleansDashboard();
#endif

await app.RunAsync();

Silo Configuration Extension

The UseMicroserviceOrleans() extension 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)
{
    // Very important: call UseLocalhostClustering() first
    // It overrides custom configuration defined later
    siloBuilder.UseLocalhostClustering();

    // Configure all Orleans services
    siloBuilder.ConfigureServices(services =>
    {
        ConfigureSiloServices(services);
        ConfigureOrleansSchedulingServices(services);
    });

    // Add activity propagation for OpenTelemetry
    siloBuilder.AddActivityPropagation();

    // Enable Orleans transactions
    siloBuilder.UseTransactions();

    // Configure transactional state storage
    ConfigureTransactionalStateStorage(context.Configuration, siloBuilder);

    // 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();
}

Configuration Services

private static void ConfigureSiloServices(IServiceCollection services)
{
    // Connection options
    services.Configure<ConnectionOptions>(connection =>
    {
        connection.ProtocolVersion = NetworkProtocolVersion.Version1;
        connection.ConnectionsPerEndpoint = OptionsExtensions.OrleansOptions.Connection.ConnectionsPerEndpoint;
        connection.ConnectionRetryDelay = OptionsExtensions.OrleansOptions.Connection.ConnectionRetryDelay;
        connection.OpenConnectionTimeout = OptionsExtensions.OrleansOptions.Connection.OpenConnectionTimeout;
    });

    // Cluster options
    services.Configure<ClusterOptions>(cluster =>
    {
        cluster.ClusterId = OptionsExtensions.OrleansOptions.Cluster.ClusterId;
        cluster.ServiceId = OptionsExtensions.OrleansOptions.Cluster.ServiceId;
    });

    // Endpoint options
    services.Configure<EndpointOptions>(endpoint =>
    {
        endpoint.SiloPort = OptionsExtensions.OrleansOptions.OrleansEndpoints.SiloPort;
        endpoint.GatewayPort = OptionsExtensions.OrleansOptions.OrleansEndpoints.GatewayPort;
        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);
        }
    });

    // Dashboard options
    services.Configure<DashboardOptions>(dashboard =>
    {
        dashboard.BasePath = OptionsExtensions.OrleansOptions.Dashboard.BasePath;
        dashboard.Host = OptionsExtensions.OrleansOptions.Dashboard.Host;
        dashboard.Port = OptionsExtensions.OrleansOptions.Dashboard.Port;
        dashboard.HostSelf = OptionsExtensions.OrleansOptions.Dashboard.HostSelf;
        // ... other dashboard settings
    });

    // Grain persistence is configured in ConfigureGrainPersistence method
    // No direct service configuration needed here

    // Reminder table options
    services.Configure<AdoNetReminderTableOptions>(storage =>
    {
        storage.ConnectionString = OptionsExtensions.OrleansOptions.AdoNetGrainReminderTable.ConnectionString;
        storage.Invariant = OptionsExtensions.OrleansOptions.AdoNetGrainReminderTable.Invariant;
    });

    // Custom JSON serializer for Orleans types
    services.AddSerializer(serializerBuilder => serializerBuilder.AddJsonSerializer(
        type => type.Namespace is not null && 
                type.Namespace.StartsWith("ConnectSoft.MicroserviceTemplate.ActorModel.Orleans", StringComparison.Ordinal),
        CreateJsonSerializerOptions()));
}

private static JsonSerializerOptions CreateJsonSerializerOptions()
{
    var jsonSerializerOptions = new JsonSerializerOptions();
    jsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
    jsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.CamelCase;
    return jsonSerializerOptions;
}

Scheduling Configuration

Orleans scheduling is optimized to prevent thread pool starvation:

/// <summary>
/// Configures Orleans scheduling options to optimize grain execution 
/// and prevent thread pool starvation.
/// </summary>
private static void ConfigureOrleansSchedulingServices(IServiceCollection services)
{
    services.Configure<SchedulingOptions>(options =>
    {
        // Warning threshold for queued work items (default: 10s)
        options.DelayWarningThreshold = TimeSpan.FromSeconds(5);

        // Activation scheduling fairness interval (default: 100ms)
        options.ActivationSchedulingQuantum = TimeSpan.FromMilliseconds(100);

        // Warning threshold for long-running turns (default: 1s)
        options.TurnWarningLengthThreshold = TimeSpan.FromMilliseconds(1000);

        // Soft limit for pending work items (default: 0, disabled)
        options.MaxPendingWorkItemsSoftLimit = 1000;
    });
}

Grain Persistence Configuration

Grain persistence supports multiple provider types, selected during template generation:

ADO.NET Grain Persistence

ADO.NET grain persistence supports SQL Server, PostgreSQL, MySQL/MariaDB, and Oracle databases:

private static void ConfigureAdoNetGrainPersistence(ISiloBuilder siloBuilder, OrleansAdoNetGrainPersistenceOptions options)
{
    // Instructions on configuring your database are available at http://aka.ms/orleans-sql-scripts
    siloBuilder.AddAdoNetGrainStorage(BankAccountActor.BankAccountActorsStoreName, storage =>
    {
        storage.ConnectionString = options.ConnectionString;
        storage.Invariant = options.Invariant;

        if (string.Equals(storage.Invariant, "System.Data.SqlClient", StringComparison.Ordinal) ||
            string.Equals(storage.Invariant, "Microsoft.Data.SqlClient", StringComparison.Ordinal))
        {
            SqlServerDatabaseHelper databaseHelper = new();
            databaseHelper.CreateIfNotExists(storage.ConnectionString);

            // Load and execute Orleans SQL scripts
            string mainSql = File.ReadAllText(
                Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Scripts", "OrleansScripts", "SqlServer", "SQLServer-Main.sql"));
            string persistenceSql = File.ReadAllText(
                Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Scripts", "OrleansScripts", "SqlServer", "SQLServer-Persistence.sql"));

            using SqlConnection connection = new(storage.ConnectionString);
            connection.Open();

        using (SqlCommand mainSqlCommand = new(mainSql, connection))
        {
            mainSqlCommand.ExecuteNonQuery();
        }

        using SqlCommand persistenceSqlCommand = new(persistenceSql, connection);
        persistenceSqlCommand.ExecuteNonQuery();
    }
}

Azure Blob Storage Grain Persistence

Azure Blob Storage grain persistence stores grain state in Azure Blob Storage containers:

private static void ConfigureAzureBlobStorageGrainPersistence(
    ISiloBuilder siloBuilder, 
    OrleansAzureBlobStorageGrainPersistenceOptions options)
{
    // Instructions on configuring Azure Blob Storage are available at 
    // https://learn.microsoft.com/en-us/dotnet/orleans/grains/grain-persistence/azure-storage
    siloBuilder.AddAzureBlobGrainStorage(BankAccountActor.BankAccountActorsStoreName, configure =>
    {
        configure.Configure(blobStorageOptions =>
        {
            if (options.UseManagedIdentity)
            {
                blobStorageOptions.BlobServiceClient = CreateBlobServiceClientWithManagedIdentity(options);
            }
            else
            {
                blobStorageOptions.BlobServiceClient = new Azure.Storage.Blobs.BlobServiceClient(options.ConnectionString);
            }
            blobStorageOptions.ContainerName = options.ContainerName;
        });
    });

    siloBuilder.AddAzureBlobGrainStorageAsDefault(configure =>
    {
        configure.Configure(blobStorageOptions =>
        {
            if (options.UseManagedIdentity)
            {
                blobStorageOptions.BlobServiceClient = CreateBlobServiceClientWithManagedIdentity(options);
            }
            else
            {
                blobStorageOptions.BlobServiceClient = new Azure.Storage.Blobs.BlobServiceClient(options.ConnectionString);
            }
            blobStorageOptions.ContainerName = options.ContainerName;
        });
    });
}

The provider supports both connection string and managed identity authentication. When using managed identity, the connection string can be used to extract the service URI, or you can provide the BlobEndpoint directly.

Configuration (appsettings.json)

Orleans Configuration Structure

{
"Orleans": {
    "OrleansEndpoints": {
      "SiloPort": 11111,
      "GatewayPort": 30000,
      "AddressFamily": "InterNetwork",
      "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,
      "ScriptPath": null,
      "CustomCssPath": null
    }
  }
}

Configuration Options

Section Property Description
OrleansEndpoints SiloPort Port for silo-to-silo communication
GatewayPort Port for client-to-silo (gateway) communication
AddressFamily IP address family (InterNetwork, InterNetworkV6)
ListenOnAnyHostAddress Listen on all IP addresses
Connection ConnectionsPerEndpoint Connections per endpoint
ConnectionRetryDelay Delay before retry
OpenConnectionTimeout Connection timeout
Cluster ClusterId Unique cluster identifier
ServiceId Service identifier
AdoNetGrainStorage ConnectionString Database connection string
Invariant Database provider invariant name
AdoNetGrainReminderTable ConnectionString Reminders database connection
Invariant Database provider invariant name
Dashboard HostSelf Enable dashboard self-hosting
Port Dashboard HTTP port
Username Basic auth username
Password Basic auth password

Port Assignments

Port Purpose
11111 Silo-to-silo communication
30000 Client gateway
5000/5001 HTTP endpoints (REST, gRPC, Swagger)
8080 Orleans Dashboard (optional)

Grain Contracts & Interfaces

Domain Actor Interface (Framework-Agnostic)

Actor interfaces are defined at the domain layer:

// 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);
    }
}

Orleans Grain Interface

Orleans-specific grain interfaces extend domain interfaces:

// 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 Use Case
IGrainWithStringKey string "account-123" Human-readable identifiers
IGrainWithIntegerKey long 12345 Numeric identifiers
IGrainWithGuidKey Guid Guid.NewGuid() Unique identifiers (default)
IGrainWithCompoundKey (long, string) (tenantId, "user-456") Multi-tenant scenarios

Grain Implementation

Grains implement both the grain interface and Orleans Grain base class:

// BankAccountActor.cs - Transactional State Example
namespace ConnectSoft.MicroserviceTemplate.ActorModel.Orleans
{
    using System;
    using System.Threading.Tasks;
    using global::Orleans;
    using global::Orleans.Runtime;
    using global::Orleans.Transactions;
    using Microsoft.Extensions.Logging;

    /// <summary>
    /// Bank account actor orleans based implementation using transactional state.
    /// </summary>
    /// <remarks>
    /// This example uses ITransactionalState for ACID transactions.
    /// For persistent state examples, see the State Management section.
    /// </remarks>
    [Reentrant]
    public class BankAccountActor(
        ILogger<BankAccountActor> logger,
        [TransactionalState(nameof(accountState), "TransactionStore")]
        ITransactionalState<BankAccountState> accountState)
        : Grain, IBankAccountGrain, IRemindable
    {
        public const string BankAccountActorsStoreName = "bankAccountActorsStore";
        public const string BankAccountActorReminderName = "bankAccountActorReminder";

        private readonly ILogger<BankAccountActor> logger = logger;
        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 Deposit(decimal amount)
        {
            return this.accountState.PerformUpdate(balance => balance.Balance += amount);
        }

        /// <inheritdoc/>
        public Task<decimal> GetBalance()
        {
            return this.accountState.PerformRead(balance => balance.Balance);
        }

        /// <summary>
        /// Receive a new reminder.
        /// </summary>
        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.Here(log => log.LogInformation(
                    message: "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.Here(log => log.LogInformation(
                message: "Process bank account actor reminder started with status : {Status}...", 
                status));
        }
    }
}

State Management

Persistent State Model

Grain state is managed via IPersistentState<T>:

// 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; }
    }
}

State Persistence Pattern

State is injected via constructor and managed transparently:

[PersistentState("bankAccountActorState", BankAccountActor.BankAccountActorsStoreName)]
IPersistentState<BankAccountState> accountState
  • First Parameter: Logical state name (unique per grain instance)
  • Second Parameter: Storage provider name (from configuration)

State Read/Write Lifecycle

// State is automatically loaded on grain activation
public override async Task OnActivateAsync()
{
    // accountState.State is already populated
    this.logger.LogInformation("Grain activated with balance: {Balance}", this.accountState.State.Balance);
}

// Update state and persist
public async Task<WithdrawOutput> Withdraw(WithdrawInput input)
{
    // Modify state
    this.accountState.State.Balance -= input.Amount;

    // Persist changes
    await this.accountState.WriteStateAsync();

    return new WithdrawOutput { Amount = input.Amount };
}

State Storage Providers

Supported storage providers:

Provider Invariant Use Case
SQL Server Microsoft.Data.SqlClient Production, reliable, transactional
PostgreSQL Npgsql Cross-platform, open-source
MySQL MySql.Data.MySqlClient MySQL databases
SQLite System.Data.SQLite Development, embedded
Redis Orleans.Providers.Redis High-performance, volatile
MongoDB Orleans.Providers.MongoDB Document-based, schema-less
Azure Table Orleans.Providers.AzureStorage Cloud-native, serverless

State Versioning & Migration

Best practices for state evolution:

Practice Rationale
Additive Changes Only Add new fields, never remove existing ones
Optional Fields Use nullable or default values for new fields
Migration Scripts Transform old state schema to new schema on read
Isolated State Models Keep state separate from business logic
Surrogates for Complex Types Use surrogates for domain objects in state

Surrogates for Serialization

Why Surrogates?

Orleans requires types to be serializable. Complex domain types may not serialize directly:

  • Value objects with behavior
  • Domain entities with navigation properties
  • Types with circular references
  • Framework-specific types (Entity Framework, etc.)

Surrogates provide a serialization-safe DTO layer.

Surrogate Definition

// 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>
    /// <remarks>
    /// Surrogates should use plain fields instead of properties for better performance.
    /// </remarks>
    [GenerateSerializer]
    [Alias("ConnectSoft.MicroserviceTemplate.ActorModel.Orleans.WithdrawInputSurrogate")]
    public struct WithdrawInputSurrogate
    {
        /// <summary>
        /// Gets or sets the amount.
        /// </summary>
        [Id(0)]
        public int Amount { get; set; }
    }
}

Surrogate Converter

Surrogates are converted via IConverter<T, TSurrogate>:

// WithdrawInputSurrogateConverter.cs
namespace ConnectSoft.MicroserviceTemplate.ActorModel.Orleans
{
    using global::Orleans;

    /// <summary>
    /// This is a converter that converts between the <see cref="WithdrawInputSurrogate"/> 
    /// and the foreign type of <see cref="WithdrawInput"/>.
    /// </summary>
    [RegisterConverter]
    public sealed class WithdrawInputSurrogateConverter : IConverter<WithdrawInput, WithdrawInputSurrogate>
    {
        /// <inheritdoc/>
        public WithdrawInput ConvertFromSurrogate(in WithdrawInputSurrogate surrogate)
        {
            return new WithdrawInput
            {
                Amount = surrogate.Amount,
            };
        }

        /// <inheritdoc/>
        public WithdrawInputSurrogate ConvertToSurrogate(in WithdrawInput value)
        {
            return new WithdrawInputSurrogate()
            {
                Amount = value.Amount,
            };
        }
    }
}

Surrogate Attributes

Attribute Purpose
[GenerateSerializer] Generates serialization code at compile-time
[Alias("Full.Type.Name")] Type alias for version-safe deserialization
[Id(n)] Field identifier for forward-compatible versioning
[RegisterConverter] Registers the converter with Orleans

Domain Types

// WithdrawInput.cs (ActorModel project)
namespace ConnectSoft.MicroserviceTemplate.ActorModel
{
    /// <summary>
    /// Withdraw input - transient entity used in actor layer components 
    /// as input argument(s).
    /// </summary>
    public struct WithdrawInput
    {
        /// <summary>
        /// Gets or sets the amount.
        /// </summary>
        public int Amount { get; set; }
    }
}

// WithdrawOutput.cs (ActorModel project)
namespace ConnectSoft.MicroserviceTemplate.ActorModel
{
    /// <summary>
    /// Withdraw output - transient entity used in actor layer components 
    /// as output argument(s).
    /// </summary>
    public struct WithdrawOutput
    {
        /// <summary>
        /// Gets or sets the amount.
        /// </summary>
        public int Amount { get; set; }
    }
}

Reminders & Timers

Reminders Overview

Orleans Reminders are durable, cluster-aware timers bound to grain identities:

  • Persistent: Survive silo restarts and failures
  • Cluster-Aware: Automatically resume after failover
  • Reliable: Backed by reminder storage (SQL Server, Azure Table, etc.)
  • Scalable: Distributed across cluster

IRemindable Interface

Grains implement IRemindable to receive reminder callbacks:

public class BankAccountActor : Grain<Guid>, IBankAccountGrain, IRemindable
{
    public Task ReceiveReminder(string reminderName, TickStatus status)
    {
        if (reminderName == BankAccountActorReminderName)
        {
            this.ProcessBankAccountActorReminder(status);
        }

        return Task.CompletedTask;
  }
}

Registering Reminders

Reminders are registered in grain lifecycle methods:

public override async Task OnActivateAsync()
{
    // Register a reminder that fires every 24 hours
    await this.RegisterOrUpdateReminder(
        reminderName: BankAccountActorReminderName,
        dueTime: TimeSpan.FromMinutes(30),  // First trigger after 30 minutes
        period: TimeSpan.FromHours(24));     // Repeat every 24 hours
}

// Or register dynamically
public async Task ScheduleReconciliation()
{
    await this.RegisterOrUpdateReminder(
        reminderName: "ReconciliationReminder",
        dueTime: TimeSpan.FromHours(1),
        period: TimeSpan.FromDays(1));
}

Reminder vs Timer

Feature Reminder Timer
Persistent ✅ Yes ❌ In-memory only
Survives Restart ✅ Yes ❌ No
Cluster-Aware ✅ Yes ❌ No
Durable ✅ Yes ❌ No
Performance Lower (persistence overhead) Higher (in-memory)

Recommendation: Use Reminders for durable operations, Timers for temporary, in-memory operations.

Reminder Configuration

Reminders are stored in a dedicated database table:

private static void ConfigureAdoNetReminderService(AdoNetReminderTableOptions options)
{
    options.ConnectionString = OptionsExtensions.OrleansOptions.AdoNetGrainReminderTable.ConnectionString;
    options.Invariant = OptionsExtensions.OrleansOptions.AdoNetGrainReminderTable.Invariant;

    if (string.Equals(options.Invariant, "System.Data.SqlClient", StringComparison.Ordinal) ||
        string.Equals(options.Invariant, "Microsoft.Data.SqlClient", StringComparison.Ordinal))
    {
        SqlServerDatabaseHelper databaseHelper = new();
        databaseHelper.CreateIfNotExists(options.ConnectionString);

        // Load and execute Orleans reminders SQL script
        string remindersSql = File.ReadAllText(
            Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Scripts", "OrleansScripts", "SqlServer", "SQLServer-Reminders.sql"));

        using SqlConnection connection = new(options.ConnectionString);
        connection.Open();

        using SqlCommand command = new(remindersSql, connection);
        command.ExecuteNonQuery();
    }
}

Clustering

Overview

Orleans clustering enables multiple silos to discover each other, form a cluster, detect failures, and manage membership. The ConnectSoft Microservice Template supports multiple clustering providers that can be configured via the Orleans:ClusteringSettings section in appsettings.json.

Clustering Modes

The template supports the following clustering providers:

Provider Configuration Use Case
Localhost In-memory emulation Development, single-node testing (default)
ADO.NET UseAdoNetClustering() Production, supports SQL Server, PostgreSQL, MySQL, Oracle
Redis UseRedisClustering() High-performance, fast membership
Azure Table Storage UseAzureStorageClustering() Cloud-native, Azure-hosted applications

Configuration

Clustering is configured via the Orleans:ClusteringSettings section in appsettings.json:

{
  "Orleans": {
    "ClusteringSettings": {
      "ProviderType": "Localhost",
      "AzureTableStorage": { ... },
      "AdoNet": { ... },
      "Redis": { ... }
    }
  }
}

Localhost Clustering (Development/Testing)

The Localhost provider type uses in-memory emulation via a special system grain. This is the default for development and testing.

Configuration:

{
  "Orleans": {
    "ClusteringSettings": {
      "ProviderType": "Localhost"
    }
  }
}

Characteristics: - No external dependencies required - Ideal for development and single-node testing - Not suitable for production - Automatic silo and gateway port assignment

Azure Table Storage Clustering

Azure Table Storage clustering is ideal for Azure-hosted applications and supports both connection string and managed identity authentication.

Configuration with Connection String:

{
  "Orleans": {
    "ClusteringSettings": {
      "ProviderType": "AzureTableStorage",
      "AzureTableStorage": {
        "ConnectionString": "DefaultEndpointsProtocol=https;AccountName=mystorageaccount;AccountKey=mykey;EndpointSuffix=core.windows.net",
        "TableName": "OrleansMembershipTable",
        "UseManagedIdentity": false
      }
    }
  }
}

Configuration with Managed Identity:

{
  "Orleans": {
    "ClusteringSettings": {
      "ProviderType": "AzureTableStorage",
      "AzureTableStorage": {
        "ConnectionString": null,
        "TableName": "OrleansMembershipTable",
        "UseManagedIdentity": true,
        "ManagedIdentityClientId": "client-id-here"
      }
    }
  }
}

Characteristics: - Cloud-native, serverless-friendly - Supports managed identity for passwordless authentication - Scalable and reliable - Automatic table creation

For detailed setup instructions, see Orleans Clustering - Azure Table Storage.

ADO.NET Clustering

ADO.NET clustering supports relational databases (SQL Server, PostgreSQL, MySQL/MariaDB, Oracle) and is ideal for production environments requiring transactional consistency.

Configuration:

{
  "Orleans": {
    "ClusteringSettings": {
      "ProviderType": "AdoNet",
      "AdoNet": {
        "ConnectionString": "Server=localhost;Database=MICROSERVICE_ORLEANS_DATABASE;User Id=sa;Password=YourPassword;",
        "Invariant": "Microsoft.Data.SqlClient",
        "TableName": "OrleansMembershipTable"
      }
    }
  }
}

Characteristics: - Production-ready with transactional membership table - Reliable failover detection - Supports multiple silos - Database scripts are automatically executed on startup

Supported Database Providers: - Microsoft.Data.SqlClient - SQL Server (.NET Core) - System.Data.SqlClient - SQL Server (.NET Framework) - Npgsql - PostgreSQL - MySql.Data.MySqlClient - MySQL/MariaDB - Oracle.DataAccess.Client - Oracle Database

For detailed setup instructions, see Orleans Clustering - ADO.NET.

Redis Clustering

Redis clustering provides high-performance membership management with fast failover detection.

Configuration:

{
  "Orleans": {
    "ClusteringSettings": {
      "ProviderType": "Redis",
      "Redis": {
        "ConnectionString": "localhost:6379",
        "Database": 0
      }
    }
  }
}

Characteristics: - High-performance membership operations - Fast failover detection - Supports multiple silos - Redis must be accessible from all silos

For detailed setup instructions, see Orleans Clustering - Redis.

Cluster Configuration

{
"Orleans": {
    "Cluster": {
      "ClusterId": "dev",
      "ServiceId": "ConnectSoft.MicroserviceTemplate"
    }
  }
}
  • ClusterId: Unique identifier for the cluster (dev, staging, prod)
  • ServiceId: Service identifier (typically application name)

Silo Endpoints

services.Configure<EndpointOptions>(endpoint =>
{
    endpoint.SiloPort = 11111;           // Silo-to-silo communication
    endpoint.GatewayPort = 30000;        // Client gateway
    endpoint.AdvertisedIPAddress = IPAddress.Loopback;

    if (listenOnAnyHostAddress)
{
        endpoint.SiloListeningEndpoint = new IPEndPoint(IPAddress.Any, endpoint.SiloPort);
        endpoint.GatewayListeningEndpoint = new IPEndPoint(IPAddress.Any, endpoint.GatewayPort);
    }
});

Health Checks

Orleans Health Check Grains

ConnectSoft implements grain-based health checks:

Health Check Purpose
OrleansGrainHealthCheck Verifies grain activation and routing
OrleansStorageHealthCheck Tests persistent state read/write
OrleansClusterHealthCheck Validates cluster membership
OrleansSiloHealthCheck Checks silo service health

Grain Health Check Implementation

// OrleansGrainHealthCheck.cs
public class OrleansGrainHealthCheck(IClusterClient client, ILogger<OrleansGrainHealthCheck> logger)
    : IHealthCheck
{
    private const string FailedMessage = "Failed local health check.";
    private readonly IClusterClient client = client;
    private readonly ILogger<OrleansGrainHealthCheck> logger = logger;

    public async Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context,
        CancellationToken cancellationToken = default)
    {
        try
        {
            await this.client.GetGrain<ILocalHealthCheckGrain>(Guid.Empty).CheckAsync()
                .ConfigureAwait(false);
        }
        catch (Exception exception)
        {
            this.logger.LogError(exception: exception, FailedMessage);
            return HealthCheckResult.Unhealthy(FailedMessage, exception);
        }

        return HealthCheckResult.Healthy();
    }
}

Local Health Check Grain

// LocalHealthCheckGrain.cs
[StatelessWorker(1)]
public class LocalHealthCheckGrain : Grain, ILocalHealthCheckGrain
{
    /// <inheritdoc/>
    public ValueTask CheckAsync() => ValueTask.CompletedTask;
}
  • [StatelessWorker(1)]: Stateless worker grain (no persistence)
  • Lightweight ping operation
  • Verifies grain activation and routing

Cluster Health Check

// OrleansClusterHealthCheck.cs
public class OrleansClusterHealthCheck(IClusterClient client, ILogger<OrleansClusterHealthCheck> logger)
    : IHealthCheck
{
    private const string DegradedMessage = " silo(s) unavailable.";
    private const string FailedMessage = "Failed cluster status health check.";

    public async Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context,
        CancellationToken cancellationToken = default)
    {
        var manager = this.client.GetGrain<IManagementGrain>(0);

        try
        {
            var hosts = await manager.GetHosts().ConfigureAwait(false);
            var count = hosts.Values.Count(x => x.IsTerminating() || x == SiloStatus.None);
            return count > 0 
                ? HealthCheckResult.Degraded(count + DegradedMessage) 
                : HealthCheckResult.Healthy();
        }
        catch (Exception exception)
        {
            this.logger.LogError(exception: exception, FailedMessage);
            return HealthCheckResult.Unhealthy(FailedMessage, exception);
        }
    }
}

Storage Health Check

// OrleansStorageHealthCheck.cs
public class OrleansStorageHealthCheck(IClusterClient client, ILogger<OrleansStorageHealthCheck> logger)
    : IHealthCheck
{
    public async Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context,
        CancellationToken cancellationToken = default)
    {
        try
        {
            // Call grain with random key each time to test storage
            await this.client.GetGrain<IStorageHealthCheckGrain>(Guid.NewGuid())
                .CheckAsync()
                .ConfigureAwait(false);
        }
        catch (Exception exception)
        {
            this.logger.LogError(exception: exception, "Failed storage health check.");
            return HealthCheckResult.Unhealthy("Failed storage health check.", exception);
        }

        return HealthCheckResult.Healthy();
    }
}

Health Check Registration

Health checks are registered via extension methods:

// HealthChecksExtensions.cs
#if UseOrleans
builder.AddOrleansGrainHealthCheck();
builder.AddOrleansStorageHealthCheck();
builder.AddOrleansClusterHealthCheck();
builder.AddOrleansSiloHealthCheck();
#endif

Observability

Structured Logging

Grains use ILogger<T> with scoped context:

using (this.logger.BeginScope(
    new Dictionary<string, object>(StringComparer.Ordinal)
    {
        ["ApplicationFlowName"] = nameof(BankAccountActor) + "/" + nameof(this.Withdraw),
    }))
{
    this.logger.Here(log => log.LogInformation(
        message: "Withdraw the given amount from the bank account with {ActorId} started...", 
        actorId));
    // ... operation ...
}

Metrics

Custom metrics track grain operations:

// BankAccountActorMetrics.cs
public class BankAccountActorMetrics
{
    public const string BankAccountActorMetricsMeterName = "ConnectSoft.MicroserviceTemplate.BankAccountActor";

    private readonly Counter<long> withdrawSuccesses;
    private readonly Counter<long> withdrawFailures;
    private readonly Histogram<double> withdrawAmount;
    private readonly Histogram<double> withdrawDuration;
    private readonly UpDownCounter<long> instances;

    public BankAccountActorMetrics(IMeterFactory meterFactory)
    {
        var meter = meterFactory.Create(BankAccountActorMetricsMeterName);

        this.withdrawSuccesses = meter.CreateCounter<long>("bankaccount.withdraw.successes");
        this.withdrawFailures = meter.CreateCounter<long>("bankaccount.withdraw.failures");
        this.withdrawAmount = meter.CreateHistogram<double>("bankaccount.withdraw.amount");
        this.withdrawDuration = meter.CreateHistogram<double>("bankaccount.withdraw.duration");
        this.instances = meter.CreateUpDownCounter<long>("bankaccount.instances");
    }

    public void RecordWithdrawSuccess(
        int amount, 
        TimeSpan duration, 
        string actorId, 
        string reason)
    {
        this.withdrawSuccesses.Add(1, 
            new("actor_id", actorId), 
            new("reason", reason));
        this.withdrawAmount.Record(amount, 
            new("actor_id", actorId));
        this.withdrawDuration.Record(duration.TotalMilliseconds, 
            new("actor_id", actorId));
    }
}

OpenTelemetry Integration

Orleans integrates with OpenTelemetry for distributed tracing:

// OpenTelemetryExtensions.cs
services.AddOpenTelemetry()
    .WithTracing(tracerProviderBuilder =>
    {
        tracerProviderBuilder
            .AddSource("Microsoft.Orleans.Runtime")
            .AddSource("Microsoft.Orleans.Application")
            .AddSource("ConnectSoft.MicroserviceTemplate.BankAccountActor");
    })
    .WithMetrics(metricsBuilder =>
    {
        metricsBuilder
            .AddMeter("Microsoft.Orleans")
            .AddMeter("ConnectSoft.MicroserviceTemplate.BankAccountActor");
    });

Activity propagation is enabled:

siloBuilder.AddActivityPropagation();

This ensures: - Trace context propagation across grain calls - Automatic span creation for grain methods - Correlation with HTTP/gRPC requests

Orleans Dashboard

Dashboard Configuration

The Orleans Dashboard provides real-time diagnostics:

// OrleansExtensions.cs
internal static IApplicationBuilder MapOrleansDashboard(this IApplicationBuilder application)
{
    ArgumentNullException.ThrowIfNull(application);

    if (OptionsExtensions.OrleansOptions.Dashboard.HostSelf)
    {
        application.Map(OptionsExtensions.OrleansOptions.Dashboard.BasePath, d =>
        {
            d.UseOrleansDashboard(new DashboardOptions()
            {
                Password = OptionsExtensions.OrleansOptions.Dashboard.Password,
                Username = OptionsExtensions.OrleansOptions.Dashboard.Username,
                CounterUpdateIntervalMs = OptionsExtensions.OrleansOptions.Dashboard.CounterUpdateIntervalMs,
                CustomCssPath = OptionsExtensions.OrleansOptions.Dashboard.CustomCssPath,
                HideTrace = OptionsExtensions.OrleansOptions.Dashboard.HideTrace,
                HistoryLength = OptionsExtensions.OrleansOptions.Dashboard.HistoryLength,
                Host = OptionsExtensions.OrleansOptions.Dashboard.Host,
                HostSelf = OptionsExtensions.OrleansOptions.Dashboard.HostSelf,
                Port = OptionsExtensions.OrleansOptions.Dashboard.Port,
                ScriptPath = OptionsExtensions.OrleansOptions.Dashboard.ScriptPath,
            });
        });
    }

    return application;
}

Dashboard Features

Feature Description
Silos Live view of cluster nodes, activation count, faults
Grains Types, activation counts, placement, method stats
Calls/sec Per-grain method call rate (heatmap)
Queue Depth Pending messages inside each grain
Exceptions Logged errors across all silos
Timings Method execution latency distribution

Accessing the Dashboard

  • URL: http://localhost:8080/dashboard (configurable)
  • Basic Auth: Username/password from configuration
  • Production: Disable HostSelf or secure behind reverse proxy

Security Considerations

  • Expose only on internal port in production
  • Use basic authentication or firewall rules
  • Consider reverse proxy with TLS (Nginx, App Gateway)
  • Disable in production if not needed: "HostSelf": false

Testing

Unit Testing

Grains can be tested with mocked dependencies:

// BankAccountActorTests.cs
[TestClass]
public class BankAccountActorTests
{
    private TestCluster? cluster;

    [TestInitialize]
    public async Task Initialize()
    {
        var builder = new TestClusterBuilder();
        builder.AddSiloBuilderConfigurator<TestSiloConfigurator>();
        this.cluster = builder.Build();
        await this.cluster.DeployAsync();
    }

    [TestMethod]
    public async Task Withdraw_Should_Update_Balance()
    {
        var grain = this.cluster!.GrainFactory.GetGrain<IBankAccountGrain>(Guid.NewGuid());
        var input = new WithdrawInput { Amount = 100 };

        var output = await grain.Withdraw(input);

        Assert.AreEqual(100, output.Amount);
    }
}

Test Silo Configurator

// TestSiloConfigurator.cs
public class TestSiloConfigurator : ISiloConfigurator
{
    public void Configure(ISiloBuilder siloBuilder)
    {
        siloBuilder
            .AddMemoryGrainStorage(BankAccountActor.BankAccountActorsStoreName)
            .UseLocalhostClustering();

        siloBuilder.ConfigureServices(services =>
        {
            services.AddSingleton<BankAccountActorMetrics>();
            services.ActivateSingleton<BankAccountActorMetrics>();
        });
    }
}

Acceptance Testing with Reqnroll

Reqnroll (SpecFlow-compatible) acceptance tests validate grain behavior:

// BankAccountActorUsingOrleansStepDefinition.cs
[Binding]
public sealed class BankAccountActorUsingOrleansStepDefinition
{
    private readonly Guid primaryKey = Guid.NewGuid();
    private WithdrawInput withdrawInput = new();
    private WithdrawOutput withdrawOutput = new();

    [Given(@"I have a valid Withdraw the given amount from the bank account input")]
    [Scope(Feature = "Bank account actor feature using Orleans")]
    public void GivenIHaveAValidBankAccountActorWithdrawInput()
    {
        this.withdrawInput.Amount = 100;
    }

    [When(@"i send the input to the Orleans Bank account actor")]
    [Scope(Feature = "Bank account actor feature using Orleans")]
    public async Task WhenISendTheInputToTheOrleansBankAccountActor()
    {
        IGrainFactory? grains = BeforeAfterTestRunHooks.ServerInstance?.Services
            .GetRequiredService<IGrainFactory>();
        Assert.IsNotNull(grains);

        IBankAccountGrain actor = grains.GetGrain<IBankAccountGrain>(this.primaryKey);
        this.withdrawOutput = await actor.Withdraw(this.withdrawInput).ConfigureAwait(false);
    }

    [Then(@"i should receive a valid actor response")]
    public void ThenIShouldReceiveAValidActorResponse()
    {
        Assert.IsNotNull(this.withdrawOutput);
        Assert.AreEqual(this.withdrawInput.Amount, this.withdrawOutput.Amount);
    }
}

Feature File

Feature: BankAccountGrain
  Scenario: Withdraw money successfully
    Given I have a valid Withdraw the given amount from the bank account input
    When i send the input to the Orleans Bank account actor
    Then i should receive a valid actor response

Transactions

Overview

Orleans Transactions provide distributed ACID transactions across multiple grains, ensuring consistent state management even when operations span multiple actors in a cluster. Transactions in Orleans are:

  • Distributed: Operations can span multiple grains across different silos
  • ACID Compliant: Atomicity, Consistency, Isolation, and Durability guarantees
  • Serializable Isolation: Highest isolation level for transaction safety
  • Decentralized: No central transaction coordinator required

Transaction Support

Orleans transactions are opt-in. Both the silo and client must be configured to use transactions. Transactions use a specialized storage abstraction (ITransactionalStateStorage<TState>) separate from regular grain storage.

Configuration

Enable Transactions in Silo

Transactions are enabled in the silo configuration:

// OrleansExtensions.cs
private static void ConfigureSiloBuilder(HostBuilderContext context, ISiloBuilder siloBuilder)
{
    // ... other configuration ...

    // Enable Orleans transactions
    siloBuilder.UseTransactions();

    // Configure transactional state storage
    ConfigureTransactionalStateStorage(context.Configuration, siloBuilder);
}

Transactional State Storage Configuration

Transactional state storage is configured via Orleans:TransactionalStateStorage in appsettings.json:

{
  "Orleans": {
    "TransactionalStateStorage": {
      "ProviderType": "Memory",
      "StorageName": "TransactionStore",
      "AzureTableStorage": {
        "ConnectionString": null,
        "UseManagedIdentity": false,
        "ManagedIdentityClientId": null
      },
    }
  }
}

Supported Storage Providers:

Provider Configuration Use Case
Memory In-memory storage Development/testing only (not for production)
Azure Table Storage AddAzureTableTransactionalStateStorage() Cloud-native, Azure-hosted applications

Production Storage

For production, use Azure Table Storage or ensure proper transactional state storage is configured. Memory storage is only suitable for development and testing.

Transactional State Model

State Object

Transactional state objects must use Orleans serialization attributes:

// BankAccountState.cs
namespace ConnectSoft.MicroserviceTemplate.ActorModel.Orleans
{
    using Orleans.Serialization;

    /// <summary>
    /// Bank account state.
    /// </summary>
    [GenerateSerializer]
    public class BankAccountState
    {
        /// <summary>
        /// Gets or sets a balance.
        /// </summary>
        [Id(0)]
        public decimal Balance { get; set; } = 1_000;
    }
}

Key Requirements: - Use [GenerateSerializer] attribute on the state class - Use [Id(n)] attributes on properties for serialization - State must be a class (not a struct)

Grain Interface

Transactional methods are declared in the Orleans-specific grain interface with [Transaction] attributes:

// IBankAccountGrain.cs
namespace ConnectSoft.MicroserviceTemplate.ActorModel.Orleans
{
    using System.Threading.Tasks;
    using global::Orleans;
    using global::Orleans.Transactions;

    /// <summary>
    /// Bank account actor orleans specific adapting grain interface.
    /// </summary>
    /// <remarks>
    /// This interface redeclares methods from <see cref="IBankAccountActor"/> with Orleans-specific
    /// transaction attributes, keeping the base interface framework-agnostic.
    /// </remarks>
    public interface IBankAccountGrain : IGrainWithGuidKey, IBankAccountActor
    {
        /// <summary>
        /// Withdraw the given amount from the bank account.
        /// Redeclared from <see cref="IBankAccountActor"/> with transaction attribute.
        /// </summary>
        [Transaction(TransactionOption.Join)]
        new Task<WithdrawOutput> Withdraw(WithdrawInput input);

        /// <summary>
        /// Deposit the given amount to the bank account.
        /// </summary>
        [Transaction(TransactionOption.Join)]
        Task Deposit(decimal amount);

        /// <summary>
        /// Get the current balance of the bank account.
        /// </summary>
        [Transaction(TransactionOption.CreateOrJoin)]
        Task<decimal> GetBalance();
    }
}

Transaction Options: - TransactionOption.Create: Creates a new transaction (for orchestrating grains) - TransactionOption.Join: Joins an existing transaction if present, otherwise creates one - TransactionOption.CreateOrJoin: Creates or joins a transaction (for read operations)

Grain Implementation

Transactional grains use ITransactionalState<TState> instead of IPersistentState<TState>:

// BankAccountActor.cs
namespace ConnectSoft.MicroserviceTemplate.ActorModel.Orleans
{
    using System;
    using System.Threading.Tasks;
    using global::Orleans;
    using global::Orleans.Transactions;
    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, IRemindable
    {
        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 Deposit(decimal amount)
        {
            return this.accountState.PerformUpdate(balance => balance.Balance += amount);
        }

        /// <inheritdoc/>
        public Task<decimal> GetBalance()
        {
            return this.accountState.PerformRead(balance => balance.Balance);
        }
    }
}

Key Points: - Use [Reentrant] attribute on transactional grains to allow concurrent transaction execution - Inject ITransactionalState<TState> with [TransactionalState(name, storageName)] attribute - Use PerformUpdate() for state modifications (automatically persisted) - Use PerformRead() for read-only operations - State modifications are automatically rolled back if an exception is thrown

Using Transactions

From Client Code

Use ITransactionClient to create transaction contexts:

public class TransferService
{
    private readonly IGrainFactory grainFactory;
    private readonly ITransactionClient transactionClient;

    public TransferService(IGrainFactory grainFactory, ITransactionClient transactionClient)
    {
        this.grainFactory = grainFactory;
        this.transactionClient = transactionClient;
    }

    public async Task TransferFunds(Guid fromAccountId, Guid toAccountId, decimal amount)
    {
        var fromAccount = this.grainFactory.GetGrain<IBankAccountGrain>(fromAccountId);
        var toAccount = this.grainFactory.GetGrain<IBankAccountGrain>(toAccountId);

        // Both operations participate in the same transaction
        await this.transactionClient.RunTransaction(
            TransactionOption.Create,
            async () =>
            {
                await fromAccount.Withdraw(new WithdrawInput { Amount = (int)amount });
                await toAccount.Deposit(amount);
            });
    }
}

From Grain Code

Grains can call other transactional grains directly—the transaction context is automatically propagated:

[Reentrant]
public class AtmGrain : Grain, IAtmGrain
{
    [Transaction(TransactionOption.Create)]
    public Task Transfer(Guid fromAccountId, Guid toAccountId, decimal amount)
    {
        var fromAccount = this.GrainFactory.GetGrain<IBankAccountGrain>(fromAccountId);
        var toAccount = this.GrainFactory.GetGrain<IBankAccountGrain>(toAccountId);

        // Transaction context is automatically propagated
        return Task.WhenAll(
            fromAccount.Withdraw(new WithdrawInput { Amount = (int)amount }),
            toAccount.Deposit(amount));
    }
}

Transaction Lifecycle

  1. Transaction Creation: Created when a method with TransactionOption.Create or TransactionOption.CreateOrJoin is called
  2. Transaction Propagation: Automatically propagated to all grain calls within the transaction
  3. State Modifications: All state changes are buffered until commit
  4. Commit: Transaction commits when the creating method completes successfully
  5. Rollback: Transaction rolls back if any exception is thrown or if explicitly aborted

Best Practices

  1. Keep Transactions Short
  2. Long-running transactions can cause contention
  3. Prefer multiple smaller transactions over one large transaction

  4. Use Appropriate Transaction Options

  5. Create: For orchestrating grains (e.g., transfer operations)
  6. Join: For operations that should participate in existing transactions
  7. CreateOrJoin: For read operations that may or may not be in a transaction

  8. Handle Exceptions Properly

  9. Exceptions automatically roll back transactions
  10. Ensure business logic validates state before modifying it

  11. Use Reentrant Grains

  12. Mark transactional grains with [Reentrant] to allow concurrent transaction execution
  13. Required for proper transaction handling

  14. Framework-Agnostic Base Interfaces

  15. Keep base actor interfaces (e.g., IBankAccountActor) framework-agnostic
  16. Redeclare methods in Orleans-specific interfaces (e.g., IBankAccountGrain) with transaction attributes

Testing Transactions

Transactional grains can be tested using acceptance tests:

[When(@"I transfer (\d+) from source to destination account")]
public async Task WhenITransferAmountFromSourceToDestinationAccount(decimal amount)
{
    IGrainFactory? grains = BeforeAfterTestRunHooks.ServerInstance?.Services
        .GetRequiredService<IGrainFactory>();
    ITransactionClient? transactionClient = BeforeAfterTestRunHooks.ServerInstance?.Services
        .GetService<ITransactionClient>();

    IBankAccountGrain sourceActor = grains.GetGrain<IBankAccountGrain>(this.sourceAccountKey);
    IBankAccountGrain destinationActor = grains.GetGrain<IBankAccountGrain>(this.destinationAccountKey);

    // Perform transfer within a transaction
    await transactionClient.RunTransaction(
        TransactionOption.Create,
        async () =>
        {
            await sourceActor.Withdraw(new WithdrawInput { Amount = (int)amount });
            await destinationActor.Deposit(amount);
        });
}

Invoking Grains

From Application Layer

Grains are accessed via IGrainFactory:

public class MicroserviceAggregateRootsProcessor : IMicroserviceAggregateRootsProcessor
{
    private readonly IGrainFactory grainFactory;

    public MicroserviceAggregateRootsProcessor(IGrainFactory grainFactory)
    {
        this.grainFactory = grainFactory;
    }

    public async Task<IMicroserviceAggregateRoot> CreateMicroserviceAggregateRoot(
        CreateMicroserviceAggregateRootInput input,
        CancellationToken token = default)
    {
        // Get grain by identity
        var grain = this.grainFactory.GetGrain<IBankAccountGrain>(input.AccountId);

        // Invoke grain method
        var result = await grain.Withdraw(new WithdrawInput { Amount = 100 });

        // Process result
        return aggregate;
    }
}

From Controllers

Grains can be invoked directly from REST/gRPC controllers:

[ApiController]
[Route("api/[controller]")]
public class BankAccountController : ControllerBase
{
    private readonly IGrainFactory grainFactory;

    [HttpPost("{accountId}/withdraw")]
    public async Task<IActionResult> Withdraw(
        Guid accountId,
        [FromBody] WithdrawRequest request)
    {
        var grain = this.grainFactory.GetGrain<IBankAccountGrain>(accountId);
        var output = await grain.Withdraw(new WithdrawInput 
        { 
            Amount = request.Amount 
        });

        return Ok(output);
    }
}

Best Practices

Do's

  1. Use Surrogates for Grain Contracts
  2. Always use [GenerateSerializer] and [Id(n)] attributes
  3. Define surrogates for complex domain types
  4. Use [RegisterConverter] for type conversion

  5. Encapsulate Behavior in Grain Logic

  6. Keep business logic inside grains
  7. Grains own state and enforce invariants
  8. Use rich domain models within grains

  9. Keep Grains Stateless or Cohesively Stateful

  10. Use [StatelessWorker] for utility grains
  11. One IPersistentState<T> per grain per domain boundary
  12. Avoid sharing state between grains

  13. Prefer Grain Identity for Partitioning

  14. Use appropriate identity type (IGrainWithGuidKey, IGrainWithStringKey, etc.)
  15. Enables sharding and scale-out
  16. Supports multi-tenancy patterns

  17. Use Reminders for Reliable Scheduling

  18. Prefer IRemindable over timers for durable operations
  19. Register reminders in OnActivateAsync()
  20. Keep reminder execution short

  21. Always Test Grains via Contracts

  22. Abstract through interfaces (e.g., IBankAccountActor)
  23. Never test grain internals directly
  24. Test input/output DTOs and surrogates

  25. Never Use ConfigureAwait(false) in Grain Methods ```csharp // ❌ BAD await this.accountState.WriteStateAsync().ConfigureAwait(false);

// ✅ GOOD await this.accountState.WriteStateAsync(); ``` Grain methods must run on the Orleans grain scheduler.

  1. Integrate Observability Early
  2. Use structured logging with scoped context
  3. Emit custom metrics via IMeterFactory
  4. Enable OpenTelemetry for distributed tracing

Don'ts

Anti-Pattern Why It Fails
Sharing State Between Grains Grains must be isolated and concurrent-safe
Calling Grains Synchronously Can cause deadlocks—always await
Fat Grains Don't turn grains into monoliths or service aggregators
Excessive Grain Chaining Can slow down system—use aggregates or dispatchers
Logging Every Method Call Pollutes logs—prefer metrics or sampled logs
Hardcoding Grain Names Use typed grain factories and config-bound identity models
Blocking Calls in Grains Always use async/await, never .Result or .Wait()

Troubleshooting

Common Issues

Issue Symptom Solution
Grain Not Activating Timeout or activation failed Check storage connection, verify grain interface
State Not Persisting Changes lost after deactivation Verify WriteStateAsync() called, check storage connection
Reminder Not Firing Reminder not triggered Check reminder storage connection, verify registration
Performance Degradation Slow grain execution Review scheduling options, check thread pool starvation warnings
Cluster Issues Silo not joining cluster Verify clustering provider connection, check ClusterId/ServiceId
Serialization Errors Type not serializable Use surrogates for complex types, verify [GenerateSerializer] attributes

Debugging Tips

  1. Enable Orleans Dashboard for real-time diagnostics
  2. Check Logs for grain activation, deactivation, and errors
  3. Monitor Metrics for grain method execution times
  4. Use OpenTelemetry Traces for end-to-end request tracking
  5. Review Scheduling Warnings for thread pool starvation

Summary

Microsoft Orleans in the ConnectSoft Microservice Template provides:

  • Virtual Actors: Automatic lifecycle management and on-demand activation
  • Stateful Services: Persistent state with IPersistentState<T> and transactional state with ITransactionalState<T>
  • Distributed Transactions: ACID transactions across multiple grains with serializable isolation
  • Distributed Execution: Cluster-aware grains with automatic routing
  • Scalability: Horizontal scaling with stateless workers and sharding
  • Reliability: Durable reminders, automatic failover, and recovery
  • Observability: Integrated logging, metrics, tracing, and dashboard
  • Cloud Ready: SQL Server, Redis, Azure Table storage providers
  • Testability: In-memory test clusters and acceptance testing

By following these patterns, microservices achieve:

  • Scalability — Horizontal scaling with automatic grain distribution
  • Reliability — Automatic failover and state recovery
  • Performance — Efficient message-based concurrency
  • Simplicity — Focus on business logic, Orleans handles distribution
  • Observability — Comprehensive diagnostics and monitoring
  • Testability — In-memory test clusters and grain mocking

The Orleans integration ensures that ConnectSoft microservices can scale horizontally, maintain state reliably, and provide excellent observability across any deployment environment.