This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

Tutorial: Dapr.Actors.Next by example

A six-part, example-driven tour of what changed in the modernized Dapr Actors implementation, with the testing for each

This tutorial walks through six worked examples, one improvement at a time, and treats the test for each as a first-class part rather than an afterthought. The cleaner authoring is worth seeing, but for several of these the headline is that you can now write the test at all.

The runnable code lives in the solution under /examples/Actor.Next/, one folder per part, each with its actor code and its xUnit v3 tests. You can clone and run them, then come back here for the what and the why. Each part below links to its example folder. It’s recommended that you install your local Dapr instance using dapr init so you get the sample state store and PubSub components created and to make sure you’re on the latest v1.18 runtime version.

Tutorial content

The first three parts build on one shopping-cart scenario so the story is continuous; the last three stand on their own. Read top to bottom and the authoring gets a little more ambitious each time, while the testing is the consistent payoff: it needs less infrastructure and reaches further at every step.

The single idea underneath the whole set: the old SDK could run actors, and the new one lets you prove they are correct. The tests are where that stops being a slogan.

Prerequisites

  • The .NET 8, 9, or 10 SDK (though all the examples use .NET 10 since 8 and 9 will reach end of life towards the end of 2026).
  • The Dapr.Actors.Next package (or the solution’s project references) and, for the test projects, Dapr.Actors.Next.Testing.
    • If you’re building against this solution directly, you can use the referenced packages the examples use, but otherwise use the combined package on NuGet.
  • At least v1.18 of the Dapr runtime as it’s required for the Dapr.Actors.Next implementation. Every unit test in this tutorial runs with no sidecar, no state store, and no Docker (testing using the built-in test functionality), but in a real-world environment you’d be expected to supplement with your own integration and E2E testing against the Dapr runtime and components.

If you are new to the SDK, skim the overview first; each part links to the relevant concept page for reference detail.

Next steps

1 - Part 1: The cart, the modern baseline

The actor you already know, with the ceremony removed, and the timer test that no longer needs a sidecar

The first example is the smallest real actor: a cart that adds items, returns a summary, and abandons itself after twenty minutes idle via a timer. It does nothing exotic, and that is the point. It is the side-by-side against Dapr.Actors that makes the modernization legible.

Code for this part: /examples/Actor.Next/01-Cart/.

What you build

[GenerateActorClient]
public interface ICartActor : IActor
{
    Task AddItem(CartItem item, CancellationToken ct = default);
    Task<CartSummary> GetSummary(CancellationToken ct = default);
    Task AbandonCart(CancellationToken ct = default);
}

public sealed record CartItem(string Sku, int Quantity);
public sealed record CartSummary(int ItemCount, decimal Total, bool Abandoned);

public interface IPricingClient
{
    ValueTask<decimal> GetPriceAsync(string sku, CancellationToken cancellationToken = default);
}

public sealed class CartState
{
    public Dictionary<string, int> Items { get; set; } = [];
    public Dictionary<string, decimal> Prices { get; set; } = [];
    public bool Abandoned { get; set; }
}

[DaprActor("Cart")]
public sealed class CartActor(
    ActorActivationContext context,
    IPricingClient pricing,
    IActorTimerScheduler timers) : Actor, ICartActor
{
    private static readonly TimeSpan AbandonAfter = TimeSpan.FromMinutes(20);

    protected override ActorId Id => context.ActorId;
    protected override IActorStateAccessor State => context.State;

    public async Task AddItem(CartItem item, CancellationToken ct = default)
    {
        var cart = await State.GetOrCreateAsync("cart", () => new CartState(), ct);
        cart.Value.Items[item.Sku] = cart.Value.Items.GetValueOrDefault(item.Sku) + item.Quantity;   // mutate through .Value
        cart.Value.Prices[item.Sku] = await pricing.GetPriceAsync(item.Sku, ct);                     // injected per activation
        cart.Value.Abandoned = false;

        // Re-arm the idle timer on every add; its callback is the AbandonCart method.
        await timers.RescheduleAsync("Cart", Id, "abandon-cart", AbandonAfter, nameof(AbandonCart), string.Empty, cancellationToken: ct);
    }

    public async Task<CartSummary> GetSummary(CancellationToken ct = default)
    {
        var cart = await State.GetOrCreateAsync("cart", () => new CartState(), ct);
        var itemCount = cart.Value.Items.Values.Sum();
        var total = cart.Value.Items.Sum(entry => entry.Value * cart.Value.Prices.GetValueOrDefault(entry.Key));
        return new CartSummary(itemCount, total, cart.Value.Abandoned);
    }

    public async Task AbandonCart(CancellationToken ct = default)
    {
        var cart = await State.GetOrCreateAsync("cart", () => new CartState(), ct);
        cart.Value.Abandoned = true;           // saved once at end of turn
    }
}

Startup is one line, with no endpoint mapping and no per-actor registration:

builder.Services.AddDaprActors();

Why this is the baseline

This carries the whole spine at once. The contract derives from IActor and carries [GenerateActorClient], which is what marks it for the generator; [DaprActor] on the implementation then produces the proxy and dispatcher with no reflection; the dependency is constructor-injected into a per-activation scope; state is read through the per-actor cache and saved once at end of turn; and actor callbacks arrive over a persistent stream to the runtime rather than through mapped actor-handler endpoints. Nullable annotations are doing real work in the signatures. None of this is new behavior for the actor model; it is the same actor you would write against Dapr.Actors with the registration boilerplate, the endpoint mapping, and the reflection-based proxy removed, which is also what makes Native AOT targeting possible.

For the reference detail behind each of these, see Author, register, and call actors.

The test

