Learnixo
Back to blog
Backend Systemsadvanced

Orleans in .NET: Virtual Actors for Distributed Systems

Build distributed, stateful systems with Microsoft Orleans. Covers grains, silos, grain state persistence, timers, reminders, streams, cluster configuration, and real use cases for the actor model.

LearnixoJune 4, 20266 min read
.NETC#OrleansActor ModelDistributed SystemsReal-TimeCloud
Share:𝕏

What is Orleans?

Orleans is Microsoft's virtual actor framework for .NET. It was originally built for Halo's matchmaking backend and is now used for Xbox, Azure, and many large-scale .NET systems.

The core idea: every entity in your system is a grain (actor). Grains are activated on demand, run single-threaded (no locking needed), and are automatically placed across a cluster. You call them by ID — Orleans handles location, activation, and failure.

When to use Orleans:

  • Per-entity stateful logic: user sessions, game state, IoT device twins, order workflows
  • High concurrency with shared mutable state — actors eliminate lock contention
  • Real-time at scale: chat rooms, live scores, collaborative editing
  • Workflow orchestration that outlives a single request

When not to use Orleans:

  • Simple stateless CRUD APIs
  • Batch processing (use Hangfire/Quartz)
  • If your team isn't familiar with actor model concepts

Setup

Bash
dotnet new console -n OrderFlow.Silo
dotnet add package Microsoft.Orleans.Server
dotnet add package Microsoft.Orleans.Client
dotnet add package Microsoft.Orleans.Persistence.AdoNet  # or CosmosDB, Redis, etc.

Grains

A grain is an interface + implementation. Orleans activates it on first call and keeps it in memory.

C#
// Interfaces/IOrderGrain.cs
public interface IOrderGrain : IGrainWithGuidKey
{
    Task<OrderState> GetStateAsync();
    Task AddLineAsync(OrderLineDto line);
    Task SubmitAsync();
    Task<string> GetStatusAsync();
}

// Grains/OrderGrain.cs
public class OrderGrain : Grain, IOrderGrain
{
    private readonly IPersistentState<OrderState> _state;
    private readonly ILogger<OrderGrain> _logger;

    public OrderGrain(
        [PersistentState("order", "orderStore")] IPersistentState<OrderState> state,
        ILogger<OrderGrain> logger)
    {
        _state  = state;
        _logger = logger;
    }

    public Task<OrderState> GetStateAsync() => Task.FromResult(_state.State);

    public async Task AddLineAsync(OrderLineDto line)
    {
        if (_state.State.Status != "Pending")
            throw new InvalidOperationException("Cannot modify a non-pending order.");

        _state.State.Lines.Add(line);
        _state.State.Total += line.Quantity * line.UnitPrice;
        await _state.WriteStateAsync();
    }

    public async Task SubmitAsync()
    {
        if (!_state.State.Lines.Any())
            throw new InvalidOperationException("Cannot submit empty order.");

        _state.State.Status = "Submitted";
        _state.State.SubmittedAt = DateTime.UtcNow;
        await _state.WriteStateAsync();

        _logger.LogInformation("Order {OrderId} submitted", this.GetPrimaryKey());

        // Notify another grain
        var customerGrain = GrainFactory.GetGrain<ICustomerGrain>(_state.State.CustomerId);
        await customerGrain.NotifyOrderSubmittedAsync(this.GetPrimaryKey());
    }

    public Task<string> GetStatusAsync() => Task.FromResult(_state.State.Status);
}

// State class — what's persisted
public class OrderState
{
    public Guid CustomerId { get; set; }
    public string Status { get; set; } = "Pending";
    public List<OrderLineDto> Lines { get; set; } = new();
    public decimal Total { get; set; }
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    public DateTime? SubmittedAt { get; set; }
}

Timers and Reminders

Timers — fire while the grain is active. Lost if the grain deactivates.

C#
public override Task OnActivateAsync(CancellationToken ct)
{
    // Check for timeout every 30 seconds (grain must be active)
    RegisterTimer(CheckTimeoutAsync, null, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30));
    return base.OnActivateAsync(ct);
}

private async Task CheckTimeoutAsync(object _)
{
    if (_state.State.Status == "Pending" &&
        DateTime.UtcNow - _state.State.CreatedAt > TimeSpan.FromHours(24))
    {
        _state.State.Status = "Expired";
        await _state.WriteStateAsync();
    }
}

Reminders — durable, fire even after grain deactivation and silo restart.

C#
public class OrderGrain : Grain, IOrderGrain, IRemindable
{
    public async Task SubmitAsync()
    {
        _state.State.Status = "Submitted";
        await _state.WriteStateAsync();

        // Schedule payment check in 15 minutes — survives silo restart
        await this.RegisterOrUpdateReminder(
            "payment-check",
            TimeSpan.FromMinutes(15),
            TimeSpan.FromMinutes(15));
    }

