State is the thing that trips most teams up in microservices. Stateless services are easy — scale horizontally, route anywhere. But real business logic is rarely stateless. Orders have lifecycles. User sessions accumulate context. Trading positions need to be mutated atomically. The moment you introduce shared state, you open the door to concurrency bugs, lost updates, and a whole class of distributed systems problems that will ruin your weekend on-call rotation.
The Dapr virtual actor model is one of the cleanest solutions I've used for this problem. I've reached for it on several projects, most notably a trading platform where we needed isolated, strongly-consistent state per instrument position. Let me walk through how it actually works, where it differs from Akka and Orleans, and — critically — when you should not use it.
The actor model itself is not new. Akka (Scala/Java) and Microsoft Orleans (.NET) have implemented it for years. Dapr's contribution is making the pattern sidecar-based and language-agnostic.
In Akka, you manage actor lifecycle explicitly — you supervise, restart, and relocate actors yourself. It's powerful but carries real operational weight. Orleans introduced the "virtual actor" concept (called Grains) where the runtime handles placement, activation, and passivation automatically. Dapr borrows this virtual model almost entirely, but offloads the state backend to whatever component you configure — Redis, Cosmos DB, Azure Table Storage — through its pluggable component system.
The key guarantee in all virtual actor systems: at most one active instance of an actor with a given ID runs at any moment, and all calls to that actor are serialised. No locks needed in your application code. The runtime handles concurrency.
Each Dapr actor instance gets its own namespaced state, persisted in the configured state store. State is lazily loaded on first access and written back at the end of each method call.
public interface IPositionActor : IActor
{
Task<decimal> GetPositionAsync();
Task ApplyTradeAsync(TradeEvent trade);
Task<PositionSnapshot> SnapshotAsync();
}
public class PositionActor : Actor, IPositionActor
{
private const string StateKey = "position";
public PositionActor(ActorHost host) : base(host) { }
public async Task<decimal> GetPositionAsync()
{
var result = await StateManager.TryGetStateAsync<PositionState>(StateKey);
return result.HasValue ? result.Value.NetQuantity : 0m;
}
public async Task ApplyTradeAsync(TradeEvent trade)
{
var state = (await StateManager.TryGetStateAsync<PositionState>(StateKey)).Value
?? new PositionState();
state.NetQuantity += trade.Side == TradeSide.Buy ? trade.Quantity : -trade.Quantity;
state.LastUpdated = DateTimeOffset.UtcNow;
await StateManager.SetStateAsync(StateKey, state);
}
}The actor ID maps directly to an instrument symbol (e.g., "MSFT", "BTC-USD"). Every trade for that instrument routes to exactly one actor instance, eliminating any need for distributed locking.
Dapr actors support two kinds of scheduled callbacks: timers and reminders. They look similar in the API but behave very differently under failure.
Timers are in-memory. If the actor is garbage collected or the host restarts, the timer is gone. Use timers for transient tasks: refreshing a cached value, flushing a write buffer within a session.
Reminders are durable. They're persisted in the state store and fire even after a host restart. If your actor needs to do something at a future point — send a settlement notification, expire a session, retry a downstream call — use a reminder.
// Register a durable reminder — survives restarts
await RegisterReminderAsync(
"settlement-check",
null,
dueTime: TimeSpan.FromMinutes(5),
period: TimeSpan.FromMinutes(5));
// Handle the reminder
public async Task ReceiveReminderAsync(string reminderName, byte[] state,
TimeSpan dueTime, TimeSpan period)
{
if (reminderName == "settlement-check")
{
await CheckPendingSettlements();
}
}On the trading platform, we used reminders to trigger end-of-day position reconciliation. A timer would have been silently dropped during any deployment — a subtle bug that would show up only in production at market close.
Registration is straightforward:
// Program.cs
builder.Services.AddActors(options =>
{
options.Actors.RegisterActor<PositionActor>();
options.ActorIdleTimeout = TimeSpan.FromMinutes(30);
options.ActorScanInterval = TimeSpan.FromSeconds(30);
});
// ...
app.MapActorsHandlers();Calling from another service:
var actorId = new ActorId("MSFT");
var proxy = ActorProxy.Create<IPositionActor>(actorId, "PositionActor");
await proxy.ApplyTradeAsync(trade);The Dapr sidecar handles routing. You don't care which pod the actor is running on.
This is the part most blog posts skip. Actors are not a universal tool.
Don't use actors when you need aggregate queries. Actors are designed for single-entity operations. If you need "sum all positions across all instruments", you're going to fight the model. Use a separate read model fed by pub/sub events.
Don't use actors for high-throughput, low-latency paths where serialisation is the bottleneck. The single-threaded guarantee becomes a throughput ceiling if you're routing thousands of calls per second through one actor ID.
Don't use actors when the state is trivially derivable. If you can recompute the state from an event log in under 100ms, just do that. Actors add operational complexity (state store dependency, Dapr sidecar) that isn't always justified.
Don't reach for actors just to avoid a database. The state store is still a dependency. Redis going down affects your actors just as much as it would affect any other service.
The Dapr virtual actor model is genuinely useful for a specific class of problems: isolated, frequently-mutated, per-entity state with eventual scheduling needs. The trading platform position tracker was a near-perfect fit — one actor per instrument, serialised trade application, durable end-of-day reminders. The alternative would have been distributed locks, optimistic concurrency retries, and a lot more code.
Get the problem shape right, and the actor model removes an entire category of concurrency complexity from your codebase. Get it wrong, and you've added a stateful dependency to a problem that didn't need one.