[Fact]
public async Task Advancing_virtual_time_fires_the_abandon_timer()
{
    await using var runtime = CreateRuntime();
    var cart = runtime.CreateActor<ICartActor>(ActorId.Create("idle-cart"), "Cart");

    var add = cart.AddItem(new CartItem("sku-1", 1));
    await runtime.RunToIdle();
    await add;

    runtime.Time.Advance(TimeSpan.FromMinutes(20));   // fires the abandon timer; no real wait
    await runtime.RunToIdle();

    var summaryTask = cart.GetSummary();
    await runtime.RunToIdle();
    Assert.True((await summaryTask).Abandoned);
}

private static ActorTestRuntime CreateRuntime()
{
    _ = typeof(CartActor);
    return new ActorTestRuntime(services =>
    {
        services.AddSingleton<IPricingClient, FakePricingClient>();
        services.AddDaprActors(_ => { });
    });
}

private sealed class FakePricingClient : IPricingClient
{
    public ValueTask<decimal> GetPriceAsync(string sku, CancellationToken cancellationToken = default) =>
        ValueTask.FromResult(sku == "sku-1" ? 12.50m : 1.00m);
}

Why the test is the headline

The Dapr.Actors version of this test is an integration test: it stands up a sidecar and a state store, registers the actor over HTTP, and then either sleeps twenty minutes or, far more commonly, gives up and never tests the timer at all. The timer path is exactly the kind of thing teams leave untested because the wall clock makes it impractical.

The new version is a sub-second unit test with no infrastructure. The fake IPricingClient is injected through the same dependency injection you use in production, and the clock is yours to advance, so the timer fires on demand. A path most teams never test is now trivial to test, and the test has nothing to stand up or tear down so it runs much faster and with lower resource consumption.

For the full testing surface, see Testing.

See also

2 - Part 2: Evolving the cart's state

A breaking state change becomes a build error and a unit-testable migration, with no database

The second example continues the same cart across a breaking state change. CartStateV1 stored a flat list of SKUs; CartStateV2 groups them into line items with quantities, and CartStateV3 adds a cached total. You ship each new shape with one small upcaster, and old carts fold forward on read with no data backfill.

Code for this part: /examples/Actor.Next/02-Migration/.

What you build

public sealed class CartStateV1ToV2 : IActorStateUpcaster<CartStateV1, CartStateV2>
{
    public ValueTask<CartStateV2> UpcastAsync(CartStateV1 state, CancellationToken cancellationToken = default)
    {
        var lines = state.Skus
            .GroupBy(sku => sku, StringComparer.Ordinal)
            .Select(group => new CartLine(group.Key, group.Count()))
            .ToList();

        return ValueTask.FromResult(new CartStateV2 { Lines = lines });
    }
}

public sealed class CartStateV2ToV3 : IActorStateUpcaster<CartStateV2, CartStateV3>
{
    public ValueTask<CartStateV3> UpcastAsync(CartStateV2 state, CancellationToken cancellationToken = default) =>
        ValueTask.FromResult(new CartStateV3
        {
            Lines = state.Lines.ToList(), TotalQuantity = state.Lines.Sum(line => line.Quantity),
        });
}

That is the entire migration: one small upcaster per shape change, and the chain composes. The source generator discovers and registers the upcasters; on read the runtime discovers whatever version is stored, folds it up to the shape your code asks for, and re-persists at the current shape lazily on the next save. Nothing in the actor branches on a version.

Why this is a good example

Two things Dapr.Actors had no clean answer for show up here. First, serialization is a single pluggable IDaprSerializer path (System.Text.Json by default) rather than the remoting-versus-non-remoting DataContract split, so there is one way data is written regardless of how the actor is called. Second, and more important, the breaking change is caught at build time. The DAPR1410 analyzer reports a state-type change that would break deserialization of already-persisted data, with a code fix that scaffolds the upcaster or suggests making the change additive, and a missing hop in a longer chain is flagged too (DAPR1415).

Migration is opt-in by silence: if you never change a state shape, there is no version to bump and no upcaster to write. You author one only when you actually make a breaking change, and the compiler tells you exactly when that is. Additive changes (new defaultable members) need no upcaster at all, since the generator synthesizes the hop.

For the full model, including the graduation offramp and the integrity guarantees, see State migration.

The test

[Fact]
public async Task GetOrCreateAsync_folds_seeded_v1_to_current_cart()
{
    await using var runtime = CreateRuntime();
    var id = ActorId.Create("cart-v1");
    await runtime.SeedStateAsync("MigratingCart", id, "cart", new CartStateV1 { Skus = ["sku-1", "sku-1", "sku-2"] });
    var cart = CreateCart(runtime, id);

    var read = cart.GetState();
    await runtime.RunToIdle();
    var current = await read;

    Assert.Equal(2, current.Lines.Single(line => line.Sku == "sku-1").Quantity);
    Assert.Equal(3, current.TotalQuantity);
    Assert.Equal(3, runtime.StateOf(cart).Get<CartStateV3>("cart")!.TotalQuantity);
}

private static ActorTestRuntime CreateRuntime()
{
    _ = typeof(MigratingCartActor);
    return new ActorTestRuntime(services => services.AddDaprActors(_ => { }));
}

private static IMigratingCartActor CreateCart(ActorTestRuntime runtime, ActorId id) =>
    runtime.CreateActor<IMigratingCartActor>(id, "MigratingCart");

Why the test matters

The migration test is the proof that the thing you are most afraid of, old data not loading after a deploy, actually loads. You seed the old shape directly into the runtime’s store, read it under the current build, and assert the migrated result, all without a real state store and without any production data to be nervous about. The test above already folds the full V1 -> V2 -> V3 chain in a single read; add cases for an additive change or a graduated entry and the rest of the surface is covered too.