    public async Task ReceiveReminder(string reminderName, TickStatus status)
    {
        if (reminderName == "payment-check")
        {
            var paymentGrain = GrainFactory.GetGrain<IPaymentGrain>(this.GetPrimaryKey());
            var paid = await paymentGrain.IsPaymentCompleteAsync();
            if (!paid) await EscalateAsync();
        }
    }
}

Streams

Orleans Streams provide pub/sub between grains — loosely coupled, durable event passing.

C#
// Producer grain
public class OrderGrain : Grain, IOrderGrain
{
    private IAsyncStream<OrderSubmittedEvent>? _stream;

    public override Task OnActivateAsync(CancellationToken ct)
    {
        var streamProvider = this.GetStreamProvider("StreamProvider");
        _stream = streamProvider.GetStream<OrderSubmittedEvent>(
            StreamId.Create("orders", this.GetPrimaryKey()));
        return base.OnActivateAsync(ct);
    }

    public async Task SubmitAsync()
    {
        _state.State.Status = "Submitted";
        await _state.WriteStateAsync();
        await _stream!.OnNextAsync(new OrderSubmittedEvent(this.GetPrimaryKey(), _state.State.Total));
    }
}

// Consumer grain
public class NotificationGrain : Grain, INotificationGrain,
    IAsyncObserver<OrderSubmittedEvent>
{
    public override async Task OnActivateAsync(CancellationToken ct)
    {
        var streamProvider = this.GetStreamProvider("StreamProvider");
        var stream = streamProvider.GetStream<OrderSubmittedEvent>(
            StreamId.Create("orders", this.GetPrimaryKey()));
        await stream.SubscribeAsync(this);
    }

    public async Task OnNextAsync(OrderSubmittedEvent item, StreamSequenceToken? token)
    {
        await SendConfirmationEmailAsync(item.OrderId);
    }

    public Task OnErrorAsync(Exception ex) => Task.CompletedTask;
    public Task OnCompletedAsync() => Task.CompletedTask;
}

Silo Configuration

C#
// Program.cs (Silo host)
var host = Host.CreateDefaultBuilder(args)
    .UseOrleans(silo =>
    {
        silo
            // Cluster membership — use Azure Table Storage in production
            .UseAzureStorageClustering(options =>
                options.ConfigureTableServiceClient(azureStorageConnectionString))

            // Or localhost for development
            .UseLocalhostClustering()

            // Grain state persistence
            .AddAdoNetGrainStorage("orderStore", options =>
            {
                options.Invariant = "System.Data.SqlClient";
                options.ConnectionString = sqlConnectionString;
            })

            // Reminder storage
            .UseAdoNetReminderService(options =>
            {
                options.Invariant = "System.Data.SqlClient";
                options.ConnectionString = sqlConnectionString;
            })

            // Streams
            .AddAzureQueueStreams("StreamProvider", configurator =>
                configurator.ConfigureAzureQueue(options =>
                    options.ConfigureQueueServiceClient(azureStorageConnectionString)));
    })
    .Build();

await host.RunAsync();

Client Access from ASP.NET Core

C#
// API project — connect to the Orleans cluster
builder.Host.UseOrleansClient(client =>
{
    client.UseAzureStorageClustering(options =>
        options.ConfigureTableServiceClient(azureStorageConnectionString));
});

// Controller
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
    private readonly IGrainFactory _grains;

    public OrdersController(IGrainFactory grains) => _grains = grains;

    [HttpPost("{id}/submit")]
    public async Task<IActionResult> Submit(Guid id)
    {
        var grain = _grains.GetGrain<IOrderGrain>(id);
        await grain.SubmitAsync();
        return NoContent();
    }

    [HttpGet("{id}")]
    public async Task<IActionResult> Get(Guid id)
    {
        var grain = _grains.GetGrain<IOrderGrain>(id);
        var state = await grain.GetStateAsync();
        return Ok(state);
    }
}

Interview Questions

Q: What is a grain in Orleans and how is it different from a regular service? A grain represents a single entity (one order, one user). It has persistent identity (key), persisted state, and single-threaded execution. Multiple callers can't race to corrupt state because Orleans queues calls. A service is stateless and handles many concurrent requests. Use grains when per-entity state isolation and serialised access matter.

Q: What is the difference between an Orleans timer and a reminder? Timers are in-process — they only fire while the grain is active and are lost if the grain deactivates or the silo restarts. Reminders are durable — stored in a persistent store and will fire even after deactivation and restarts. Use reminders for anything that must not be lost (payment retries, expiry checks, escalations).

Q: How does Orleans handle single-threaded grain execution across a cluster? Each grain lives on exactly one silo at a time (placement). All calls to that grain are dispatched to that silo and queued on the grain's single-threaded scheduler. No locking, no lock statements needed inside grain code. If the silo fails, the grain reactivates on another silo.

Q: What is the virtual actor model? Actors are "virtual" because you never explicitly create or destroy them. You call GrainFactory.GetGrain<IOrderGrain>(id) — if the grain isn't active, Orleans activates it automatically on a silo. If it hasn't been called in a while, it deactivates (freeing memory). Conceptually every possible grain instance always exists; Orleans manages the actual in-memory instances.

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.