DEV Community

Cover image for Durable Entities: Stateful Actors Inside Azure Functions
Martin Oehlert
Martin Oehlert

Posted on

Durable Entities: Stateful Actors Inside Azure Functions

Parts 1 to 3 gave you a workflow with a beginning and an end: an orchestration starts, runs its ordered steps, and completes. So what do you reach for when the thing you are modeling never ends, when it is just a piece of keyed state that a shopping cart, a game session, or a per-user counter keeps mutating over its whole lifetime, and every one of those mutations has to be race-free without you writing a single lock? That is the other half of Durable Functions. An entity is a tiny stateful actor: long-lived, addressable by a string key, reactive to operations, and serialized so exactly one operation touches its state at a time. Orchestrations are code-as-workflow; entities are code-as-state, and the determinism rules that constrained your orchestrators do not apply here.

Defining an entity: from a counter to a cart

Three shapes define an entity in the isolated worker, and the class-based one is the shape to reach for first. You derive from TaskEntity<TState>, write your operations as public methods, and wire up a single [Function] method with an [EntityTrigger] to route incoming operations to them. Every code sample below is from the companion sample (isolated worker, .NET 10). Here is the canonical counter.

public class Counter : TaskEntity<int>
{
    public void Add(int amount) => this.State += amount;
    public Task Reset() { this.State = 0; return Task.CompletedTask; }
    public Task<int> Get() => Task.FromResult(this.State);

    [Function(nameof(Counter))]
    public static Task Run([EntityTrigger] TaskEntityDispatcher dispatcher)
        => dispatcher.DispatchAsync<Counter>();
}
Enter fullscreen mode Exit fullscreen mode

Three things carry this. The persisted state is the inherited this.State property, and only State is serialized: any other field you hang off the class is scratch space that does not survive between operations. The operations are the public methods (Add, Reset, Get); each one reads or mutates this.State and returns void, a Task, or a Task<T> when it hands a value back. The [Function(nameof(Counter))] method is the registration itself: dispatcher.DispatchAsync<Counter>() takes the operation name off the incoming message and invokes the public method whose name matches. That trigger method is the whole wiring. There is no host.json opt-in and no attribute beyond [Function] and [EntityTrigger].