In the old SDK, proving a production data migration was correct meant a staging environment with representative data. Here it is a unit test that runs before any production data is touched. The build-time guard itself is verified as well: the DAPR1410 analyzer is tested with the analyzer testing harness, so the squiggle you rely on is covered.

See also

3 - Part 3: The cart that reacts

Dynamic pub/sub in one attribute, and an event-driven flow proven without a broker

The third example continues the cart. An inventory service publishes inventory-restocked events, and a cart waiting on an out-of-stock item should wake and mark it available. The subscription is declared on the actor method, and the event names which cart gets it.

Code for this part: /examples/Actor.Next/03-PubSub/.

What you build

[DaprActor("RestockingCart")]
public sealed class RestockingCartActor(ActorActivationContext context) : Actor, IRestockingCartActor
{
    protected override ActorId Id => context.ActorId;
    protected override IActorStateAccessor State => context.State;

    [Subscribe("orders-pubsub", "inventory-restocked", RouteBy = nameof(RestockEvent.CartId))]
    public async Task OnRestock(RestockEvent evt, CancellationToken ct = default)
    {
        var cart = await State.GetOrCreateAsync("cart", () => new RestockingCartState(), ct);

        // Delivery is at-least-once, so this must be safe to run more than once:
        // removing first makes a duplicate delivery a no-op after the first success.
        if (cart.Value.WaitingForStock.Remove(evt.Sku))
        {
            cart.Value.Available.Add(evt.Sku);
        }
    }
}

The host wires these subscriptions from [Subscribe] when you register the streams host alongside actors:

builder.Services.AddDaprActors();
builder.Services.AddDaprActorStreams(); // Available in `Dapr.Actors.Next` NuGet package

Why the dynamism matters

There is one declarative subscription, but it fans out to whichever cart instance the event’s CartId names, across the whole cluster, with no per-instance wiring. The host owns the subscription; the routing key picks the actor id at delivery time; and placement routes the wake-up to wherever that cart lives, so an event received on one replica can drive a cart on another. The delivery edges come along for free: the acknowledgment is gated on the turn outcome, so a successful turn acks, a transient failure retries, and a poison message deadletters, and the CloudEvents traceparent is joined onto the wake-up invoke so the inbound event and the actor turn appear as one trace.

In Dapr.Actors there was no answer for this at all. You stood up a separate subscriber service, received the event there, looked up the right actor id by hand, called the proxy yourself, and owned retry and idempotency on your own. Here it is one attribute and the field that names the target.

The one caveat to state plainly: delivery is at-least-once, so the handler must be idempotent, which is why OnRestock is written to be safe to apply twice. That is the same contract that governs reminders and retried invokes, surfaced again here.

For the reference detail, see Subscription streams.

The tests

private static readonly ActorStreamSubscription Subscription =
    new("orders-pubsub", "inventory-restocked", "RestockingCart", nameof(IRestockingCartActor.OnRestock), nameof(RestockEvent.CartId));
private static readonly JsonSerializerOptions WebJsonOptions = new(JsonSerializerDefaults.Web);

[Fact]
public async Task Publishing_one_event_wakes_only_the_named_cart()
{
    await using var runtime = CreateRuntime();
    var named = runtime.CreateActor<IRestockingCartActor>(ActorId.Create("cart-1"), "RestockingCart");
    var other = runtime.CreateActor<IRestockingCartActor>(ActorId.Create("cart-2"), "RestockingCart");
    await AddSku(runtime, named, "sku-1");
    await AddSku(runtime, other, "sku-1");

    var delivery = Runner(runtime).ProcessEventAsync(Subscription, Event(new RestockEvent("cart-1", "sku-1")));
    await runtime.RunToIdle();
    var action = await delivery;

    Assert.Equal(ActorStreamDeliveryAction.Ack, action);
    Assert.True(await IsAvailable(runtime, named, "sku-1"));
    Assert.False(await IsAvailable(runtime, other, "sku-1"));
}

[Fact]
public async Task Transient_state_write_fault_retries_delivery_instead_of_acknowledging()
{
    await using var runtime = CreateRuntime();
    var cart = runtime.CreateActor<IRestockingCartActor>(ActorId.Create("cart-retry"), "RestockingCart");
    await AddSku(runtime, cart, "sku-1");

    runtime.Faults.FailNextStateWrite<RestockingCartState>();
    var firstDelivery = Runner(runtime).ProcessEventAsync(Subscription, Event(new RestockEvent("cart-retry", "sku-1")));
    await runtime.RunToIdle();
    var first = await firstDelivery;
    var secondDelivery = Runner(runtime).ProcessEventAsync(Subscription, Event(new RestockEvent("cart-retry", "sku-1")));
    await runtime.RunToIdle();
    var second = await secondDelivery;

    Assert.Equal(ActorStreamDeliveryAction.Retry, first);
    Assert.Equal(ActorStreamDeliveryAction.Ack, second);
    Assert.True(await IsAvailable(runtime, cart, "sku-1"));
}

private static ActorTestRuntime CreateRuntime()
{
    _ = typeof(RestockingCartActor);
    return new ActorTestRuntime(services => services.AddDaprActors(_ => { }));
}

