You have a service that tracks the state of individual entities -- players in a game, devices in an IoT fleet, shopping carts in an e-commerce platform. At first, everything fits in one process. Then traffic grows, and suddenly you are knee-deep in distributed caches, sticky sessions, race conditions, and a deployment topology that requires a whiteboard to explain. The code that used to be a clean class with a few properties and methods has turned into a distributed systems thesis.
Orleans exists to make that pain go away. Built by Microsoft Research and battle-tested in Halo, Xbox Live, Skype, and Azure IoT Hub, Orleans is a framework that lets you model distributed entities as plain C# objects -- called grains -- and then transparently scales them across a cluster of servers. You write single-process code. The runtime handles the rest.
With Orleans 10 now shipping alongside .NET 10, and first-class .NET Aspire integration making cluster setup trivial, there has never been a better time to understand what Orleans brings to the table.
The virtual actor model
Orleans implements the virtual actor pattern. If you have used Akka or similar actor frameworks, the concept will feel familiar -- but with a crucial twist.
In a traditional actor system, you explicitly create and destroy actors. If an actor crashes, you need supervision strategies to restart it. If you send a message to an actor that does not exist yet, you get an error.
Orleans grains are virtual. They always exist, conceptually. You never create or destroy them. You simply address a grain by its type and identity, and the runtime activates it on demand. When a grain is idle, the runtime silently deactivates it to free memory. When someone calls it again, the runtime reactivates it -- possibly on a completely different server. The caller never knows and never cares.
This has profound implications:
- No lifecycle management: You never write code to create or clean up distributed entities.
- Transparent fault tolerance: If a server dies, grains that were active on it simply reactivate elsewhere on the next call.
- Location transparency: Grain-to-grain calls look like normal async method invocations. The runtime routes them across the cluster.
- Automatic scalability: Add servers to the cluster, and the runtime redistributes grain activations across them.
Grains: the building block
A grain is defined by an interface and an implementation. The interface declares the grain's contract, and every method must return Task, Task<T>, or ValueTask<T>.
public interface IPlayerGrain : IGrainWithGuidKey
{
ValueTask<PlayerProfile> GetProfileAsync();
Task UpdateScoreAsync(int points);
Task JoinSessionAsync(IGameSessionGrain session);
}
The implementation inherits from Grain and implements the interface:
public class PlayerGrain(
[PersistentState("profile", "playerStore")]
IPersistentState<PlayerProfile> profile,
ILogger<PlayerGrain> logger) : Grain, IPlayerGrain
{
public ValueTask<PlayerProfile> GetProfileAsync() =>
new(profile.State);
public async Task UpdateScoreAsync(int points)
{
profile.State.TotalScore += points;
profile.State.GamesPlayed++;
await profile.WriteStateAsync();
logger.LogInformation(
"Player {Id} scored {Points} points (total: {Total})",
this.GetPrimaryKey(), points, profile.State.TotalScore);
}
public async Task JoinSessionAsync(IGameSessionGrain session)
{
profile.State.CurrentSessionId = session.GetPrimaryKey();
await profile.WriteStateAsync();
await session.AddPlayerAsync(this.AsReference<IPlayerGrain>());
}
}
Notice that the grain uses a primary constructor to inject its persistent state and logger -- standard dependency injection, nothing exotic. The [PersistentState] attribute tells Orleans which named state object to inject and which storage provider to back it with.
From a caller's perspective, invoking this grain is trivial:
public class ScoreService(IClusterClient client)
{
public async Task RecordScoreAsync(Guid playerId, int points)
{
var player = client.GetGrain<IPlayerGrain>(playerId);
await player.UpdateScoreAsync(points);
}
}
GetGrain does not make a network call. It returns a lightweight proxy -- a grain reference -- that routes calls to wherever the grain happens to be activated. If the grain is not active, Orleans activates it on the most appropriate silo, loads its state, and then processes the call.
Grain identity and key types
Every grain has a stable identity composed of its type and a key. Orleans supports several key types through marker interfaces:
| Interface | Key type | Typical use |
|---|---|---|
IGrainWithGuidKey |
Guid |
Players, devices, sessions |
IGrainWithIntegerKey |
long |
Database row IDs, sequential entities |
IGrainWithStringKey |
string |
Named resources, tenants, URLs |
IGrainWithGuidCompoundKey |
Guid + string |
Multi-tenant entities |
IGrainWithIntegerCompoundKey |
long + string |
Composite identifiers |
Choose the key type that matches your domain. If players have GUIDs, use IGrainWithGuidKey. If tenants have string slugs, use IGrainWithStringKey. The key is part of the grain's permanent address -- it never changes.
State persistence
Orleans separates in-memory state from durable storage. While a grain is active, its state lives in memory for low-latency reads. When you call WriteStateAsync(), Orleans pushes the state to the configured storage provider. When the grain reactivates later, Orleans reads it back.
The modern approach injects IPersistentState<T> into the grain constructor:
[GenerateSerializer]
public class DeviceState
{
[Id(0)] public string FirmwareVersion { get; set; } = "unknown";
[Id(1)] public DateTimeOffset LastHeartbeat { get; set; }
[Id(2)] public Dictionary<string, double> Telemetry { get; set; } = [];
}
public class DeviceGrain(
[PersistentState("device", "deviceStore")]
IPersistentState<DeviceState> state) : Grain, IDeviceGrain
{
public Task RecordHeartbeatAsync(Dictionary<string, double> telemetry)
{
state.State.LastHeartbeat = DateTimeOffset.UtcNow;
foreach (var (key, value) in telemetry)
state.State.Telemetry[key] = value;
return state.WriteStateAsync();
}
public ValueTask<DeviceState> GetStateAsync() => new(state.State);
}
A grain can have multiple named state objects -- one for the device profile, another for telemetry history. Each can be backed by a different storage provider. State is loaded automatically before OnActivateAsync runs, so by the time your grain methods execute, the state is ready.
// TIP
Mark your state classes with [GenerateSerializer] and each property with [Id(n)]. Orleans uses a version-tolerant serialiser that allows you to add new properties without breaking existing persisted data.
Out of the box, Orleans ships providers for Azure Table Storage, Azure Blob Storage, Amazon DynamoDB, ADO.NET (SQL Server, PostgreSQL, MySQL), and -- new in Orleans 10 -- stable Redis providers for clustering, persistence, and reminders.
Silos and clusters
A silo is a server process that hosts grains. A cluster is a group of silos that work together. Silos within a cluster communicate over a membership protocol and coordinate grain placement, activation, and deactivation.
For local development, a single silo is all you need. In production, you run multiple silos behind a load balancer, and Orleans handles everything else.
var builder = Host.CreateApplicationBuilder(args);
builder.UseOrleans(silo =>
{
silo.UseLocalhostClustering()
.AddMemoryGrainStorage("deviceStore")
.AddMemoryGrainStorage("playerStore");
});
builder.Build().Run();
That is a complete, runnable Orleans host for local development. UseLocalhostClustering configures the silo to run standalone. The AddMemoryGrainStorage calls register in-memory storage providers -- fine for development, swapped for durable providers in production.
.NET Aspire integration
If you are already using .NET Aspire, Orleans configuration becomes even simpler. Aspire manages the clustering provider, environment variables, and service discovery automatically.
In the AppHost project, declare the Orleans cluster and wire it to your silo:
var builder = DistributedApplication.CreateBuilder(args);
var redis = builder.AddRedis("orleans-redis");
var orleans = builder.AddOrleans("cluster")
.WithClustering(redis);
builder.AddProject<Projects.DeviceSilo>("silo")
.WithReference(orleans)
.WaitFor(redis)
.WithReplicas(3);
builder.Build().Run();
In the silo project, the configuration is minimal:
var builder = Host.CreateApplicationBuilder(args);
builder.AddServiceDefaults();
builder.AddKeyedRedisClient("orleans-redis");
builder.UseOrleans();
builder.Build().Run();
No connection strings scattered across files, no manual clustering configuration. Aspire injects the cluster ID, service ID, and provider settings via environment variables, and UseOrleans() picks them up automatically.
Grain placement and rebalancing
When a grain activates, Orleans decides which silo hosts it. This is called placement. Orleans 9.2 changed the default placement strategy from RandomPlacement to ResourceOptimizedPlacement, which considers CPU and memory utilisation across the cluster.
You can override placement on a per-grain basis:
[PreferLocalPlacement]
public class ComputeGrain : Grain, IComputeGrain
{
// Prefer activating on the silo that made the call
}
Built-in strategies include:
- ResourceOptimizedPlacement (default): Balances activations based on CPU and memory load.
- RandomPlacement: Distributes activations randomly across silos.
- PreferLocalPlacement: Activates on the calling silo when possible, reducing network hops.
- ActivationCountBasedPlacement: Balances based on the number of active grains per silo.
Orleans 9.2 also introduced activation rebalancing, which periodically migrates grains across silos to maintain an even distribution as load patterns change.
Timers, reminders, and durable jobs
Grains can schedule work using timers (in-memory, lost on deactivation) or reminders (persistent, survive restarts). Orleans 10 introduces durable jobs as a replacement for reminders, offering a more robust scheduling mechanism.
public class MonitorGrain : Grain, IMonitorGrain
{
private IDisposable? _timer;
public override Task OnActivateAsync(CancellationToken ct)
{
// Fire every 30 seconds -- lost if the grain deactivates
_timer = this.RegisterGrainTimer(
CheckHealthAsync,
new GrainTimerCreationOptions
{
DueTime = TimeSpan.Zero,
Period = TimeSpan.FromSeconds(30)
});
return Task.CompletedTask;
}
private async Task CheckHealthAsync(CancellationToken ct)
{
// Perform health check logic
}
}
// WARNING
Timers are scoped to a grain's activation. If the grain deactivates and reactivates on a different silo, the timer does not carry over. Use reminders or durable jobs for anything that must survive restarts.
Streaming with IAsyncEnumerable
Orleans supports returning IAsyncEnumerable<T> from grain methods, enabling efficient server-to-caller streaming without loading entire result sets into memory:
public interface IReportGrain : IGrainWithStringKey
{
IAsyncEnumerable<SalesRecord> GetMonthlySalesAsync(
int year, int month, CancellationToken ct = default);
}
public class ReportGrain(ISalesRepository repo) : Grain, IReportGrain
{
public async IAsyncEnumerable<SalesRecord> GetMonthlySalesAsync(
int year, int month,
[EnumeratorCancellation] CancellationToken ct = default)
{
await foreach (var record in repo.StreamRecordsAsync(year, month, ct))
{
yield return record;
}
}
}
The caller consumes it with a standard await foreach loop. Orleans batches elements across the network -- 100 per batch by default, configurable via WithBatchSize().
Common pitfalls
Treating grains like microservices. Grains are lightweight, fine-grained entities -- one per player, one per device, one per order. Do not create a single OrderServiceGrain that handles all orders. That defeats the purpose of the model and creates a bottleneck.
Blocking inside grain methods. Grains are single-threaded by design. A single grain processes one request at a time. If you block the thread with synchronous I/O or Task.Result, you stall that grain and everything waiting on it. Always use await.
Ignoring serialisation. Orleans serialises everything that crosses grain boundaries. If your state classes or method parameters are not marked with [GenerateSerializer], you will get runtime errors. Always annotate your DTOs.
Using in-memory state as the source of truth. Grain state is volatile until you call WriteStateAsync(). If the silo crashes between state mutation and the write call, the change is lost. Write state after every meaningful mutation -- do not batch writes across multiple calls to save round trips.
Forgetting that grains can deactivate. Orleans deactivates idle grains to free memory. Any in-memory state that is not persisted via IPersistentState<T> is gone after deactivation. Do not store important data in plain fields unless you are prepared to lose it.
Calling GetGrain in a tight loop. While GetGrain itself is cheap (it returns a proxy, not a network call), calling many different grains in a tight loop can flood the cluster with activations. Use Task.WhenAll to batch concurrent calls, and consider whether a stateless worker grain or a stream is more appropriate for fan-out operations.
Summary
- Orleans implements the virtual actor model: grains always exist, are activated on demand, and deactivate when idle. You never manage their lifecycle.
- Grains are plain C# classes with interfaces. State is injected via
IPersistentState<T>, and storage providers handle the durable backing. - The runtime handles placement, fault recovery, and cluster membership. Servers can be added or removed without code changes.
- Orleans 10 brings stable Redis providers, a built-in monitoring dashboard, and durable jobs as a reminders replacement.
- .NET Aspire integration eliminates most configuration boilerplate -- declare your cluster in the AppHost and call
UseOrleans()in the silo. - Grains are not microservices. Model one grain per domain entity, keep methods async, and always persist state after mutations.