One naming rule crashes you at runtime if you miss it: never call the trigger method RunAsync. ITaskEntity already defines an instance RunAsync(TaskEntityOperation), confirmed against the compiled Microsoft.DurableTask.Entities types, and the collision throws an ambiguous-match error the moment an operation dispatches. It compiles without complaint (your trigger is a static method, so C# sees no clash), which is exactly why the failure waits until runtime. Name the trigger Run, or anything else, and the problem is gone.

Two lifecycle operations come for free. Override InitializeState to seed state on first access, before any operation has set it: protected override int InitializeState(TaskEntityOperation entityOperation) => 10; runs only while the state is still null. Deletion is setting state back to null; TaskEntity<TState> hands you an implicit Delete operation, and you override it by declaring your own Delete() method that sets this.State = null (which needs a nullable TState).

An int is the smallest interesting state. Entities earn their keep when the state is a whole object. Model a shopping cart and the same shape holds: the operations become AddItem, RemoveItem, and GetTotal, and this.State becomes a collection of line items that lives across every operation the cart ever receives.

public record CartLine(string Sku, int Quantity, decimal UnitPrice);

public class ShoppingCart : TaskEntity<List<CartLine>>
{
    protected override List<CartLine> InitializeState(TaskEntityOperation entityOperation)
        => new();

    public void AddItem(CartLine line) => this.State.Add(line);

    public void RemoveItem(string sku) => this.State.RemoveAll(l => l.Sku == sku);

    public Task<decimal> GetTotal() =>
        Task.FromResult(this.State.Sum(l => l.UnitPrice * l.Quantity));

    [Function(nameof(ShoppingCart))]
    public static Task Run([EntityTrigger] TaskEntityDispatcher dispatcher)
        => dispatcher.DispatchAsync<ShoppingCart>();
}
Enter fullscreen mode Exit fullscreen mode

The cart reads almost the same as the counter, and that is the point: the state grew from an int to a List<CartLine>, but the operation-per-method model and the single trigger did not change. InitializeState returns an empty list on first access instead of relying on a null state, AddItem and RemoveItem each take their one argument, and GetTotal returns a Task<decimal> so an orchestration can call it for the value.

Which brings up the rule that bites hardest once state stops being a primitive: entity state serializes with System.Text.Json, not Newtonsoft. Property names are case-sensitive on the way back in, a Newtonsoft [JsonProperty] attribute is ignored (use [JsonPropertyName]), and dictionary keys come out camelCased by default. The operation methods carry their own constraints: at most one argument each, no overloads, no generic type parameters, and every argument and return value has to be JSON-serializable. Design the state type and the operation signatures around those limits from the start, because a serialization mismatch shows up as silently-empty state after a round-trip, not a compile error.

Talking to an entity: signal, call, and entity IDs

Every entity is addressed by an ID, and you build one with new EntityInstanceId(name, key). The name is the entity type, and it matches the entity function name (case-insensitively); the key is the string that picks one instance out of every entity of that type, so a user id, an order id, or a GUID. new EntityInstanceId(nameof(Counter), "myCounter") addresses the one counter keyed myCounter, which internally resolves to the form @Counter@myCounter. The ID is all you need to reach an instance, and if none with that ID exists yet, the first operation you send creates it.

There are two ways to reach an entity, and the difference decides your whole design. A signal is one-way and fire-and-forget: the task you await completes when the message is reliably enqueued, not when the entity has processed it, so there is no return value and no error to observe on the sending side. A call is a two-way round-trip: the sender waits for the operation to run and gets back its result, or the exception it threw.

The catch is that not every surface can do both. From a client you can signal an entity and you can read its state, but you cannot call one for a return value.

var id = new EntityInstanceId(nameof(Counter), "myCounter");
await client.Entities.SignalEntityAsync(id, "Add", 5);
Enter fullscreen mode Exit fullscreen mode

SignalEntityAsync enqueues an Add operation carrying 5 and returns the instant that message is durably queued. The increment happens later, on the entity's own turn. That is the entire vocabulary a client has for changing an entity: fire an operation and move on, with no value coming back.

To pull a value out of an operation you have to be inside an orchestration. The orchestrator's entity feature can both call and signal, and the call is the only place in Durable Functions where you get request/response with an entity.

var id = new EntityInstanceId(nameof(Counter), "myCounter");
int current = await context.Entities.CallEntityAsync<int>(id, "Get");
if (current < 10)
    await context.Entities.SignalEntityAsync(id, "Add", 1);
Enter fullscreen mode Exit fullscreen mode

CallEntityAsync<int>(id, "Get") sends the Get operation and awaits its return value, so current holds the counter's actual state; the conditional SignalEntityAsync then fires a one-way Add only if it is under the threshold. Read the value with a call, change it with a signal. From inside an entity the vocabulary shrinks again: an entity can signal other entities but never call one and wait for a reply.

Reading state without running an operation at all is the client's other move. GetEntityAsync<T> hands back the entity's state without dispatching anything to it.

EntityMetadata<int>? entity = await client.Entities.GetEntityAsync<int>(id);
int value = entity?.State ?? 0;
Enter fullscreen mode Exit fullscreen mode

GetEntityAsync<int> returns an EntityMetadata<int>? that is null when the entity has never been created, and the read does not create it (unlike a signal or a call). entity.State is the typed value. The qualifier that matters is that this state is committed but can be stale: the query hits the durable tracking store and returns the most recently persisted state, never a half-applied intermediate, but it can lag the entity's in-memory state. Only an orchestration sees an entity's live in-memory state; a client read is always a snapshot of the last commit.

Two traps close out the surface. The first is API shape: in the isolated worker, entity access lives on context.Entities.* and client.Entities.*. The bare context.SignalEntity(...) and context.CallEntityAsync(...) forms belong to the in-process model, and reaching for them is the single most common isolated-migration error. The second follows straight from the signal semantics: because awaiting SignalEntityAsync only means "enqueued," a client GetEntityAsync fired right after a signal can still return the pre-signal state. Signal and then immediately read, and you should expect the old value; the write is on its way, not applied.

One operation at a time: the single-threaded guarantee

The Counter above has no lock around Add, and that is not an oversight. It is the guarantee that makes an entity worth reaching for: a single entity runs its operations serially, one after another, and only one operation runs at a time for a given instance. Two callers can signal Add at the same instant, and the runtime queues both and applies them in turn, so this.State += amount never races another copy of itself. You get race-free updates with no lock, no semaphore, no compare-and-swap; serializing the operations is the platform's job, not yours.

That serialization is scoped to one entity id, and this is what keeps it from being a bottleneck by default. The guarantee is per instance: @Counter@userA and @Counter@userB are scheduled independently, so a thousand distinct user counters make progress without ever queueing behind each other. This is the scale-out mechanism the actor model runs on, many entities each holding modest state, each serialized only against itself. Microsoft frames it as distributing work across many entities rather than promising that distinct ids run literally in parallel, so read it as "scheduled independently" and design for that, not for a parallelism guarantee.

Underneath, delivery is reliable and in order: messages from one sender arrive FIFO. The honest qualifier is that on rare restart, scale, or crash paths an operation can be delivered more than once (at-least-once), so an Add that runs twice should leave the same result as an Add that runs once wherever you can arrange it. Idempotent operations are the design default here, not an edge-case hardening step you bolt on later.

One property ties this back to the virtual-actor model from earlier: an entity has no create step. The first operation you signal or call to an id materializes it, and a read does not, which is why GetEntityAsync on an id nothing has ever written to comes back null instead of a fresh zero-state instance.

When one entity is a bottleneck: sharding for throughput

The serial guarantee has a limit, and you hit it the moment one entity id gets popular. Point every increment in the system at a single @Counter@global and you have rebuilt a single-threaded queue: every write lines up behind the one before it, and the entity works through them one at a time no matter how many workers you are running. There is no published per-entity operations-per-second figure to design against, so do not cite one, but the shape is clear enough. Throughput on one id is whatever one serialized entity can commit, and because entities prioritize durability over latency, they are a poor fit for latency-sensitive writes even before you hit that ceiling.

The fix is to stop funneling. Replace the one id with N sub-entities, @Counter@global-0 through @Counter@global-{N-1}, and each write picks one shard and signals only that shard. Up to N increments can now be in flight at once against N independently-scheduled entities, so the contention that a single id created spreads N ways. Reading the total means fanning out across every shard and summing what each one holds.

int shard = Random.Shared.Next(ShardCount);
var shardId = new EntityInstanceId(nameof(Counter), $"global-{shard}");
await client.Entities.SignalEntityAsync(shardId, "Add", 1);

int total = 0;
for (int i = 0; i < ShardCount; i++)
{
    var id = new EntityInstanceId(nameof(Counter), $"global-{i}");
    EntityMetadata<int>? m = await client.Entities.GetEntityAsync<int>(id);
    total += m?.State ?? 0;
}
Enter fullscreen mode Exit fullscreen mode

The write path picks a shard (random here; a hash of the caller or plain round-robin works as well) and signals just that one, so no two writers are forced through the same entity unless they happen to land on the same index. The read path pays for that spread with a fan-out: it visits every shard and adds up the committed state.

The cost lands on consistency, and it is worth stating rather than hiding. Each GetEntityAsync<int> returns that shard's last committed state on its own, and under active writes some shards will have applied increments that others have not caught up to yet, so the summed total is eventually consistent: correct once the writes quiesce, approximate while they are in flight. If you need a total that is exact against a single instant while writes are still landing, you have to lock the shards together in a critical section, which serializes them again and hands back the throughput you sharded for in the first place. Shard when an approximate running total is acceptable, and keep a single id (or a database) for when the count has to be exact.

Entities, orchestrations, or a database

The decision that decides everything above is when to reach for an entity at all. Microsoft's documented "good for" list is short and specific: aggregating data from multiple sources, distributed locks and semaphores, and stateful objects like shopping carts or game sessions, with the counter as the canonical worked example. If what you are modeling is a named thing that accumulates state over its lifetime and reacts to updates, that is the entity's home ground.

The first fork is entity versus orchestration, and it is the code-as-state and code-as-workflow line from the intro made concrete. An orchestration is a workflow: ordered steps, a defined start and end, deterministic replay, activity retries. An entity is long-lived keyed state with no predefined lifecycle, and because it is not replayed the way an orchestrator is, an operation can run any code, including the nondeterministic or long-running work that would be illegal inside an orchestrator. They are not rivals; you combine them, and the combination is the only place you get request/response with an entity, since a call has to originate in an orchestration.

The second fork is entity versus a database row, and this is the one to be honest about, because the entity does not always win. The entity wins when the alternative is writing the locking code yourself: updates are serialized race-free, the state survives restarts, and the durable backend manages it with no connection pool or transaction of yours involved. The database wins on everything the entity was not built for: higher throughput on a single hot key, rich queries and joins, lower read latency, and state larger than the cap. Remember that a client read of an entity is committed-but-stale and that entities are not richly queryable, so a question like "every cart abandoned for more than an hour" is a query, and a query belongs to a database, not an entity.

Some limits are worth naming before you commit. There is the serial-per-id throughput ceiling from the previous section; a maximum entity state of 1 MB on the Durable Task Scheduler backend (that cap is backend-specific, not a universal number, so check the one you run); durability prioritized over latency; and request/response available only from an orchestration. One thing that is not a documented limit: there is no rule against long-running work inside an operation. Current docs allow it. Keeping operations short is still worth doing, because a slow operation blocks the entity's whole queue and long work usually belongs in a child orchestration, but treat that as a design habit of your own, not a platform rule you can cite.

When you do need to touch several entities atomically, the classic case being a bank transfer that debits one account and credits another with no observable state in between, you open a durable critical section from an orchestration over the entities you are locking.

public static class TransferOrchestrator
{
    [Function(nameof(TransferOrchestrator))]
    public static async Task<bool> Run(
        [OrchestrationTrigger] TaskOrchestrationContext context)
    {
        TransferRequest request = context.GetInput<TransferRequest>()!;
        var from = new EntityInstanceId(nameof(BankAccount), request.From);
        var to = new EntityInstanceId(nameof(BankAccount), request.To);

        await using (await context.Entities.LockEntitiesAsync(from, to))
        {
            bool withdrew = await context.Entities.CallEntityAsync<bool>(
                from, nameof(BankAccount.Withdraw), request.Amount);
            if (!withdrew)
                return false;

            await context.Entities.CallEntityAsync(
                to, nameof(BankAccount.Deposit), request.Amount);
            return true;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The signature is now pinned to the compiled SDK: context.Entities.LockEntitiesAsync(...) has a params EntityInstanceId[] overload (and an IEnumerable<EntityInstanceId> one) and returns Task<IAsyncDisposable>, so await using (await context.Entities.LockEntitiesAsync(from, to)) is the exact isolated shape. One note trips people up: entities and critical sections both work in the isolated worker, yet Microsoft's own orchestration docs still carry a stale line claiming critical sections are not available in isolated. That line is out of date; the mechanism moved to a renamed API, it did not disappear. Note too that a critical section gives you isolation, not a transaction: there is no automatic rollback, so any compensating undo on a mid-transfer failure is code you write.

One deployment gotcha before you build on any of this: the MSSQL storage provider does not support entities in the isolated worker. If your task hub runs on MSSQL, entities are off the table until you move backends, and learning that now beats learning it from a failed deploy.

There is also a client-side listing API for walking every entity of a type, for enumerating your shards or every open cart rather than addressing one by id. The isolated signature is confirmed against the compiled SDK: client.Entities.GetAllEntitiesAsync<T>(EntityQuery) returns an AsyncPageable<EntityMetadata<T>> you enumerate with await foreach. The EntityQuery filter carries InstanceIdStartsWith (matching the @name@key id form), IncludeState, and paging via PageSize.

var query = new EntityQuery
{
    InstanceIdStartsWith = $"@{nameof(ShoppingCart).ToLowerInvariant()}@",
    IncludeState = true,
};

await foreach (EntityMetadata<List<CartLine>> cart in
    client.Entities.GetAllEntitiesAsync<List<CartLine>>(query))
{
    decimal total = cart.State.Sum(l => l.UnitPrice * l.Quantity);
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

You now own the decision the series has been circling. Orchestrations were code-as-workflow: a thing with a start, an end, and steps that replay deterministically. Entities are code-as-state: a named, addressable thing that outlives any single request, mutates across its lifetime, and hands you race-free updates without a line of locking code. The choice between them is not about which is newer or nicer; it is whether the thing you are modeling has a lifecycle or only a state.

And when the answer is "only a state," one fork remains, and it is the one to sit with. Keyed state that mutates over a lifetime, needs serialized race-free updates, and can tolerate a committed-but-possibly-stale read points squarely at an entity. A need for high single-key throughput, rich queries, or strict read latency points just as squarely at a database row you manage yourself. Neither is the default answer. You pick on which set of constraints is actually yours.

For the next piece of keyed state in your system, a per-user counter, a cart, a session: do you reach for a Durable Entity so the runtime serializes every update for you, or a database row you lock yourself?

Top comments (1)

Collapse
 
michael_salinas_472fbf6c1 profile image
Michael Salinas

Thank you for sharing such an excellent post. I really enjoyed reading it.

I’m a Python Full-Stack Engineer with over 10 years of experience designing and building scalable software solutions for clients across a variety of industries. Along the way, I’ve learned that successful projects depend not only on strong technical execution but also on creating real business value.

With my recent contract completed, I’m exploring new opportunities to collaborate with professionals who value innovation, practical problem-solving, and long-term partnerships. I enjoy discussing ideas that combine technical excellence with sound business strategy, creating outcomes that benefit everyone involved.

I believe every connection has the potential to become something meaningful. If you're interested in exchanging ideas, exploring opportunities, or simply connecting with someone who enjoys building impactful technology, I'd be happy to hear from you.

Wishing you success in your future endeavors, and I look forward to connecting.