private static ActorStreamSubscriptionRunner Runner(ActorTestRuntime runtime)
{
    var invocationClient = (IActorInvocationClient)runtime.GetType().GetProperty("Runtime", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(runtime)!;
    return new ActorStreamSubscriptionRunner(
        new ActorStreamForwarder(invocationClient, new ActorStreamRoutingKeyExtractor()),
        new DefaultActorStreamFailureClassifier());
}

private static ActorStreamEvent Event(RestockEvent evt) =>
    new("event-1", "orders-pubsub", "inventory-restocked", Encoding.UTF8.GetBytes(JsonSerializer.Serialize(evt, WebJsonOptions)), new Dictionary<string, string>());

private static async Task AddSku(ActorTestRuntime runtime, IRestockingCartActor cart, string sku)
{
    var add = cart.AddUnavailableSku(sku);
    await runtime.RunToIdle();
    await add;
}

private static async Task<bool> IsAvailable(ActorTestRuntime runtime, IRestockingCartActor cart, string sku)
{
    var read = cart.IsAvailable(sku);
    await runtime.RunToIdle();
    return await read;
}

There is no fake broker to stand up: Runner drives the same forward-invoke and Ack/Retry/Drop path the streaming host uses, built over the runtime’s internal invocation client. AddUnavailableSku and IsAvailable are ordinary actor methods; the helpers just call them and drain the turn with RunToIdle. The stream types (ActorStreamSubscription, ActorStreamSubscriptionRunner, ActorStreamForwarder, ActorStreamRoutingKeyExtractor, DefaultActorStreamFailureClassifier, ActorStreamEvent, and the ActorStreamDeliveryAction result) live in Dapr.Actors.Next.Streams.

Why the tests matter

Pub/sub-driven behavior is exactly the kind of thing that, in the old SDK, you could only exercise with a real broker in an integration test, slow enough that most teams test the handler in isolation and never test that the wiring routes and retries correctly. The glue between an event arriving and the right actor reacting once is the part most likely to be wrong, and it was the part hardest to test.

Here both halves are plain unit tests. The first proves the dynamic routing is precise: one event wakes only the cart it names. The second proves the delivery edge teams almost never test: a failed turn retries the delivery instead of acking it, and the redelivery then acks once the write succeeds. The runtime stands in for the broker, so you feed an event through the subscription runner and inspect the delivery action it returns, with no Redis, no Kafka, and no Docker.

See also

4 - Part 4: The auction

A state machine, and the previously untestable timer-versus-message race written as a deterministic unit test

The fourth example is the live auction: open and accepting bids, with a soft-close timer that each accepted bid resets; when it fires the auction transitions to sold and marks fulfillment as started. It looks like a workflow and is not, which is exactly what we’re seeking to illustrate. This is the example where the test is unambiguously the headline.

Code for this part: /examples/Actor.Next/04-Auction/.

What you build

[GenerateActorClient]
public interface IAuctionActor : IActor
{
    Task<BidResult> PlaceBid(Bid bid, CancellationToken cancellationToken = default);
    Task Close(CancellationToken cancellationToken = default);
    Task Expire(CancellationToken cancellationToken = default);
    Task<AuctionState> GetState(CancellationToken cancellationToken = default);
    Task<AuctionData> GetData(CancellationToken cancellationToken = default);
}

public enum AuctionState { Open, Sold, Expired }

public sealed record AuctionData(decimal HighBid, string? HighBidder, bool FulfillmentStarted)
{
    public static AuctionData Empty { get; } = new(0, null, false);
}

public sealed record Bid(decimal Amount, string Bidder);
public sealed record CloseAuction;
public sealed record ExpireAuction;
public enum BidResult { Accepted, TooLow, Closed }

[DaprActor("Auction")]
public sealed class AuctionActor(ActorActivationContext context, IActorTimerScheduler timers) : StateMachineActor<AuctionState, AuctionData>, IAuctionActor(context, timers, "Auction", AuctionData.Empty)
{
    private static readonly TimeSpan SoftClose = TimeSpan.FromSeconds(30);

    protected override void Configure(IStateMachine<AuctionState, AuctionData> sm)
    {
        sm.InitialState(AuctionState.Open);

        sm.In(AuctionState.Open)
            .On<Bid>()
                .When((data, bid) => bid.Amount > data.HighBid)
                    .Do(async ctx =>
                    {
                        ctx.Update(data => data with { HighBid = ctx.Event.Amount, HighBidder = ctx.Event.Bidder });
                        await timers.RescheduleAsync("Auction", Id, "soft-close", SoftClose, nameof(Close), string.Empty);
                        ctx.Reply(BidResult.Accepted);
                    })
                .Otherwise()
                    .Reply(BidResult.TooLow);

        sm.In(AuctionState.Open)
            .On<CloseAuction>().GoTo(AuctionState.Sold);

        sm.In(AuctionState.Open)
            .On<ExpireAuction>().GoTo(AuctionState.Expired);

        sm.In(AuctionState.Sold)
            .OnEntry(ctx => ctx.Update(data => data with { FulfillmentStarted = true }))
            .Ignore<Bid>()
            .Ignore<CloseAuction>()
            .Ignore<ExpireAuction>();

        sm.In(AuctionState.Expired)
            .Ignore<Bid>()
            .Ignore<CloseAuction>()
            .Ignore<ExpireAuction>();
    }

    public Task<BidResult> PlaceBid(Bid bid, CancellationToken cancellationToken = default)
    {
        if (CurrentState != AuctionState.Open)
        {
            return Task.FromResult(BidResult.Closed);
        }

        return Raise<BidResult>(bid, cancellationToken);
    }

    public Task Close(CancellationToken cancellationToken = default) =>
        Raise<object?>(new CloseAuction(), cancellationToken);

    public Task Expire(CancellationToken cancellationToken = default) =>
        Raise<object?>(new ExpireAuction(), cancellationToken);

    public Task<AuctionState> GetState(CancellationToken cancellationToken = default) =>
        Task.FromResult(CurrentState);

    public Task<AuctionData> GetData(CancellationToken cancellationToken = default) =>
        Task.FromResult(Data);
}

Why a state machine, and where it ends

An auction is a long-lived, addressable entity with explicit states, not a finite process, so it belongs in a state-machine actor rather than a workflow. A high rate of bids would grow a workflow’s replay history without bound, every bidder needs an instant synchronous read of the current high bid, and the soft-close behavior is a race. The transition to Sold is where it hands off: its entry action marks fulfillment started, which is the point where a real deployment kicks off a fulfillment workflow for the finite, multi-step process (charge the winner, notify losers, arrange shipping), so the entity stays in the actor and the bounded orchestration goes to a workflow. That boundary is the thing to take away; the two compose rather than compete.

For the authoring detail and the full when-to-use comparison, see State machine actors.

The tests

[Fact]
public async Task Last_second_bid_wins_race_with_close_timer()
{
    await using var runtime = CreateRuntime(new PriorityActorScheduler(7));
    var auction = runtime.CreateActor<IAuctionActor>(ActorId.Create("auction-1"), "Auction");
    await PlaceBid(runtime, auction, new Bid(100, "alice"));

    runtime.Time.Advance(TimeSpan.FromSeconds(29));
    var lateBid = auction.PlaceBid(new Bid(110, "bob"));
    await runtime.RunToIdle();
    Assert.Equal(BidResult.Accepted, await lateBid);

    runtime.Time.Advance(TimeSpan.FromSeconds(2));
    await runtime.RunToIdle();

    Assert.Equal(AuctionState.Open, await ReadState(runtime, auction));
    Assert.Equal("bob", (await ReadData(runtime, auction)).HighBidder);
}

[Fact]
public void State_machine_has_no_structural_defects()
{
    ActorStateMachine.Analyze<AuctionActor>().AssertNoStructuralDefects();
    Assert.Contains(ActorStateMachine.Analyze<BadAuctionActor>().StructuralDefects, defect => defect.Contains("unreachable", StringComparison.Ordinal));

    // For deeper exploration, run the same test body under the optional Coyote bridge when enabled.
    Assert.False(CoyoteBridge.IsEnabled);
}

private static ActorTestRuntime CreateRuntime(ControlledActorScheduler? scheduler = null)
{
    _ = typeof(AuctionActor);
    return new ActorTestRuntime(services => services.AddDaprActors(_ => { }), new ActorTestRuntimeOptions { Scheduler = scheduler });
}

private static async Task<BidResult> PlaceBid(ActorTestRuntime runtime, IAuctionActor auction, Bid bid)
{
    var placed = auction.PlaceBid(bid);
    await runtime.RunToIdle();
    return await placed;
}

private static async Task<AuctionState> ReadState(ActorTestRuntime runtime, IAuctionActor auction)
{
    var read = auction.GetState();
    await runtime.RunToIdle();
    return await read;
}

private static async Task<AuctionData> ReadData(ActorTestRuntime runtime, IAuctionActor auction)
{
    var read = auction.GetData();
    await runtime.RunToIdle();
    return await read;
}

BadAuctionActor is a deliberately-broken variant in the example project (it leaves a state unreachable); the second assertion proves the analyzer catches it. The soft-close is scheduled inside the accepted-bid effect, so each accepted bid reschedules the soft-close timer, whose callback invokes Close and raises CloseAuction to move the auction to Sold.

Why the tests matter

The first test is a genuine race condition, the kind that ships, survives code review, and reproduces once a week in production. In the old SDK this is not merely a hard test to write; it is a test you cannot write reliably at all, because you cannot make a timer and a message race on demand against a real sidecar. Here the bid and the close-timer firing are two separate turns whose order is not fixed, and the scheduler explores that order rather than depending on timing, so the test passes or fails deterministically every run. The two turns never overlap; whichever runs first runs to completion before the other starts. If you want to go further, the same test runs under the Coyote bridge for exhaustive exploration.

The second test analyzes the machine’s structure directly: AuctionActor must have no defects, while the deliberately-broken BadAuctionActor surfaces an unreachable state. Because the machine is a transition table, the runtime can turn “did my refactor strand a state” from a production surprise into a build failure. This example alone justifies the rewrite.

For the test runtime and the structural analysis, see Testing.

See also

5 - Part 5: Runtime-defined state machines

State machines supplied as data at runtime, verified before they go live, and driven dynamically

The fifth example is the destination the first four were building toward: state machines that are not compiled into your app at all, but supplied as data at runtime, verified before they go live, and driven without a compile-time contract.

The scenario is a device-management platform. You manage many kinds of devices, a smart lock, a thermostat, a valve, and each device type has its own state machine: a lock moves from Locked to Unlocking to Unlocked to Locking and back, driven by commands and sensor events. New device types are onboarded through configuration, not a code deploy, and each physical device is a long-lived, addressable entity, exactly the kind of thing Part 4 argued belongs in a state-machine actor. The difference here is that the machine is data, so one compiled actor runs every device type.

Code for this part: /examples/Actor.Next/05-Interpreted/.

What you build

Three capabilities, each reusing machinery built for an earlier, compile-time purpose.

Runtime-defined behavior. Because a state machine’s configuration is a transition table (data, not code), a single compiled InterpretedStateMachineActor runs a machine supplied as data. A device type’s states, transitions, guards, and effects are a definition document; each physical device is an instance of the interpreter running its type’s definition. Guards and effects (actuate the motor, check the battery, publish a telemetry event) resolve by name from a capability registry of vetted, compiled actions rather than arbitrary code, so a new device type composes only from safe building blocks.

// A device-type definition, authored as data rather than compiled. The optional
// includeUnlockingCompletion flag lets a test build a deliberately-stranded machine.
public static class DeviceManagementDemo
{
    public static InterpretedMachineDefinition SmartLockDefinition(bool includeUnlockingCompletion = true, string motorEffect = "ActuateMotor") =>
        new()
        {
            DocumentVersion = 1,
            InitialState = "Locked",
            States =
            [
                new InterpretedStateDefinition { Name = "Locked" },
                new InterpretedStateDefinition { Name = "Unlocking" },
                new InterpretedStateDefinition { Name = "Unlocked" },
                new InterpretedStateDefinition { Name = "Locking" },
            ],
            Transitions = Transitions(includeUnlockingCompletion, motorEffect),
        };

    public static InterpretedMachineVerificationResult Verify(IInterpretedMachineVerifier verifier, InterpretedMachineDefinition definition) =>
        verifier.Verify(definition);

    private static IReadOnlyList<InterpretedTransitionDefinition> Transitions(bool includeUnlockingCompletion, string motorEffect)
    {
        var transitions = new List<InterpretedTransitionDefinition>
        {
            new()
            {
                Source = "Locked",
                Event = "Unlock",
                Branches = [new InterpretedBranchDefinition { Guards = ["CheckBattery"], Target = "Unlocking", Effects = [motorEffect] }],
            },
            new()
            {
                Source = "Unlocked",
                Event = "Lock",
                Branches = [new InterpretedBranchDefinition { Otherwise = true, Target = "Locking", Effects = [motorEffect] }],
            },
            new()
            {
                Source = "Locking",
                Event = "MotorStopped",
                Branches = [new InterpretedBranchDefinition { Otherwise = true, Target = "Locked" }],
            },
        };

        if (includeUnlockingCompletion)
        {
            transitions.Add(new InterpretedTransitionDefinition
            {
                Source = "Unlocking",
                Event = "MotorStopped",
                Branches = [new InterpretedBranchDefinition { Otherwise = true, Target = "Unlocked" }],
            });
        }

        return transitions;
    }
}

Verification before rollout. Before a device type reaches real hardware, run its definition through the interpreted machine verifier. A machine with an unreachable state or a dead end is a device that can get stuck in a state it can never leave, a field failure you want caught at onboarding, not after firmware ships. The loop is author the definition, verify it deterministically, then deploy it.

Dynamic invocation. A single control plane drives every device regardless of type. Because device types are onboarded at runtime and their command sets are not known at compile time, the control plane uses IActorRegistry to discover what a device accepts and IDynamicActorClient to send a command, with no per-type compiled contract. This calling half is a general capability; see Dynamic invocation.

Why this was impossible before

Every earlier decision compounds here. The manifest built for state versioning becomes the description of what a device accepts; the declarative state machine built for the auction becomes the format a device type is authored in; the test runtime built for deterministic testing becomes the gate that keeps a broken device type off real hardware; and one stream per type is what lets a device type onboarded at runtime be hosted at all.

It’s important to be candid about the boundary: interpreted actors carry a dynamic state bag, so the typed migration story from Part 2 deliberately does not apply. Versioning a device type means versioning its definition document as data, not writing an IActorStateUpcaster.

The test

The gate is testable: a bad definition is rejected before it can be activated, and a good one passes.

[Fact]
public void Verification_rejects_device_type_that_can_strand_a_lock()
{
    var verifier = new InterpretedMachineVerifier(new DeviceCapabilityRegistry());
    var stranded = DeviceManagementDemo.SmartLockDefinition(includeUnlockingCompletion: false);

    var result = DeviceManagementDemo.Verify(verifier, stranded);

    Assert.False(result.IsValid);
    Assert.Contains(result.Defects, defect => defect.Contains("State 'Unlocking' is a dead end", StringComparison.Ordinal));
}

[Fact]
public async Task Definition_with_unregistered_effect_is_rejected_before_rollout()
{
    var registry = new DeviceCapabilityRegistry();
    var verifier = new InterpretedMachineVerifier(registry);
    var store = new InMemoryInterpretedMachineStore();
    var deployer = new InterpretedMachineDeployer(verifier, store);
    var definition = DeviceManagementDemo.SmartLockDefinition(motorEffect: "UnknownMotor");

    Assert.False(registry.TryGetEffect("UnknownMotor", out _));
    var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => deployer.DeployAsync("SmartLock", FrontDoor, definition).AsTask());
    Assert.Contains("Effect 'UnknownMotor'", ex.Message, StringComparison.Ordinal);
    Assert.Null(await store.GetAsync("SmartLock", FrontDoor));
}

FrontDoor is ActorId.Create("front-door"), and DeviceCapabilityRegistry is the example’s ICapabilityRegistry that registers the vetted CheckBattery guard and ActuateMotor effect by name; an effect that isn’t registered (like UnknownMotor) fails verification, so the deploy is rejected before anything is stored.

Why the test is the gate

Verification is not a nice-to-have; it is what makes running a definition you did not compile safe, and you can test the gate itself. The same kind of structural checking that validates a compiled state machine in Part 4, here run through the interpreted machine verifier, validates a device-type definition expressed as data, so you can refuse to roll out a device type that can strand a device before any hardware runs it. The capability registry test proves the other half of the safety story: a definition can reference only actions you have vetted and registered, so a typo or an unsupported effect fails at validation, not on a device in the field.

It should be noted that behavioral testing of a fully dynamic machine is shallower than for a typed one, since there is no compile-time contract to assert against. Structural verification plus the vetted capability registry carry the safety weight here, rather than rich behavioral assertions.

See also

6 - Part 6: Composing interpreted actors with workflows

Runtime-defined approval machines that hand off to a settlement workflow, with retry and compensation, tested without a sidecar

The sixth example puts the interpreted runtime from Part 5 next to a Dapr Workflow and shows the two composing. An approval document is a long-lived, addressable entity, so it is an interpreted state-machine actor whose behavior is authored as data. Settling an approved document is a finite, failure-prone process, so it is a workflow. The entity starts the workflow, and the workflow drives the entity back; each does what it is good at.

The scenario is a document-routing platform. Each document type, an expense report or a contract, is a state machine supplied at runtime, and one compiled InterpretedStateMachineActor runs every one of them, so a new document type is configuration rather than a code deploy. When a document reaches Approved, a vetted effect starts a settlement workflow that notifies parties, charges under a retry policy, and compensates if the charge cannot be made good.

Code for this part: /examples/Actor.Next/06-Approvals/.

Two layers: one compiled runtime, many dynamic behaviors

This example is built from two layers that never mix, and keeping them apart is the whole point.

The compiled layer ships in the assembly and is registered once at startup: the single InterpretedStateMachineActor type, the ICapabilityRegistry of vetted guards and effects (WithinApprovalLimit, StartSettlement, and the rest), the InterpretedMachineVerifier, the InterpretedMachineDeployer, the definition store, and the SettlementWorkflow. None of it knows an expense report from a contract; it is generic machinery that never has to be re-registered when a new document type appears.

The dynamic layer is the InterpretedMachineDefinition itself (its states, transitions, branches, and the guard and effect names it composes from) authored as data and deployed per document type at onboarding time. The ExpenseReport() and Contract() builders look like logic, but they are data factories: their output is the dynamic part, and in a real system that same definition could arrive as JSON from a database or an HTTP body with no recompile. Two genuinely different machines — one with a LegalReview stage, one without — run on the one compiled actor precisely because the difference lives in data, not in code.

The two layers meet by name, never by reference. Exactly as invoking an actor requires its name, a definition never holds a compiled guard or effect; it holds a string like "StartSettlement" that binds to the compiled IActorEffect at runtime through the capability registry. Verification is the single checkpoint that guarantees the binding will succeed: InterpretedMachineVerifier runs inside InterpretedMachineDeployer.DeployAsync, right before a definition is stored, and rejects any definition whose shape is unsound (a dead-end or unreachable state) or that names a guard or effect the registry does not vet. A broken document type is caught at onboarding, not on a live document.

Because behavior is data and the runtime is compiled, adding a new document type is configuration rather than a code deploy. The sections that follow are just this split in motion: a document type authored as data, a compiled effect that is the seam to the workflow, and a compiled workflow that drives the data-defined document back — none of the compiled pieces naming a single document type.

What you build

A document type, authored as data

The expense-report machine is an InterpretedMachineDefinition document. Its Approved state carries an entry effect, StartSettlement, that is the seam to the workflow. The optional flag builds a deliberately-stranded machine for a test, and the effect name is a parameter so a test can reference an unregistered effect.

public static InterpretedMachineDefinition ExpenseReport(
    bool includeSettlementCompletion = true,
    string settlementEffect = StartSettlementEffect) =>
    new()
    {
        DocumentVersion = 1,
        InitialState = "Draft",
        InitialData = InitialData("ExpenseReport"),
        States =
        [
            new InterpretedStateDefinition { Name = "Draft" },
            new InterpretedStateDefinition { Name = "Submitted" },
            new InterpretedStateDefinition { Name = "InReview" },
            new InterpretedStateDefinition { Name = "Escalated" },
            new InterpretedStateDefinition { Name = "Approved", EntryEffects = [settlementEffect] },
            new InterpretedStateDefinition { Name = "Rejected", Terminal = true },
            new InterpretedStateDefinition { Name = "Archived", Terminal = true },
            new InterpretedStateDefinition { Name = "SettlementFailed", Terminal = true },
        ],
        Transitions = [ /* Submit, BeginReview, and the shared review/approve/settle tail */ ],
    };

The InReview transition uses a named guard: amounts at or below the auto-approval limit go straight to Approved, everything else escalates to a manager. The contract type reuses the same tail but inserts a LegalReview stage, so the two document types are genuinely different machines running on the same compiled actor.

new()
{
    Source = "InReview",
    Event = "Approve",
    Branches =
    [
        new InterpretedBranchDefinition { Guards = ["WithinApprovalLimit"], Target = "Approved", Effects = ["RecordDecision"] },
        new InterpretedBranchDefinition { Otherwise = true, Target = "Escalated", Effects = ["NotifyManager"] },
    ],
}

The composition seam: actor to workflow

StartSettlement is a compiled IActorEffect in the capability registry, constructed with an injected IDaprWorkflowClient. On entry into Approved it schedules the workflow with a deterministic instance id derived from the actor type and document id. A method invocation is an event, and like any actor invocation it is at-least-once, so a re-run of the approving turn re-schedules the same instance rather than starting a second workflow. That deterministic id is the re-run-safety rule from State machine actors applied to a real external side effect.

public async ValueTask ExecuteAsync(ActorCapabilityContext context, CancellationToken cancellationToken = default)
{
    var bag = ApprovalCapabilityRegistry.State(context);
    var input = new SettlementInput(
        DocumentId: context.ActorId.Value,
        DocumentType: bag.Get<string>("documentType") ?? context.ActorType,
        Requester: bag.Get<string>("requester") ?? string.Empty,
        Amount: bag.Get<decimal>("amount"),
        Parties: bag.Get<string[]>("parties") ?? [],
        SimulateChargeFailure: bag.Get<bool>("simulateChargeFailure"));

    var instanceId = InstanceIdFor(context.ActorType, context.ActorId.Value);   // "settlement-{type}-{id}"
    await workflowClient.ScheduleNewWorkflowAsync(WorkflowName, instanceId, input);
}

The settlement workflow: retry, compensate, and drive the entity back

The workflow fans out notifications, charges under a retry policy, and if the charge exhausts its retries it compensates and drives the document to SettlementFailed; on success it drives it to Archived. Driving the document back uses IDynamicActorClient, the workflow-to-actor half of the composition, with no compiled document contract.

public override async Task<SettlementResult> RunAsync(WorkflowContext context, SettlementInput input)
{
    // 1. Fan out: notify every party in parallel.
    await Task.WhenAll(input.Parties.Select(party =>
        context.CallActivityAsync(nameof(NotifyPartiesActivity), new PartyNotification(input.DocumentId, party))));

    // 2. Retry: charge under a backoff policy.
    try
    {
        await context.CallActivityAsync(
            nameof(ChargeOrProvisionActivity),
            new ChargeRequest(input.DocumentId, input.Amount, input.SimulateChargeFailure),
            ChargeRetry);
    }
    catch (WorkflowTaskFailedException)
    {
        // 3. Compensate, then report the failure back to the document entity.
        await context.CallActivityAsync(nameof(ReleaseReservationActivity), new ReleaseRequest(input.DocumentId, input.Amount));
        await context.CallActivityAsync(nameof(SignalDocumentActivity), new DocumentSignal(input.DocumentId, "SettlementFailed"));
        return new SettlementResult(Settled: false, FinalState: "SettlementFailed");
    }

    // 4. Success: drive the document to its archived outcome.
    await context.CallActivityAsync(nameof(SignalDocumentActivity), new DocumentSignal(input.DocumentId, "SettlementCompleted"));
    return new SettlementResult(Settled: true, FinalState: "Archived");
}

SignalDocumentActivity calls back into the interpreted actor over the dynamic path, client.InvokeAsync("ApprovalDocument", documentId, "Raise", ...), raising SettlementCompleted or SettlementFailed. The document’s turn runs after the approving turn has already committed, so this is a well-ordered follow-up rather than a re-entrant call.

The tests

The composition is testable with no sidecar and no workflow engine, from both sides of the seam.

The actor starts the workflow, re-run safely

Driving a document to Approved through the interpreted machine (with a fake workflow client) proves the entry effect schedules the workflow, and running the entry effect twice proves it does not start a second one, because the instance id is deterministic.

[Fact]
public async Task Approving_schedules_settlement_with_a_deterministic_id_and_does_not_double_schedule()
{
    var scheduled = new List<string>();
    var workflowClient = new Mock<IDaprWorkflowClient>();
    workflowClient
        .Setup(client => client.ScheduleNewWorkflowAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<object?>()))
        .Returns((string _, string? instanceId, object? _) => { scheduled.Add(instanceId!); return Task.FromResult(instanceId!); });

    var effect = new StartSettlementEffect(workflowClient.Object, NullLogger.Instance);
    var context = ApprovedContext("exp-9", amount: 250m, parties: ["finance", "alice"]);

    await effect.ExecuteAsync(context);
    await effect.ExecuteAsync(context);   // simulate an at-least-once re-run of the approving turn

    var expected = StartSettlementEffect.InstanceIdFor("ApprovalDocument", "exp-9");
    Assert.All(scheduled, id => Assert.Equal(expected, id));
    Assert.Single(scheduled.Distinct());   // Dapr dedups by instance id, so a single workflow runs
}

The workflow retries and compensates

The workflow runs directly against a mocked WorkflowContext, the same technique the testing tools use for actors. One test makes the charge succeed and asserts the document is archived; the other makes the charge exhaust its retries (a WorkflowTaskFailedException) and asserts compensation runs and the document is driven to SettlementFailed.

[Fact]
public async Task Charge_that_exhausts_its_retries_compensates_and_fails_the_document()
{
    var input = new SettlementInput("exp-2", "ExpenseReport", "carol", 8500m, ["finance", "carol", "vendor"], SimulateChargeFailure: true);
    var context = MockContext();
    context.Setup(ctx => ctx.CallActivityAsync(nameof(ChargeOrProvisionActivity), It.IsAny<ChargeRequest>(), It.IsAny<WorkflowTaskOptions>()))
        .Returns(Task.FromException(new WorkflowTaskFailedException("charge failed", new WorkflowTaskFailureDetails("Declined", "Charge was declined"))));

    var result = await new SettlementWorkflow().RunAsync(context.Object, input);

    Assert.False(result.Settled);
    Assert.Equal("SettlementFailed", result.FinalState);
    context.Verify(ctx => ctx.CallActivityAsync(nameof(ReleaseReservationActivity), It.IsAny<ReleaseRequest>(), It.IsAny<WorkflowTaskOptions>()), Times.Once());
    context.Verify(ctx => ctx.CallActivityAsync(nameof(SignalDocumentActivity), It.Is<DocumentSignal>(s => s.EventName == "SettlementFailed"), It.IsAny<WorkflowTaskOptions>()), Times.Once());
}

A verification test rounds it out: a stranded definition (an Approved state with no way out) and a definition referencing an unregistered effect are both rejected before rollout, exactly as in Part 5, so a broken document type cannot be onboarded.

Why this composition is the point

A state machine actor and a workflow are easy to confuse, and this example demonstrates a more concrete boundary. The document is reactive, addressable, and long-lived: it answers where a document is right now and reacts to submit, review, and decision events over an indefinite life, which is what a state-machine actor is good at. Settlement is a bounded sequence with retries, a compensating undo, and a definite end, which is what a workflow is good at and what would bloat an actor turn. Named states make the seam obvious, because the transition into Approved is the natural place to start the workflow that state implies, and the workflow’s terminal outcome is the natural event to raise back.

Authoring and testing arc

Each tutorial grew a little more ambitious than the last, but the testing is the throughline: Minimal testing was necessary in part 1, then the testing proved a state migration without a database. Then we validated an event flow without a PubSub broker, made a race condition repeatable, and finally saw the state machine verifier implemented as a deployment gate. The takeaway: The old SDK could run actors; the new one lets you prove they are correct, including behavior that does not exist until runtime.

See also