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

Return to the regular view of this page.

Dapr .NET SDK

.NET SDK packages for developing Dapr applications

Dapr offers a variety of packages to help with the development of .NET applications. Using them you can create .NET clients, servers, and virtual actors with Dapr.

Prerequisites

Installation

To get started with the Client .NET SDK, install the Dapr .NET SDK package:

dotnet add package Dapr.Client

Try it out

Put the Dapr .NET SDK to the test. Walk through the .NET quickstarts and tutorials to see Dapr in action:

SDK samplesDescription
QuickstartsExperience Dapr’s API building blocks in just a few minutes using the .NET SDK.
SDK samplesClone the SDK repo to try out some examples and get started.
Pub/sub tutorialSee how Dapr .NET SDK works alongside other Dapr SDKs to enable pub/sub applications.

Available packages

Package NameDocumentation LinkDescription
Dapr.ClientDocumentationCreate .NET clients that interact with a Dapr sidecar and other Dapr applications.
Dapr.AIDocumentationCreate and manage AI operations in .NET.
Dapr.AI.A2aDapr SDK for implementing agent-to-agent operations using the A2A framework.
Dapr.AI.Microsoft.ExtensionsDocumentationEasily interact with LLMs conversationally and using tooling via the Dapr Conversation building block.
Dapr.AspNetCoreDocumentationWrite servers and services in .NET using the Dapr SDK. Includes support and utilities providing richer integration with ASP.NET Core.
Dapr.ActorsDocumentationCreate virtual actors with state, reminders/timers, and methods.
Dapr.Actors.AspNetCoreDocumentationCreate virtual actors with state, reminders/timers, and methods with rich integration with ASP.NET Core.
Dapr.Actors.AnalyzersDocumentationA collection of Roslyn source generators and analyzers for enabling better practices and preventing common errors when using Dapr Actors in .NET.
Dapr.Actors.NextDocumentationCreate virtual actors with state, timers/reminders, state machines, integrated Pub/Sub and more!
Dapr.CryptographyDocumentationEncrypt and decrypt streaming state of any size using Dapr’s cryptography building block.
Dapr.JobsDocumentationCreate and manage the scheduling and orchestration of jobs.
Dapr.Jobs.AnalyzersDocumentationA collection of Roslyn source generators and analyzers for enabling better practices and preventing common errors when using Dapr Jobs in .NET.
Dapr.DistributedLocksDocumentationCreate and manage distributed locks for managing exclusive resource access.
Dapr.Extensions.ConfigurationDapr secret store configuration provider implementation for Microsoft.Extensions.Configuration.
Dapr.PluggableComponentsUsed to implement pluggable components with Dapr using .NET.
Dapr.PluggableComponents.AspNetCoreImplement pluggable components with Dapr using .NET with rich ASP.NET Core support.
Dapr.PluggableComponents.ProtosNote: Developers needn’t install this package directly in their applications.
Dapr.MessagingDocumentationBuild distributed applications using the Dapr Messaging SDK that utilize messaging components like streaming pub/sub subscriptions.
Dapr.MetadataDocumentationRetrieve typed Dapr runtime metadata in .NET through the options pattern.
Dapr.StateManagementDocumentCreate and manage state in Dapr applications.
Dapr.SecretsManagementDocumentRetrieve secrets in Dapr applications.
Dapr.TestcontainersDocumentationRun Dapr integration tests using Testcontainers-based harnesses.
Dapr.WorkflowDocumentationCreate and manage workflows that work with other Dapr APIs.

More information

Learn more about local development options, best practices, or browse NuGet packages to add to your existing .NET applications.

1 - Getting started with the Dapr client .NET SDK

How to get up and running with the Dapr .NET SDK

The Dapr client package allows you to interact with other Dapr applications from a .NET application.

Building blocks

The .NET SDK allows you to interface with all of the Dapr building blocks.

Invoke a service

HTTP

You can either use the DaprClient or System.Net.Http.HttpClient to invoke your services.

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDaprClient();
var app = builder.Build();

using var scope = app.Services.CreateScope();
var client = scope.ServiceProvider.GetRequiredService<DaprClient>();
 
// Invokes a POST method named "deposit" that takes input of type "Transaction"
var data = new { id = "17", amount = 99m };
var account = await client.InvokeMethodAsync<Account>("routing", "deposit", data, cancellationToken);
Console.WriteLine("Returned: id:{0} | Balance:{1}", account.Id, account.Balance);

using Microsoft.Extensins.Hosting; using Microsoft.Extensions.DependencyInjection;

var builder = Host.CreateApplicationBuilder(args); builder.Services.AddDaprClient(); var app = builder.Build();

using var scope = app.Services.CreateScope(); var client = scope.ServiceProvider.GetRequiredService();

// Invokes a POST method named “deposit” that takes input of type “Transaction” var data = new { id = “17”, amount = 99m }; var account = await client.InvokeMethodAsync(“routing”, “deposit”, data, cancellationToken); Console.WriteLine(“Returned: id:{0} | Balance:{1}”, account.Id, account.Balance);

var client = DaprClient.CreateInvokeHttpClient(appId: "routing");

// To set a timeout on the HTTP client:
client.Timeout = TimeSpan.FromSeconds(2);

var deposit = new Transaction  { Id = "17", Amount = 99m };
var response = await client.PostAsJsonAsync("/deposit", deposit, cancellationToken);
var account = await response.Content.ReadFromJsonAsync<Account>(cancellationToken: cancellationToken);
Console.WriteLine("Returned: id:{0} | Balance:{1}", account.Id, account.Balance);

gRPC

You can use the DaprClient to invoke your services over gRPC.

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20));
var invoker = DaprClient.CreateInvocationInvoker(appId: myAppId, daprEndpoint: serviceEndpoint);
var client = new MyService.MyServiceClient(invoker);

var options = new CallOptions(cancellationToken: cts.Token, deadline: DateTime.UtcNow.AddSeconds(1));
await client.MyMethodAsync(new Empty(), options);

Assert.Equal(StatusCode.DeadlineExceeded, ex.StatusCode);

Save & get application state

var state = new Widget() { Size = "small", Color = "yellow", };
await client.SaveStateAsync(storeName, stateKeyName, state, cancellationToken: cancellationToken);
Console.WriteLine("Saved State!");

state = await client.GetStateAsync<Widget>(storeName, stateKeyName, cancellationToken: cancellationToken);
Console.WriteLine($"Got State: {state.Size} {state.Color}");

await client.DeleteStateAsync(storeName, stateKeyName, cancellationToken: cancellationToken);
Console.WriteLine("Deleted State!");

Query State (Alpha)

var query = "{" +
                "\"filter\": {" +
                    "\"EQ\": { \"value.Id\": \"1\" }" +
                "}," +
                "\"sort\": [" +
                    "{" +
                        "\"key\": \"value.Balance\"," +
                        "\"order\": \"DESC\"" +
                    "}" +
                "]" +
            "}";
var queryResponse = await client.QueryStateAsync<Account>("querystore", query, cancellationToken: cancellationToken);

Console.WriteLine($"Got {queryResponse.Results.Count}");
foreach (var account in queryResponse.Results)
{
    Console.WriteLine($"Account: {account.Data.Id} has {account.Data.Balance}");
}

Publish messages

var eventData = new { Id = "17", Amount = 10m, };
await client.PublishEventAsync(pubsubName, "deposit", eventData, cancellationToken);
Console.WriteLine("Published deposit event!");

Interact with output bindings

When calling InvokeBindingAsync, you have the option to handle serialization and encoding yourself, or to have the SDK serialize it to JSON and then encode it to bytes for you.

Manual serialization

For most scenarios, you’re advised to use this overload of InvokeBindingAsync as it gives you clarity and control over how the data is being handled.

In this example, the data is sent as the UTF-8 byte representation of the string.

using var client = new DaprClientBuilder().Build();

var request = new BindingRequest("send-email", "create")
{
    // note: This is an example payload for the Twilio SendGrid binding 
    Data = Encoding.UTF8.GetBytes("<h1>Testing Dapr Bindings</h1>This is a test.<br>Bye!"),
    Metadata =
    {
        { "emailTo", "customer@example.com" },
        { "subject", "An email from Dapr SendGrid binding" },
    },
}
await client.InvokeBindingAsync(request);

Automatic serialzation and encoding

In this example, the data is sent as a UTF-8 encoded byte representation of the value serialized to JSON.

using var client = new DaprClientBuilder().Build();

var email = new 
{
    // note: This is an example payload for the Twilio SendGrid binding 
    data =  "<h1>Testing Dapr Bindings</h1>This is a test.<br>Bye!",
    metadata = new 
    {
        emailTo = "customer@example.com",
        subject = "An email from Dapr SendGrid binding",    
    },
};
await client.InvokeBindingAsync("send-email", "create", email);

Retrieve secrets

Prior to retrieving secrets, it’s important that the outbound channel be registered and ready or the SDK will be unable to communicate bidirectionally with the Dapr sidecar. The SDK provides a helper method intended to be used for this purpose called CheckOutboundHealthAsync. This isn’t referring to outbound from the SDK to the runtime, so much as outbound from the Dapr runtime back into the client application using the SDK.

This method is simply opening a connection to the https://docs.dapr.io/reference/api/health_api/#wait-for-specific-health-check-against-outbound-path endpoint in the Dapr Health API and evaluating the HTTP status code returned to determine the health of the endpoint as reported by the runtime.

It’s important to note that this and the WaitForSidecarAsync methods perform nearly identical operations; WaitForSidecarAsync polls the CheckOutboundHealthAsync endpoint indefinitely until it returns a healthy status value. They are intended exclusively for situations like secrets or configuration retrieval. Using them in other scenarios will result in unintended behavior (e.g., the endpoint never being ready because there are no registered components that use an “outbound” channel).

This behavior will be changed in a future release and should only be relied on sparingly.

// Get an instance of the DaprClient from DI
var client = scope.GetRequiredService<DaprClient>();

// Wait for the outbound channel to be established - only use for this scenario and not generally
await client.WaitForOutboundHealthAsync();

// Retrieve a key-value-pair-based secret - returns a Dictionary<string, string>
var secrets = await client.GetSecretAsync("mysecretstore", "key-value-pair-secret");
Console.WriteLine($"Got secret keys: {string.Join(", ", secrets.Keys)}");
// Get an instance of the DaprClient from DI
var client = scope.GetRequiredService<DaprClient>();

// Wait for the outbound channel to be established - only use for this scenario and not generally
await client.WaitForOutboundHealthAsync();

// Retrieve a key-value-pair-based secret - returns a Dictionary<string, string>
var secrets = await client.GetSecretAsync("mysecretstore", "key-value-pair-secret");
Console.WriteLine($"Got secret keys: {string.Join(", ", secrets.Keys)}");

// Retrieve a single-valued secret - returns a Dictionary<string, string>
// containing a single value with the secret name as the key
var data = await client.GetSecretAsync("mysecretstore", "single-value-secret");
var value = data["single-value-secret"]
Console.WriteLine("Got a secret value, I'm not going to be print it, it's a secret!");

Get Configuration Keys

// Retrieve a specific set of keys.
var specificItems = await client.GetConfiguration("configstore", new List<string>() { "key1", "key2" });
Console.WriteLine($"Here are my values:\n{specificItems[0].Key} -> {specificItems[0].Value}\n{specificItems[1].Key} -> {specificItems[1].Value}");

// Retrieve all configuration items by providing an empty list.
var specificItems = await client.GetConfiguration("configstore", new List<string>());
Console.WriteLine($"I got {configItems.Count} entires!");
foreach (var item in configItems)
{
    Console.WriteLine($"{item.Key} -> {item.Value}")
}

Subscribe to Configuration Keys

// The Subscribe Configuration API returns a wrapper around an IAsyncEnumerable<IEnumerable<ConfigurationItem>>.
// Iterate through it by accessing its Source in a foreach loop. The loop will end when the stream is severed
// or if the cancellation token is cancelled.
var subscribeConfigurationResponse = await daprClient.SubscribeConfiguration(store, keys, metadata, cts.Token);
await foreach (var items in subscribeConfigurationResponse.Source.WithCancellation(cts.Token))
{
    foreach (var item in items)
    {
        Console.WriteLine($"{item.Key} -> {item.Value}")
    }
}

Distributed lock (Alpha)

Acquire a lock

using System;
using Dapr.Client;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.DependencyInjection;

namespace LockService
{
    class Program
    {
        [Obsolete("Distributed Lock API is in Alpha, this can be removed once it is stable.")]
        static async Task Main(string[] args)
        {
            const string daprLockName = "lockstore";
            const string fileName = "my_file_name";
            
            var builder = Host.CreateDefaultBuilder();
            builder.ConfigureServices(services =>
            {
                services.AddDaprClient();
            });
            var app = builder.Build();
            
            using var scope = app.Services.CreateScope();
            var client = scope.ServiceProvider.GetRequiredService<DaprClient>();
     
            // Locking with this approach will also unlock it automatically, as this is a disposable object
            await using (var fileLock = await client.Lock(DAPR_LOCK_NAME, fileName, "random_id_abc123", 60))
            {
                if (fileLock.Success)
                {
                    Console.WriteLine("Success");
                }
                else
                {
                    Console.WriteLine($"Failed to lock {fileName}.");
                }
            }
        }
    }
}

Unlock an existing lock

using System;
using Dapr.Client;

namespace LockService
{
    class Program
    {
        static async Task Main(string[] args)
        {
            var daprLockName = "lockstore";
            
            var builder = Host.CreateDefaultBuilder();
            builder.ConfigureServices(services =>
            {
                services.AddDaprClient();
            });
            var app = builder.Build();
            
            using var scope = app.Services.CreateScope();
            var client = scope.ServiceProvider.GetRequiredService<DaprClient>();
            
            var response = await client.Unlock(DAPR_LOCK_NAME, "my_file_name", "random_id_abc123"));
            Console.WriteLine(response.status);
        }
    }
}

Sidecar APIs

Sidecar Health

While the .NET SDK provides a way to poll for the sidecar health, it is not generally recommended that developer utilize this functionality unless they are explicitly using Dapr to also retrieve secrets or configuration values.

There are two methods available:

The “outbound” direction refers to the communication outbound from the Dapr runtime to your application. If your application doesn’t use actors, secret management, configuration retrieval or workflows, the runtime will not attempt to create an outbound connection. This means that if your application takes a dependency on WaitForSidecarAsync without using any of these Dapr components, it will indefinitely lock up during startup as the endpoint will never be established.

A future release will remove these methods altogether and perform this as an internal SDK operation, so neither method should be relied on in general. Reach out in the Discord #dotnet-sdk channel for more clarification as to whether your scenario may necessitate using this, but in most situations, these methods should not be required.

Shutdown the sidecar

var client = new DaprClientBuilder().Build();
await client.ShutdownSidecarAsync();

1.1 - DaprClient usage

Essential tips and advice for using DaprClient

Lifetime management

A DaprClient holds access to networking resources in the form of TCP sockets used to communicate with the Dapr sidecar. DaprClient implements IDisposable to support eager cleanup of resources.

Dependency Injection

To get started with ASP.NET Core dependency injection, install the Dapr.AspNetCore package from NuGet:

dotnet add package Dapr.AspNetCore

The AddDaprClient() method will register the Dapr client with ASP.NET Core dependency injection. This method accepts an optional options delegate for configuring the DaprClient and an ServiceLifetime argument, allowing you to specify a different lifetime for the registered resources instead of the default Singleton value.

The following example assumes all default values are acceptable and is sufficient to register the DaprClient.

services.AddDaprClient();

The optional configuration delegates are used to configure DaprClient by specifying options on the provided DaprClientBuilder as in the following example:

services.AddDaprClient(daprBuilder => {
    daprBuilder.UseJsonSerializationOptions(new JsonSerializerOptions {
            WriteIndented = true,
            MaxDepth = 8
        });
    daprBuilder.UseTimeout(TimeSpan.FromSeconds(30));
});

The another optional configuration delegate overload provides access to both the DaprClientBuilder as well as an IServiceProvider allowing for more advanced configurations that may require injecting services from the dependency injection container.

services.AddSingleton<SampleService>();
services.AddDaprClient((serviceProvider, daprBuilder) => {
    var sampleService = serviceProvider.GetRequiredService<SampleService>();
    var timeoutValue = sampleService.TimeoutOptions;
    
    daprBuilder.UseTimeout(timeoutValue);
});

Manual Instantiation

Rather than using dependency injection, a DaprClient can also be built using the static client builder.

For best performance, create a single long-lived instance of DaprClient and provide access to that shared instance throughout your application. DaprClient instances are thread-safe and intended to be shared.

Avoid creating a DaprClient per-operation and disposing it when the operation is complete.

Configuring DaprClient

A DaprClient can be configured by invoking methods on DaprClientBuilder class before calling .Build() to create the client. The settings for each DaprClient object are separate and cannot be changed after calling .Build().

var daprClient = new DaprClientBuilder()
    .UseJsonSerializerSettings( ... ) // Configure JSON serializer
    .Build();

By default, the DaprClientBuilder will prioritize the following locations, in the following order, to source the configuration values:

  • The value provided to a method on the DaprClientBuilder (e.g. UseTimeout(TimeSpan.FromSeconds(30)))
  • The value pulled from an optionally injected IConfiguration matching the name expected in the associated environment variable
  • The value pulled from the associated environment variable
  • Default values

Configuring on DaprClientBuilder

The DaprClientBuilder contains the following methods to set configuration options:

  • UseHttpEndpoint(string): The HTTP endpoint of the Dapr sidecar
  • UseGrpcEndpoint(string): Sets the gRPC endpoint of the Dapr sidecar
  • UseGrpcChannelOptions(GrpcChannelOptions): Sets the gRPC channel options used to connect to the Dapr sidecar
  • UseHttpClientFactory(IHttpClientFactory): Configures the DaprClient to use a registered IHttpClientFactory when building HttpClient instances
  • UseJsonSerializationOptions(JsonSerializerOptions): Used to configure JSON serialization
  • UseDaprApiToken(string): Adds the provided token to every request to authenticate to the Dapr sidecar
  • UseTimeout(TimeSpan): Specifies a timeout value used by the HttpClient when communicating with the Dapr sidecar

Configuring From IConfiguration

Rather than rely on sourcing configuration values directly from environment variables or because the values are sourced from dependency injected services, another options is to make these values available on IConfiguration.

For example, you might be registering your application in a multi-tenant environment and need to prefix the environment variables used. The following example shows how these values can be sourced from the environment variables to your IConfiguration when their keys are prefixed with test_;

var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddEnvironmentVariables("test_"); //Retrieves all environment variables that start with "test_" and removes the prefix when sourced from IConfiguration
builder.Services.AddDaprClient();

Configuring From Environment Variables

The SDK will read the following environment variables to configure the default values:

  • DAPR_HTTP_ENDPOINT: used to find the HTTP endpoint of the Dapr sidecar, example: https://dapr-api.mycompany.com
  • DAPR_GRPC_ENDPOINT: used to find the gRPC endpoint of the Dapr sidecar, example: https://dapr-grpc-api.mycompany.com
  • DAPR_HTTP_PORT: if DAPR_HTTP_ENDPOINT is not set, this is used to find the HTTP local endpoint of the Dapr sidecar assuming ‘127.0.0.1’
  • DAPR_GRPC_PORT: if DAPR_GRPC_ENDPOINT is not set, this is used to find the gRPC local endpoint of the Dapr sidecar assuming ‘127.0.0.1’
  • DAPR_API_TOKEN: used to set the API Token

As a general rule, either both the HTTP and gRPC ports or both the HTTP and gRPC endpoints should be specified. Practically speaking, whether HTTP or gRPC is used depends on the specific Dapr services you’re using and whether the .NET SDK supports an HTTP or gRPC protocol. Over time, the vast majority of the .NET SDK will support gRPC exclusively, but this will never be the case for all services, so it’s a better practice to future-proof your application and always specify both values.

Configuring gRPC channel options

Dapr’s use of CancellationToken for cancellation relies on the configuration of the gRPC channel options and this is enabled by default. If you need to configure these options yourself, make sure to enable the ThrowOperationCanceledOnCancellation setting.

var daprClient = new DaprClientBuilder()
    .UseGrpcChannelOptions(new GrpcChannelOptions { ... ThrowOperationCanceledOnCancellation = true })
    .Build();

Using cancellation with DaprClient

The APIs on DaprClient that perform asynchronous operations accept an optional CancellationToken parameter. This follows a standard .NET idiom for cancellable operations. Note that when cancellation occurs, there is no guarantee that the remote endpoint stops processing the request, only that the client has stopped waiting for completion.

When an operation is cancelled, it will throw an OperationCancelledException.

Understanding DaprClient JSON serialization

Many methods on DaprClient perform JSON serialization using the System.Text.Json serializer. Methods that accept an application data type as an argument will JSON serialize it, unless the documentation clearly states otherwise.

It is worth reading the System.Text.Json documentation if you have advanced requirements. The Dapr .NET SDK provides no unique serialization behavior or customizations - it relies on the underlying serializer to convert data to and from the application’s .NET types.

DaprClient is configured to use a serializer options object configured from JsonSerializerDefaults.Web. This means that DaprClient will use camelCase for property names, allow reading quoted numbers ("10.99"), and will bind properties case-insensitively. These are the same settings used with ASP.NET Core and the System.Text.Json.Http APIs, and are designed to follow interoperable web conventions.

System.Text.Json as of .NET 5.0 does not have good support for all of F# language features built-in. If you are using F# you may want to use one of the converter packages that add support for F#’s features such as FSharp.SystemTextJson.

Simple guidance for JSON serialization

Your experience using JSON serialization and DaprClient will be smooth if you use a feature set that maps to JSON’s type system. These are general guidelines that will simplify your code where they can be applied.

  • Avoid inheritance and polymorphism
  • Do not attempt to serialize data with cyclic references
  • Do not put complex or expensive logic in constructors or property accessors
  • Use .NET types that map cleanly to JSON types (numeric types, strings, DateTime)
  • Create your own classes for top-level messages, events, or state values so you can add properties in the future
  • Design types with get/set properties OR use the supported pattern for immutable types with JSON

Polymorphism and serialization

The System.Text.Json serializer used by DaprClient uses the declared type of values when performing serialization.

This section will use DaprClient.SaveStateAsync<TValue>(...) in examples, but the advice is applicable to any Dapr building block exposed by the SDK.

public class Widget
{
    public string Color { get; set; }
}
...

// Storing a Widget value as JSON in the state store
widget widget = new Widget() { Color = "Green", };
await client.SaveStateAsync("mystatestore", "mykey", widget);

In the example above, the type parameter TValue has its type argument inferred from the type of the widget variable. This is important because the System.Text.Json serializer will perform serialization based on the declared type of the value. The result is that the JSON value { "color": "Green" } will be stored.

Consider what happens when you try to use derived type of Widget:

public class Widget
{
    public string Color { get; set; }
}

public class SuperWidget : Widget
{
    public bool HasSelfCleaningFeature { get; set; }
}
...

// Storing a SuperWidget value as JSON in the state store
Widget widget = new SuperWidget() { Color = "Green", HasSelfCleaningFeature = true, };
await client.SaveStateAsync("mystatestore", "mykey", widget);

In this example we’re using a SuperWidget but the variable’s declared type is Widget. Since the JSON serializer’s behavior is determined by the declared type, it only sees a simple Widget and will save the value { "color": "Green" } instead of { "color": "Green", "hasSelfCleaningFeature": true }.

If you want the properties of SuperWidget to be serialized, then the best option is to override the type argument with object. This will cause the serializer to include all data as it knows nothing about the type.

Widget widget = new SuperWidget() { Color = "Green", HasSelfCleaningFeature = true, };
await client.SaveStateAsync<object>("mystatestore", "mykey", widget);

Error handling

Methods on DaprClient will throw DaprException or a subclass when a failure is encountered.

try
{
    var widget = new Widget() { Color = "Green", };
    await client.SaveStateAsync("mystatestore", "mykey", widget);
}
catch (DaprException ex)
{
    // handle the exception, log, retry, etc.
}

The most common cases of failure will be related to:

  • Incorrect configuration of Dapr component
  • Transient failures such as a networking problem
  • Invalid data, such as a failure to deserialize JSON

In any of these cases you can examine more exception details through the .InnerException property.

2 - Dapr Workflow .NET SDK

Get up and running with Dapr Workflow and the Dapr .NET SDK

2.1 - DaprWorkflowClient lifetime management and registration

Learn how to configure the DaprWorkflowClient lifetime management and dependency injection

Lifetime management

A DaprWorkflowClient holds access to networking resources in the form of TCP sockets used to communicate with the Dapr sidecar as well as other types used in the management and operation of Workflows. DaprWorkflowClient implements IAsyncDisposable to support eager cleanup of resources.

Dependency Injection

The AddDaprWorkflow() method will register the Dapr workflow services with ASP.NET Core dependency injection. This method requires an options delegate that defines each of the workflows and activities you wish to register and use in your application.

Change gRPC Message Size Limits

You can also configure gRPC message size limits for the workflow client during registration. This is useful when workflow payloads are larger than the default gRPC limits.

services
    .AddDaprWorkflowClient()
    .WithGrpcMessageSizeLimits( 
        maxReceiveMessageSize: 16 * 1024 * 1024, 
        maxSendMessageSize: 16 * 1024 * 1024);

Change gRPC Message Size Limits

You can also configure gRPC message size limits for the workflow client during registration. This is useful when workflow payloads are larger than the default gRPC limits.

services
    .AddDaprWorkflowClient()
    .WithGrpcMessageSizeLimits( 
        maxReceiveMessageSize: 16 * 1024 * 1024, 
        maxSendMessageSize: 16 * 1024 * 1024);

Singleton Registration

By default, the AddDaprWorkflow method registers the DaprWorkflowClient and associated services using a singleton lifetime. This means that the services are instantiated only a single time.

The following is an example of how registration of the DaprWorkflowClient as it would appear in a typical Program.cs file:

builder.Services.AddDaprWorkflow();

var app = builder.Build();
await app.RunAsync();

Scoped Registration

While this may generally be acceptable in your use case, you may instead wish to override the lifetime specified. This is done by passing a ServiceLifetime argument in AddDaprWorkflow. For example, you may wish to inject another scoped service into your ASP.NET Core processing pipeline that needs context used by the DaprWorkflowClient that wouldn’t be available if the former service were registered as a singleton.

This is demonstrated in the following example:

builder.Services.AddDaprWorkflow(serviceLifetime: ServiceLifecycle.Scoped);

var app = builder.Build();
await app.RunAsync();

Transient Registration

Finally, Dapr services can also be registered using a transient lifetime meaning that they will be initialized every time they’re injected. This is demonstrated in the following example:

builder.Services.AddDaprWorkflow(serviceLifetime: ServiceLifecycle.Transient);

var app = builder.Build();
await app.RunAsync();

Using a DaprWorkflowClient instance

In an ASP.Net Core application, you can inject the DaprWorkflowClient into methods or controllers via method or constructor injection. This example demonstrates method injection in a minimal API scenario:

app.MapPost("/start", async (
    [FromServices] DaprWorkflowClient daprWorkflowClient,
    Order order
    ) => {
        var instanceId = await daprWorkflowClient.ScheduleNewWorkflowAsync(
            nameof(OrderProcessingWorkflow),
            input: order);

        return Results.Accepted(instanceId);
});

To create a DaprWorkflowClient instance in a console app, retrieve it from the ServiceProvider:

using var scope = host.Services.CreateAsyncScope();
var daprWorkflowClient = scope.ServiceProvider.GetRequiredService<DaprWorkflowClient>();

Now, you can use this client to perform workflow management operations such as starting, pausing, resuming, and terminating a workflow instance. See Workflow management operations with DaprWorkflowClient for more information on these operations.

Injecting Services into Workflow Activities

Workflow activities support the same dependency injection that developers have come to expect of modern C# applications. Assuming a proper registration at startup, any such type can be injected into the constructor of the workflow activity and available to utilize during the execution of the workflow. This makes it simple to add logging via an injected ILogger or access to other Dapr building blocks by injecting DaprClient or DaprJobsClient, for example.

internal sealed class SquareNumberActivity(ILogger logger) : WorkflowActivity<int, int>
{    
    public override Task<int> RunAsync(WorkflowActivityContext context, int input) 
    {
        this.logger.LogInformation("Squaring the value {number}", input);
        var result = input * input;
        this.logger.LogInformation("Got a result of {squareResult}", result);
        
        return Task.FromResult(result);
    }
}

Activity task execution identifiers

Starting with Dapr .NET SDK v1.17.0, WorkflowActivityContext exposes a task execution identifier that is:

  • Unique per activity task
  • Stable across retries

This makes it useful for idempotency keys, task-level state tracking, and correlating logs.

internal sealed class IdempotentActivity : WorkflowActivity<int, int>
{
    public override Task<int> RunAsync(WorkflowActivityContext context, int input)
    {
        var executionId = context.TaskExecutionId;
        // Use executionId as your idempotency key or task state key.

        return Task.FromResult(input * input);
    }
}

Using ILogger in Workflow

Because workflows must be deterministic, it is not possible to inject arbitrary services into them. For example, if you were able to inject a standard ILogger into a workflow and it needed to be replayed because of an error, subsequent replay from the event source log would result in the log recording additional operations that didn’t actually take place a second or third time because their results were sourced from the log. This has the potential to introduce a significant amount of confusion. Rather, a replay-safe logger is made available for use within workflows. It will only log events the first time the workflow runs and will not log anything whenever the workflow is being replayed.

This logger can be retrieved from a method present on the WorkflowContext available on your workflow instance and otherwise used precisely as you might otherwise use an ILogger instance.

An end-to-end sample demonstrating this can be seen in the .NET SDK repository but a brief extraction of this sample is available below.

public sealed class OrderProcessingWorkflow : Workflow<OrderPayload, OrderResult>
{
    public override async Task<OrderResult> RunAsync(WorkflowContext context, OrderPayload order)
    {
        string orderId = context.InstanceId;
        var logger = context.CreateReplaySafeLogger<OrderProcessingWorkflow>(); //Use this method to access the logger instance

        logger.LogInformation("Received order {orderId} for {quantity} {name} at ${totalCost}", orderId, order.Quantity, order.Name, order.TotalCost);
        
        //...
    }
}

Next steps

2.2 - Workflow serialization in the .NET SDK

Configure workflow serialization for the Dapr .NET SDK

Overview

Starting with Dapr .NET SDK v1.17.0, Dapr.Workflow supports pluggable serialization. The SDK continues to use System.Text.Json by default, but you can now:

  • Override the default System.Text.Json settings.
  • Register a custom serializer (for example, MessagePack or BSON).

Serialization configuration is entirely client-side and does not require a specific Dapr runtime version.

Compatibility and breaking changes

Default JSON serialization

By default, the .NET SDK uses System.Text.Json with JsonSerializerDefaults.Web (see the JsonSerializerDefaults.Web reference). This means:

  • Property names are case-insensitive.
  • “camelCase” formatting is used for property names.
  • Quoted numbers (JSON strings for number properties) are allowed when reading.

This default convention is designed to be compatible with other Dapr language SDKs for multi-app workflows.

Override System.Text.Json defaults

To override the default JSON settings, register the workflow client using the workflow builder so you can provide custom JsonSerializerOptions:

builder.Services
    .AddDaprWorkflowBuilder(options =>
    {
        // Explicit registration is operation - the source generator discovers types automatically
        options.RegisterWorkflow<MyWorkflow>();
        options.RegisterActivity<MyActivity>();
    })
    .WithJsonSerializer(new JsonSerializerOptions { PropertyNamingPolicy = null });

All DaprWorkflowClient instances resolved from DI will use the provided JsonSerializerOptions for workflow and activity payloads.

Custom serialization providers

Custom serializers must implement the IWorkflowSerializer interface. The following example shows a MessagePack-based implementation that encodes data as Base64 strings for transport:

public sealed class MessagePackWorkflowSerializer : IWorkflowSerializer
{
    private readonly MessagePackSerializerOptions _options;

    public MessagePackWorkflowSerializer(MessagePackSerializerOptions options)
    {
        _options = options;
    }

    /// <inheritdoc/>
    public string Serialize(object? value, Type? inputType = null)
    {
        if (value == null)
        {
            return string.Empty;
        }

        var targetType = inputType ?? value.GetType();
        var bytes = MessagePackSerializer.Serialize(targetType, value, _options);
        return Convert.ToBase64String(bytes);
    }

    /// <inheritdoc/>
    public T? Deserialize<T>(string? data)
    {
        return (T?)Deserialize(data, typeof(T));
    }

    /// <inheritdoc/>
    public object? Deserialize(string? data, Type returnType)
    {
        if (returnType == null)
        {
            throw new ArgumentNullException(nameof(returnType));
        }

        if (string.IsNullOrEmpty(data))
        {
            return default;
        }

        try
        {
            var bytes = Convert.FromBase64String(data);
            return MessagePackSerializer.Deserialize(returnType, bytes, _options);
        }
        catch (FormatException ex)
        {
            throw new InvalidOperationException(
                "Failed to decode Base64 data. The input may not be valid MessagePack-serialized data.",
                ex);
        }
        catch (MessagePackSerializationException ex)
        {
            throw new InvalidOperationException(
                $"Failed to deserialize data to type {returnType.FullName}.",
                ex);
        }
    }
}

Register a custom serializer

Register the serializer with the workflow builder:

builder.Services
    .AddDaprWorkflowBuilder(options =>
    {
        // Explicit registration is operation - the source generator discovers types automatically
        options.RegisterWorkflow<MyWorkflow>();
        options.RegisterActivity<MyActivity>();
    })
    .WithSerializer(new MessagePackWorkflowSerializer(MessagePackSerializerOptions.Standard));

If you need DI-provided configuration, use the overload that receives an IServiceProvider:

builder.Services
    .AddDaprWorkflowBuilder(options =>
    {
        // Explicit registration is operation - the source generator discovers types automatically
        options.RegisterWorkflow<MyWorkflow>();
        options.RegisterActivity<MyActivity>();
    })
    .WithSerializer(serviceProvider =>
    {
        var options = serviceProvider
            .GetRequiredService<IOptions<MessagePackSerializerOptions>>()
            .Value;

        return new MessagePackWorkflowSerializer(options);
    });

2.3 - Multi-application workflows in the .NET SDK

Call activities and child workflows hosted in another Dapr application using the .NET SDK

Overview

Dapr workflows can call activities or child workflows that are hosted in a different Dapr application. In .NET, multi-application workflows are supported starting with:

  • Dapr runtime v1.16.0+
  • Dapr .NET SDK v1.17.0+

Conceptual guidance and constraints are covered in Multi Application Workflows.

Requirements

Multi-application workflow calls require:

  • The target app ID must exist and must register the activity or workflow you invoke.
  • All participating app IDs must be in the same namespace.
  • All participating app IDs must use the same workflow (actor) state store.

Call an activity in another application

Set TargetAppId on WorkflowTaskOptions when calling an activity to execute it in another app:

public sealed class BusinessWorkflow : Workflow<string, string>
{
    public override async Task<string> RunAsync(WorkflowContext context, string input)
    {
        var options = new WorkflowTaskOptions { TargetAppId = "App2" };
        var output = await context.CallActivityAsync<string>(nameof(ActivityA), input, options);
        return output;
    }
}

The parent workflow continues to orchestrate locally and receives the activity result.

Call a child workflow in another application

Set TargetAppId on ChildWorkflowTaskOptions when calling a child workflow to execute it in another app:

public sealed class BusinessWorkflow : Workflow<string, string>
{
    public override async Task<string> RunAsync(WorkflowContext context, string input)
    {
        var options = new ChildWorkflowTaskOptions { TargetAppId = "App2" };
        var output = await context.CallChildWorkflowAsync<string>(nameof(Workflow2), input, options);
        return output;
    }
}

Next steps

2.4 - Workflow history propagation in the .NET SDK

Share ancestor workflow execution history with child workflows and activities using the .NET SDK

Overview

Workflow history propagation allows a parent workflow to share its execution history — and optionally its full ancestor chain — with the child workflows and activities it calls. The child can then inspect those upstream events at runtime.

Common use cases include:

  • Audit trails: Verifying a chain of custody across a multi-step workflow
  • Fraud detection: Inspecting upstream decisions before committing a transaction
  • AI agent orchestration: Passing context through hierarchical agent workflows

Conceptual guidance is covered in Workflow history propagation.

Propagation scopes

Propagation is opt-in and per-call. Each call to CallActivityAsync or CallChildWorkflowAsync can independently specify a HistoryPropagationScope:

ScopeDescription
NoneDefault. No history is propagated to the callee.
OwnHistoryPropagates the calling workflow’s own events only. Ancestor history is dropped, acting as a trust boundary.
LineagePropagates the calling workflow’s events plus the full ancestor chain it inherited from its own parent.

Propagate history to a child workflow

Use WithHistoryPropagation on ChildWorkflowTaskOptions to opt a child workflow into receiving the parent’s history:

public sealed class MerchantCheckoutWorkflow : Workflow<Order, CheckoutResult>
{
    public override async Task<CheckoutResult> RunAsync(WorkflowContext context, Order order)
    {
        // Activity without propagation — default behavior, no opt-in
        await context.CallActivityAsync(nameof(ValidateMerchantActivity), order.MerchantId);

        // Child workflow with full lineage propagation
        var options = new ChildWorkflowTaskOptions()
            .WithHistoryPropagation(HistoryPropagationScope.Lineage);

        var result = await context.CallChildWorkflowAsync<PaymentResult>(
            nameof(ProcessPaymentWorkflow), order, options);

        return new CheckoutResult(result);
    }
}

Calls that do not specify a propagation scope receive no history — other calls in the same workflow are unaffected by opt-ins.

Propagate history to an activity

The same WithHistoryPropagation extension is available on WorkflowTaskOptions for activity calls:

var options = new WorkflowTaskOptions()
    .WithHistoryPropagation(HistoryPropagationScope.OwnHistory);

var auditResult = await context.CallActivityAsync<AuditResult>(
    nameof(WriteAuditTrailActivity), payload, options);

Read propagated history

Inside a child workflow, call GetPropagatedHistory() on WorkflowContext to retrieve the history passed by the parent. The method returns null if propagation was not requested for this invocation.

public sealed class ProcessPaymentWorkflow : Workflow<Order, PaymentResult>
{
    public override async Task<PaymentResult> RunAsync(WorkflowContext context, Order order)
    {
        var history = context.GetPropagatedHistory();

        if (history != null)
        {
            foreach (var evt in history.Events)
            {
                // evt.Name, evt.InstanceId, evt.AppId
                // evt.Activities — activity results for this ancestor
                // evt.Workflows  — child workflow results for this ancestor
            }
        }

        return await context.CallActivityAsync<PaymentResult>(
            nameof(ChargeCardActivity), order);
    }
}

PropagatedHistory type

GetPropagatedHistory() returns a PropagatedHistory object (or null). Its Events property is an ordered list of PropagatedHistoryEvent values — one per ancestor workflow, in execution order (oldest ancestor first, immediate parent last).

Each PropagatedHistoryEvent represents a single ancestor workflow’s contribution to the propagated history:

MemberTypeDescription
AppIdstringDapr app ID that ran this workflow
InstanceIdstringWorkflow instance ID
NamestringThe name of the workflow
ActivitiesIReadOnlyList<PropagatedHistoryActivityResult>Activity results for this workflow, in execution order
WorkflowsIReadOnlyList<PropagatedHistoryWorkflowResult>Child workflow results for this workflow, in execution order

PropagatedHistoryActivityResult type

Each PropagatedHistoryActivityResult is a sealed record describing a single activity invocation:

MemberTypeDescription
NamestringThe scheduled name of the activity
StatusPropagatedHistoryStatusLifecycle status — Pending, Completed, or Failed
Inputstring?JSON-encoded input payload, or null when unset
Outputstring?JSON-encoded output payload, or null when the activity has not completed
FailureDetailsWorkflowTaskFailureDetails?Failure details when Status is Failed, otherwise null

PropagatedHistoryWorkflowResult type

Each PropagatedHistoryWorkflowResult is a sealed record describing a single child workflow invocation:

MemberTypeDescription
NamestringThe scheduled name of the child workflow
StatusPropagatedHistoryStatusLifecycle status — Pending, Completed, or Failed
Outputstring?JSON-encoded output payload, or null when the workflow has not completed
FailureDetailsWorkflowTaskFailureDetails?Failure details when Status is Failed, otherwise null

PropagatedHistoryStatus enum

PropagatedHistoryStatus reflects how far a task progressed past scheduling:

ValueDescription
PendingThe task was scheduled but has not yet completed or failed
CompletedThe task completed successfully
FailedThe task failed

Query propagated history

PropagatedHistory query methods

PropagatedHistory provides Get methods that return lists and TryGet methods that return a single match (the most recent) via an out parameter. All Get methods return an empty list when no match is found.

MethodReturn typeDescription
GetByAppId(string)IReadOnlyList<PropagatedHistoryEvent>All events from the given Dapr app ID
GetByInstanceId(string)IReadOnlyList<PropagatedHistoryEvent>All events from the given workflow instance ID
GetEventsByWorkflowName(string)IReadOnlyList<PropagatedHistoryEvent>All events with the given workflow name
TryGetLastWorkflowEventByName(string, out PropagatedHistoryEvent?)boolGets the most recent event matching the workflow name
GetAppIds()IReadOnlyList<string>Ordered, deduplicated list of app IDs in the history

PropagatedHistoryEvent query methods

Each PropagatedHistoryEvent also provides query methods to inspect the activities and child workflows within that ancestor:

MethodReturn typeDescription
GetActivitiesByName(string)IReadOnlyList<PropagatedHistoryActivityResult>All activities matching the given name
TryGetLastActivityByName(string, out PropagatedHistoryActivityResult?)boolGets the most recent activity matching the name
GetWorkflowsByName(string)IReadOnlyList<PropagatedHistoryWorkflowResult>All child workflows matching the given name
TryGetLastWorkflowByName(string, out PropagatedHistoryWorkflowResult?)boolGets the most recent child workflow matching the name

Example

var history = context.GetPropagatedHistory();

if (history != null)
{
    // By app ID — useful in multi-app workflows
    var fromOrderApp = history.GetByAppId("order-app");

    // By workflow instance ID
    var fromSpecificRun = history.GetByInstanceId("checkout-abc123");

    // By workflow name — returns all matches (e.g. recursion or ContinueAsNew)
    var checkoutEvents = history.GetEventsByWorkflowName(nameof(MerchantCheckoutWorkflow));

    // TryGet for a single match — avoids null ambiguity
    if (history.TryGetLastWorkflowEventByName(nameof(MerchantCheckoutWorkflow), out var parentEvent))
    {
        // Inspect the parent's activities
        var failedActivities = parentEvent.Activities
            .Where(a => a.Status == PropagatedHistoryStatus.Failed)
            .ToList();

        // Or look up a specific activity by name
        if (parentEvent.TryGetLastActivityByName(nameof(ValidateMerchantActivity), out var validation))
        {
            // validation.Status, validation.Output, validation.FailureDetails
        }
    }
}

Security considerations

By default, Dapr uses mutual TLS (mTLS) between sidecars for all cross-app communication, providing transport-layer protection for propagated history in multi-app workflow scenarios.

For stronger guarantees in production, enable WorkflowHistorySigning. This feature uses SPIFFE identity to cryptographically sign each history chunk, so the receiving workflow can verify the integrity and origin of the propagated history. Without signing enabled, Dapr emits a warning that propagated chunks lack cryptographic verification.

See Workflow history propagation for details on configuring WorkflowHistorySigning.

Next steps

2.5 - Workflow management operations with DaprWorkflowClient

Learn how to use the DaprWorkflowClient to manage workflows

Workflow management operations with DaprWorkflowClient

The DaprWorkflowClient class provides methods to manage workflow instances. Below are the operations you can perform using the DaprWorkflowClient.

Schedule a new workflow instance

To start a new workflow instance, use the ScheduleNewWorkflowAsync method. This method requires the workflow type name and an input required by the workflow. The workflow instancedId is an optional argument; if not provided, a new GUID is generated by the DaprWorkflowClient. The final optional argument is a startTime of type DateTimeOffset which can be used to define when the workflow instance should start. The method returns the instanceId of the scheduled workflow which is used for other workflow management operations.

var instanceId = $"order-workflow-{Guid.NewGuid().ToString()[..8]}";
var input = new Order("Paperclips", 1000, 9.95);
await daprWorkflowClient.ScheduleNewWorkflowAsync(
  nameof(OrderProcessingWorkflow),
  instanceId,
  input);

Retrieve the status of a workflow instance

To get the current status of a workflow instance, use the GetWorkflowStateAsync method. This method requires the instance ID of the workflow and returns a WorkflowStatus object containing details about the workflow’s current state.

var workflowStatus = await daprWorkflowClient.GetWorkflowStateAsync(instanceId);

Raise an event to a running workflow instance

To send an event to a running workflow instance that is waiting for an external event, use the RaiseEventAsync method. This method requires the instance ID of the workflow, the name of the event, and optionally the event payload.

await daprWorkflowClient.RaiseEventAsync(instanceId, "Approval", true);

Suspend a running workflow instance

A running workflow instance can be paused using the SuspendWorkflowAsync method. This method requires the instance ID of the workflow. You can optionally provide a reason for suspending the workflow.

await daprWorkflowClient.SuspendWorkflowAsync(instanceId);

Resume a suspended workflow instance

A suspended workflow instance can be resumed using the ResumeWorkflowAsync method. This method requires the instance ID of the workflow. You can optionally provide a reason for resuming the workflow.

await daprWorkflowClient.ResumeWorkflowAsync(instanceId);

Terminate a workflow instance

To terminate a workflow instance, use the TerminateWorkflowAsync method. This method requires the instance ID of the workflow. You can optionally provide an output argument of type string. Terminating a workflow instance will also terminal all child workflow instances but it has no impact on in-flight activity executions.

await daprWorkflowClient.TerminateWorkflowAsync(instanceId);

Purge a workflow instance

To remove the workflow instance history from the Dapr Workflow state store, use the PurgeWorkflowAsync method. This method requires the instance ID of the workflow. Only completed, failed, or terminated workflow instances can be purged.

await daprWorkflowClient.PurgeWorkflowAsync(instanceId);

Next steps

2.6 - Workflow versioning in the .NET SDK

Learn how to use patch-based and name-based workflow versioning in the Dapr .NET SDK

Overview

Dapr Workflow versioning lets you evolve workflows without breaking deterministic execution for in-flight instances. The .NET SDK supports two approaches:

  • Patch-based versioning: introduce conditional branches guarded by context.IsPatched("patch-name").
  • Name-based versioning: create a new workflow type name and let a versioning strategy select the newest version.

Use patch-based versioning for small, in-place changes. Use name-based versioning for larger refactors where you want a clean new workflow type.

When to use each approach

Patch-based versioning is a good fit when:

  • You need small, incremental changes in an existing workflow.
  • You want existing instances to keep deterministic behavior after deployment.
  • You want to avoid introducing a new workflow type yet.

Name-based versioning is a good fit when:

  • You want a clean, new workflow type without accumulated patches.
  • You are ready to remove old patch blocks and refactor more freely.
  • You want version selection to be automatic based on naming conventions.

Patch-based versioning

Patch-based versioning relies on a deterministic switch inside the workflow. Use WorkflowContext.IsPatched to guard new behavior:

public override async Task RunAsync(WorkflowContext context, OrderPayload input)
{
    await context.CallActivityAsync(nameof(ReserveInventoryActivity), input);

    if (context.IsPatched("v2"))
    {
        await context.CallActivityAsync(nameof(ChargePaymentActivityV2), input);
    }
    else
    {
        await context.CallActivityAsync(nameof(ChargePaymentActivity), input);
    }
}

Patch rules

  • Patch names can appear multiple times in the same workflow and can be nested.
  • Patch names must be unique across deployments. For example, if you deployed a workflow with a patch name "v1",
  • you must not reuse "v1" in later edits. Use a new identifier such as "v2" to avoid non-deterministic behavior.
  • IsPatched is available on WorkflowContext; no additional setup is required.

Name-based versioning

Name-based versioning lets you create a new workflow version by changing the workflow type name. The recommended pattern is to copy the existing workflow to a new file, rename the class, refactor as needed, and then start patching again if necessary.

For example, if you had OrderWorkflow, create OrderWorkflowV2 and refactor it. Older versions can remain for in-flight instances while new instances use the latest version.

Default naming behavior

By default, name-based versioning uses the built-in NumericVersionStrategy with a numeric suffix. The following are all valid examples:

  • MyWorkflow (treated as version 0)
  • MyWorkflow2
  • MyWorkflowV2

The default strategy assumes higher numeric values are newer (for example, MyWorkflowV10 is newer than MyWorkflowV2). The .NET SDK also includes other built-in strategies (Date, SemVer, and Numeric) plus support for custom strategies.

Built-in strategies and options

The .NET SDK ships with several built-in name-based strategies. Each strategy supports options that let you tune how the suffix is parsed and what to do when no suffix is present.

  • DateVersionStrategy: Derives a date-based version from a trailing suffix (for example, MyWorkflow20220611). Options include:
    • Date format: Uses standard C# date formatting rules; defaults to yyyyMMdd.
    • Default version: Used when no suffix is provided; defaults to 0.
    • Prefix: Optional prefix to match before the date suffix, with optional case-sensitivity.
  • SemVerVersionStrategy: Derives a SemVer version from a trailing suffix (for example, MyWorkflow1.2.3). Options include:
    • Prefix: Optional prefix to match before the SemVer suffix, with optional case-sensitivity.
    • Prerelease/build support: Can parse prerelease annotations and build metadata.
    • Default version: Optional default when no suffix is provided, if configured to allow missing suffixes.
  • NumericVersionStrategy: Derives a numeric version from a trailing suffix (for example, MyWorkflow42 or MyWorkflowV42). Options include:
    • Prefix: Optional prefix to match before the numeric suffix, with optional case-sensitivity.
    • Zero-padding width: Optional width to allow fixed-width numbers with leading zeroes.
    • Default version: Used when no suffix is provided.

Configure name-based versioning

1. Install the versioning package

If you haven’t already added it, you need to add the Dapr.Workflow package to your project. No additional packages are required.

2. Register workflow versioning

Add versioning to DI during startup:

builder.Services.AddDaprWorkflowVersioning();

3. Choose a strategy (optional)

You can select a strategy by registering it in DI after calling AddDaprWorkflowVersioning:

builder.Services.UseDefaultWorkflowStrategy<NumericVersionStrategy>("workflow-versioning-options");

The optional string key is used to locate strategy options.

4. Configure strategy options (optional)

Register strategy options using the same key:

builder.Services.ConfigureStrategyOptions<NumericVersionStrategyOptions>("workflow-versioning-options", o =>
{
    o.SuffixPrefix = "V";
});

5. Register workflows and activities

With the source generator included in Dapr.Workflow, both workflows and activities are discovered and registered automatically at build time. You do not need to opt into named workflow versioning to benefit from this automatic registration.

The AddDaprWorkflow() call is still required to wire up Dapr workflow services, but the options delegate is now optional:

builder.Services.AddDaprWorkflow();

Explicit registrations remain supported if you prefer them:

builder.Services.AddDaprWorkflow(w => 
{
    w.RegisterActivity<SendEmailActivity>();
});

Once configured, named workflow versioning is applied automatically at runtime.

Cross-assembly workflow discovery

By default, the workflow versioning source generator only scans the executing assembly. If you keep workflows in a separate referenced assembly, those implementations are not discovered unless you opt in to reference scanning.

Reference scanning is disabled by default because it can increase build times (the generator must inspect all referenced assemblies for workflow and activity implementations). To enable it, add the following to the executing application’s .csproj file:

<ItemGroup>
  <CompilerVisibleProperty Include="DaprWorkflowVersioningScanReferences" />
</ItemGroup>

When enabled, the source generator adds any discovered workflow and activity types from all referenced assemblies and registers them automatically. This applies regardless of whether you used named workflow versioning - it is a general-purpose discovery mechanism.

Override name and version

If you need to override the canonical name or version detected from the workflow type, apply the [WorkflowVersion] attribute to the class implementing your workflow and specify values explicitly.

Best practices

  • Schedule by canonical name: When scheduling a new workflow instance, use the canonical workflow name (the unversioned name). The SDK discovers versioned types and maps the canonical name to the latest version automatically. Avoid scheduling with a specific versioned name.
  • Keep old versions: Preserve older workflow types indefinitely. You can remove them only after you are certain no in-flight instances reference them. Removing old versions too early can stall long-running workflows.
  • Version in one direction: Always move versions forward. Avoid renaming or reusing older version identifiers.

Current limitations

  • A single project cannot mix multiple versioning strategies for different workflows.
  • There is no automatic migration from one versioning strategy to another.
  • Workflow versioning doesn’t work across application boundaries. If you use multi-app workflows, you must use the type name expected in the target app based on any versioning strategy applied there. Other applications calling into this .NET application can simply use the canonical name if set up to use name-based workflow versioning.

Example project

An end-to-end example is available in the Dapr .NET SDK repository at examples/Workflow/WorkflowVersioning.

Next steps

2.7 - .NET Workflow Examples

Explore Dapr Workflow code examples on GitHub

Workflow tutorials in the Dapr Quickstarts repository

The Dapr Quickstarts repository on GitHub includes many workflow tutorials that showcase the various workflow patterns and how to use the workflow management operations. You can find these tutorials in the quickstarts/tutorials/workflow/csharp folder.

Workflow examples in the .NET SDK repository

The Dapr .NET SDK repository on GitHub contains several examples demonstrating how to use Dapr Workflows with .NET. You can find these examples in the examples/Workflow folder.

Next steps

3 - Dapr actors .NET SDK

Get up and running with the Dapr actors .NET SDK

With the Dapr actor package, you can interact with Dapr virtual actors from a .NET application.

To get started, walk through the Dapr actors how-to guide.

3.1 - The IActorProxyFactory interface

Learn how to create actor clients with the IActorProxyFactory interface

Inside of an Actor class or an ASP.NET Core project, the IActorProxyFactory interface is recommended to create actor clients.

The AddActors(...) method will register actor services with ASP.NET Core dependency injection.

  • Outside of an actor instance: The IActorProxyFactory instance is available through dependency injection as a singleton service.
  • Inside an actor instance: The IActorProxyFactory instance is available as a property (this.ProxyFactory).

The following is an example of creating a proxy inside an actor:

public Task<MyData> GetDataAsync()
{
    var proxy = this.ProxyFactory.CreateActorProxy<IOtherActor>(ActorId.CreateRandom(), "OtherActor");
    await proxy.DoSomethingGreat();

    return this.StateManager.GetStateAsync<MyData>("my_data");
}

In this guide, you will learn how to use IActorProxyFactory.

Identifying an actor

All of the APIs on IActorProxyFactory will require an actor type and actor id to communicate with an actor. For strongly-typed clients, you also need one of its interfaces.

  • Actor type uniquely identifies the actor implementation across the whole application.
  • Actor id uniquely identifies an instance of that type.

If you don’t have an actor id and want to communicate with a new instance, create a random id with ActorId.CreateRandom(). Since the random id is a cryptographically strong identifier, the runtime will create a new actor instance when you interact with it.

You can use the type ActorReference to exchange an actor type and actor id with other actors as part of messages.

Two styles of actor client

The actor client supports two different styles of invocation:

Actor client styleDescription
Strongly-typedStrongly-typed clients are based on .NET interfaces and provide the typical benefits of strong-typing. They don’t work with non-.NET actors.
Weakly-typedWeakly-typed clients use the ActorProxy class. It is recommended to use these only when required for interop or other advanced reasons.

Using a strongly-typed client

The following example uses the CreateActorProxy<> method to create a strongly-typed client. CreateActorProxy<> requires an actor interface type, and will return an instance of that interface.

// Create a proxy for IOtherActor to type OtherActor with a random id
var proxy = this.ProxyFactory.CreateActorProxy<IOtherActor>(ActorId.CreateRandom(), "OtherActor");

// Invoke a method defined by the interface to invoke the actor
//
// proxy is an implementation of IOtherActor so we can invoke its methods directly
await proxy.DoSomethingGreat();

Using a weakly-typed client

The following example uses the Create method to create a weakly-typed client. Create returns an instance of ActorProxy.

// Create a proxy for type OtherActor with a random id
var proxy = this.ProxyFactory.Create(ActorId.CreateRandom(), "OtherActor");

// Invoke a method by name to invoke the actor
//
// proxy is an instance of ActorProxy.
await proxy.InvokeMethodAsync("DoSomethingGreat");

Since ActorProxy is a weakly-typed proxy, you need to pass in the actor method name as a string.

You can also use ActorProxy to invoke methods with both a request and a response message. Request and response messages will be serialized using the System.Text.Json serializer.

// Create a proxy for type OtherActor with a random id
var proxy = this.ProxyFactory.Create(ActorId.CreateRandom(), "OtherActor");

// Invoke a method on the proxy to invoke the actor
//
// proxy is an instance of ActorProxy.
var request = new MyRequest() { Message = "Hi, it's me.", };
var response = await proxy.InvokeMethodAsync<MyRequest, MyResponse>("DoSomethingGreat", request);

When using a weakly-typed proxy, you must proactively define the correct actor method names and message types. When using a strongly-typed proxy, these names and types are defined for you as part of the interface definition.

Actor method invocation exception details

The actor method invocation exception details are surfaced to the caller and the callee, providing an entry point to track down the issue. Exception details include:

  • Method name
  • Line number
  • Exception type
  • UUID

You use the UUID to match the exception on the caller and callee side. Below is an example of exception details:

Dapr.Actors.ActorMethodInvocationException: Remote Actor Method Exception, DETAILS: Exception: NotImplementedException, Method Name: ExceptionExample, Line Number: 14, Exception uuid: d291a006-84d5-42c4-b39e-d6300e9ac38b

Next steps

Learn how to author and run actors with ActorHost.

3.2 - Author & run actors

Learn all about authoring and running actors with the .NET SDK

Author actors

ActorHost

The ActorHost:

  • Is a required constructor parameter of all actors
  • Is provided by the runtime
  • Must be passed to the base class constructor
  • Contains all of the state that allows that actor instance to communicate with the runtime
internal class MyActor : Actor, IMyActor, IRemindable
{
    public MyActor(ActorHost host) // Accept ActorHost in the constructor
        : base(host) // Pass ActorHost to the base class constructor
    {
    }
}

Since the ActorHost contains state unique to the actor, you don’t need to pass the instance into other parts of your code. It’s recommended only create your own instances of ActorHost in tests.

Dependency injection

Actors support dependency injection of additional parameters into the constructor. Any other parameters you define will have their values satisfied from the dependency injection container.

internal class MyActor : Actor, IMyActor, IRemindable
{
    public MyActor(ActorHost host, BankService bank) // Accept BankService in the constructor
        : base(host)
    {
        ...
    }
}

An actor type should have a single public constructor. The actor infrastructure uses the ActivatorUtilities pattern for constructing actor instances.

You can register types with dependency injection in Startup.cs to make them available. Read more about the different ways of registering your types.

// In Startup.cs
public void ConfigureServices(IServiceCollection services)
{
    ...

    // Register additional types with dependency injection.
    services.AddSingleton<BankService>();
}

Each actor instance has its own dependency injection scope and remains in memory for some time after performing an operation. During that time, the dependency injection scope associated with the actor is also considered live. The scope will be released when the actor is deactivated.

If an actor injects an IServiceProvider in the constructor, the actor will receive a reference to the IServiceProvider associated with its scope. The IServiceProvider can be used to resolve services dynamically in the future.

internal class MyActor : Actor, IMyActor, IRemindable
{
    public MyActor(ActorHost host, IServiceProvider services) // Accept IServiceProvider in the constructor
        : base(host)
    {
        ...
    }
}

When using this pattern, avoid creating many instances of transient services which implement IDisposable. Since the scope associated with an actor could be considered valid for a long time, you can accumulate many services in memory. See the dependency injection guidelines for more information.

IDisposable and actors

Actors can implement IDisposable or IAsyncDisposable. It’s recommended that you rely on dependency injection for resource management rather than implementing dispose functionality in application code. Dispose support is provided in the rare case where it is truly necessary.

Logging

Inside an actor class, you have access to an ILogger instance through a property on the base Actor class. This instance is connected to the ASP.NET Core logging system and should be used for all logging inside an actor. Read more about logging. You can configure a variety of different logging formats and output sinks.

Use structured logging with named placeholders like the example below:

public Task<MyData> GetDataAsync()
{
    this.Logger.LogInformation("Getting state at {CurrentTime}", DateTime.UtcNow);
    return this.StateManager.GetStateAsync<MyData>("my_data");
}

When logging, avoid using format strings like: $"Getting state at {DateTime.UtcNow}"

Logging should use the named placeholder syntax which offers better performance and integration with logging systems.

Using an explicit actor type name

By default, the type of the actor, as seen by clients, is derived from the name of the actor implementation class. The default name will be the class name (without namespace).

If desired, you can specify an explicit type name by attaching an ActorAttribute attribute to the actor implementation class.

[Actor(TypeName = "MyCustomActorTypeName")]
internal class MyActor : Actor, IMyActor
{
    // ...
}

In the example above, the name will be MyCustomActorTypeName.

No change is needed to the code that registers the actor type with the runtime, providing the value via the attribute is all that is required.

Host actors on the server

Registering actors

Actor registration is part of ConfigureServices in Startup.cs. You can register services with dependency injection via the ConfigureServices method. Registering the set of actor types is part of the registration of actor services.

Inside ConfigureServices you can:

  • Register the actor runtime (AddActors)
  • Register actor types (options.Actors.RegisterActor<>)
  • Configure actor runtime settings options
  • Register additional service types for dependency injection into actors (services)
// In Startup.cs
public void ConfigureServices(IServiceCollection services)
{
    // Register actor runtime with DI
    services.AddActors(options =>
    {
        // Register actor types and configure actor settings
        options.Actors.RegisterActor<MyActor>();
        
        // Configure default settings
        options.ActorIdleTimeout = TimeSpan.FromMinutes(10);
        options.ActorScanInterval = TimeSpan.FromSeconds(35);
        options.DrainOngoingCallTimeout = TimeSpan.FromSeconds(35);
        options.DrainRebalancedActors = true;
    });

    // Register additional services for use with actors
    services.AddSingleton<BankService>();
}

Configuring JSON options

The actor runtime uses System.Text.Json for:

  • Serializing data to the state store
  • Handling requests from the weakly-typed client

By default, the actor runtime uses settings based on JsonSerializerDefaults.Web.

You can configure the JsonSerializerOptions as part of ConfigureServices:

// In Startup.cs
public void ConfigureServices(IServiceCollection services)
{
    services.AddActors(options =>
    {
        ...
        
        // Customize JSON options
        options.JsonSerializerOptions = ...
    });
}

Actors and routing

The ASP.NET Core hosting support for actors uses the endpoint routing system. The .NET SDK provides no support hosting actors with the legacy routing system from early ASP.NET Core releases.

Since actors uses endpoint routing, the actors HTTP handler is part of the middleware pipeline. The following is a minimal example of a Configure method setting up the middleware pipeline with actors.

// in Startup.cs
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    app.UseRouting();

    app.UseEndpoints(endpoints =>
    {
        // Register actors handlers that interface with the Dapr runtime.
        endpoints.MapActorsHandlers();
    });
}

The UseRouting and UseEndpoints calls are necessary to configure routing. Configure actors as part of the pipeline by adding MapActorsHandlers inside the endpoint middleware.

This is a minimal example, it’s valid for Actors functionality to existing alongside:

  • Controllers
  • Razor Pages
  • Blazor
  • gRPC Services
  • Dapr pub/sub handler
  • other endpoints such as health checks

Problematic middleware

Certain middleware may interfere with the routing of Dapr requests to the actors handlers. In particular, the UseHttpsRedirection is problematic for Dapr’s default configuration. Dapr sends requests over unencrypted HTTP by default, which the UseHttpsRedirection middleware will block. This middleware cannot be used with Dapr at this time.

// in Startup.cs
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    // INVALID - this will block non-HTTPS requests
    app.UseHttpsRedirection();
    // INVALID - this will block non-HTTPS requests

    app.UseRouting();

    app.UseEndpoints(endpoints =>
    {
        // Register actors handlers that interface with the Dapr runtime.
        endpoints.MapActorsHandlers();
    });
}

Next steps

Try the Running and using virtual actors example.

3.3 - Actor serialization in the .NET SDK

Necessary steps to serialize your types remoted and non-remoted Actors in .NET

Actor Serialization

The Dapr actor package enables you to use Dapr virtual actors within a .NET application with either a weakly- or strongly-typed client. Each utilizes a different serialization approach. This document will review the differences and convey a few key ground rules to understand in either scenario.

Please be advised that it is not a supported scenario to use the weakly- or strongly typed actor clients interchangeably because of these different serialization approaches. The data persisted using one Actor client will not be accessible using the other Actor client, so it is important to pick one and use it consistently throughout your application.

Weakly-typed Dapr Actor client

In this section, you will learn how to configure your C# types so they are properly serialized and deserialized at runtime when using a weakly-typed actor client. These clients use string-based names of methods with request and response payloads that are serialized using the System.Text.Json serializer. Please note that this serialization framework is not specific to Dapr and is separately maintained by the .NET team within the .NET GitHub repository.

When using the weakly-typed Dapr Actor client to invoke methods from your various actors, it’s not necessary to independently serialize or deserialize the method payloads as this will happen transparently on your behalf by the SDK.

The client will use the latest version of System.Text.Json available for the version of .NET you’re building against and serialization is subject to all the inherent capabilities provided in the associated .NET documentation.

The serializer will be configured to use the JsonSerializerOptions.Web default options unless overridden with a custom options configuration which means the following are applied:

  • Deserialization of the property name is performed in a case-insensitive manner
  • Serialization of the property name is performed using camel casing unless the property is overridden with a [JsonPropertyName] attribute
  • Deserialization will read numeric values from number and/or string values

Basic Serialization

In the following example, we present a simple class named Doodad though it could just as well be a record as well.

public class Doodad
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public int Count { get; set; }
}

By default, this will serialize using the names of the members as used in the type and whatever values it was instantiated with:

{"id": "a06ced64-4f42-48ad-84dd-46ae6a7e333d", "name": "DoodadName", "count": 5}

Override Serialized Property Name

The default property names can be overridden by applying the [JsonPropertyName] attribute to desired properties.

Generally, this isn’t going to be necessary for types you’re persisting to the actor state as you’re not intended to read or write them independent of Dapr-associated functionality, but the following is provided just to clearly illustrate that it’s possible.

Override Property Names on Classes

Here’s an example demonstrating the use of JsonPropertyName to change the name for the first property following serialization. Note that the last usage of JsonPropertyName on the Count property matches what it would be expected to serialize to. This is largely just to demonstrate that applying this attribute won’t negatively impact anything - in fact, it might be preferable if you later decide to change the default serialization options but still need to consistently access the properties previously serialized before that change as JsonPropertyName will override those options.

public class Doodad
{
    [JsonPropertyName("identifier")]
    public Guid Id { get; set; }
    public string Name { get; set; }
    [JsonPropertyName("count")]
    public int Count { get; set; }
}

This would serialize to the following:

{"identifier": "a06ced64-4f42-48ad-84dd-46ae6a7e333d", "name": "DoodadName", "count": 5}

Override Property Names on Records

Let’s try doing the same thing with a record from C# 12 or later:

public record Thingy(string Name, [JsonPropertyName("count")] int Count); 

Because the argument passed in a primary constructor (introduced in C# 12) can be applied to either a property or field within a record, using the [JsonPropertyName] attribute may require specifying that you intend the attribute to apply to a property and not a field in some ambiguous cases. Should this be necessary, you’d indicate as much in the primary constructor with:

public record Thingy(string Name, [property: JsonPropertyName("count")] int Count);

If [property: ] is applied to the [JsonPropertyName] attribute where it’s not necessary, it will not negatively impact serialization or deserialization as the operation will proceed normally as though it were a property (as it typically would if not marked as such).

Enumeration types

Enumerations, including flat enumerations are serializable to JSON, but the value persisted may surprise you. Again, it’s not expected that the developer should ever engage with the serialized data independently of Dapr, but the following information may at least help in diagnosing why a seemingly mild version migration isn’t working as expected.

Take the following enum type providing the various seasons in the year:

public enum Season
{
    Spring,
    Summer,
    Fall,
    Winter
}

We’ll go ahead and use a separate demonstration type that references our Season and simultaneously illustrate how this works with records:

public record Engagement(string Name, Season TimeOfYear);

Given the following initialized instance:

var myEngagement = new Engagement("Ski Trip", Season.Winter);

This would serialize to the following JSON:

{"name":  "Ski Trip", "season":  3}

That might be unexpected that our Season.Winter value was represented as a 3, but this is because the serializer is going to automatically use numeric representations of the enum values starting with zero for the first value and incrementing the numeric value for each additional value available. Again, if a migration were taking place and a developer had flipped the order of the enums, this would affect a breaking change in your solution as the serialized numeric values would point to different values when deserialized.

Rather, there is a JsonConverter available with System.Text.Json that will instead opt to use a string-based value instead of the numeric value. The [JsonConverter] attribute needs to be applied to be enum type itself to enable this, but will then be realized in any downstream serialization or deserialization operation that references the enum.

[JsonConverter(typeof(JsonStringEnumConverter<Season>))]
public enum Season
{
    Spring,
    Summer,
    Fall,
    Winter
}

Using the same values from our myEngagement instance above, this would produce the following JSON instead:

{"name":  "Ski Trip", "season":  "Winter"}

As a result, the enum members can be shifted around without fear of introducing errors during deserialization.

Custom Enumeration Values

The System.Text.Json serialization platform doesn’t, out of the box, support the use of [EnumMember] to allow you to change the value of enum that’s used during serialization or deserialization, but there are scenarios where this could be useful. Again, assume that you’re tasking with refactoring the solution to apply some better names to your various enums. You’re using the JsonStringEnumConverter<TType> detailed above so you’re saving the name of the enum to value instead of a numeric value, but if you change the enum name, that will introduce a breaking change as the name will no longer match what’s in state.

Do note that if you opt into using this approach, you should decorate all your enum members with the [EnumMeber] attribute so that the values are consistently applied for each enum value instead of haphazardly. Nothing will validate this at build or runtime, but it is considered a best practice operation.

How can you specify the precise value persisted while still changing the name of the enum member in this scenario? Use a custom JsonConverter with an extension method that can pull the value out of the attached [EnumMember] attributes where provided. Add the following to your solution:

public sealed class EnumMemberJsonConverter<T> : JsonConverter<T> where T : struct, Enum
{
    /// <summary>Reads and converts the JSON to type <typeparamref name="T" />.</summary>
    /// <param name="reader">The reader.</param>
    /// <param name="typeToConvert">The type to convert.</param>
    /// <param name="options">An object that specifies serialization options to use.</param>
    /// <returns>The converted value.</returns>
    public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        // Get the string value from the JSON reader
        var value = reader.GetString();

        // Loop through all the enum values
        foreach (var enumValue in Enum.GetValues<T>())
        {
            // Get the value from the EnumMember attribute, if any
            var enumMemberValue = GetValueFromEnumMember(enumValue);

            // If the values match, return the enum value
            if (value == enumMemberValue)
            {
                return enumValue;
            }
        }

        // If no match found, throw an exception
        throw new JsonException($"Invalid value for {typeToConvert.Name}: {value}");
    }

    /// <summary>Writes a specified value as JSON.</summary>
    /// <param name="writer">The writer to write to.</param>
    /// <param name="value">The value to convert to JSON.</param>
    /// <param name="options">An object that specifies serialization options to use.</param>
    public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
    {
        // Get the value from the EnumMember attribute, if any
        var enumMemberValue = GetValueFromEnumMember(value);

        // Write the value to the JSON writer
        writer.WriteStringValue(enumMemberValue);
    }

    private static string GetValueFromEnumMember(T value)
    {
        MemberInfo[] member = typeof(T).GetMember(value.ToString(), BindingFlags.DeclaredOnly | BindingFlags.Static | BindingFlags.Public);
        if (member.Length == 0)
            return value.ToString();
        object[] customAttributes = member.GetCustomAttributes(typeof(EnumMemberAttribute), false);
        if (customAttributes.Length != 0)
        {
            EnumMemberAttribute enumMemberAttribute = (EnumMemberAttribute)customAttributes;
            if (enumMemberAttribute != null && enumMemberAttribute.Value != null)
                return enumMemberAttribute.Value;
        }
        return value.ToString();
    }
}

Now let’s add a sample enumerator. We’ll set a value that uses the lower-case version of each enum member to demonstrate this. Don’t forget to decorate the enum with the JsonConverter attribute and reference our custom converter in place of the numeral-to-string converter used in the last section.

[JsonConverter(typeof(EnumMemberJsonConverter<Season>))]
public enum Season
{
    [EnumMember(Value="spring")]
    Spring,
    [EnumMember(Value="summer")]
    Summer,
    [EnumMember(Value="fall")]
    Fall,
    [EnumMember(Value="winter")]
    Winter
}

Let’s use our sample record from before. We’ll also add a [JsonPropertyName] attribute just to augment the demonstration:

public record Engagement([property: JsonPropertyName("event")] string Name, Season TimeOfYear);

And finally, let’s initialize a new instance of this:

var myEngagement = new Engagement("Conference", Season.Fall);

This time, serialization will take into account the values from the attached [EnumMember] attribute providing us a mechanism to refactor our application without necessitating a complex versioning scheme for our existing enum values in the state.

{"event":  "Conference",  "season":  "fall"}

Polymorphic Serialization

When working with polymorphic types in Dapr Actor clients, it is essential to handle serialization and deserialization correctly to ensure that the appropriate derived types are instantiated. Polymorphic serialization allows you to serialize objects of a base type while preserving the specific derived type information.

To enable polymorphic deserialization, you must use the [JsonPolymorphic] attribute on your base type. Additionally, it is crucial to include the [AllowOutOfOrderMetadataProperties] attribute to ensure that metadata properties, such as $type can be processed correctly by System.Text.Json even if they are not the first properties in the JSON object.

Example

[JsonPolymorphic]
[AllowOutOfOrderMetadataProperties]
public abstract class SampleValueBase
{
    public string CommonProperty { get; set; }
}

public class DerivedSampleValue : SampleValueBase
{
    public string SpecificProperty { get; set; }
}

In this example, the SampleValueBase class is marked with both [JsonPolymorphic] and [AllowOutOfOrderMetadataProperties] attributes. This setup ensures that the $type metadata property can be correctly identified and processed during deserialization, regardless of its position in the JSON object.

By following this approach, you can effectively manage polymorphic serialization and deserialization in your Dapr Actor clients, ensuring that the correct derived types are instantiated and used.

Strongly-typed Dapr Actor client

In this section, you will learn how to configure your classes and records so they are properly serialized and deserialized at runtime when using a strongly-typed actor client. These clients are implemented using .NET interfaces and are not compatible with Dapr Actors written using other languages.

This actor client serializes data using an engine called the Data Contract Serializer which converts your C# types to and from XML documents. This serialization framework is not specific to Dapr and is separately maintained by the .NET team within the .NET GitHub repository.

When sending or receiving primitives (like strings or ints), this serialization happens transparently and there’s no requisite preparation needed on your part. However, when working with complex types such as those you create, there are some important rules to take into consideration so this process works smoothly.

Serializable Types

There are several important considerations to keep in mind when using the Data Contract Serializer:

  • By default, all types, read/write properties (after construction) and fields marked as publicly visible are serialized
  • All types must either expose a public parameterless constructor or be decorated with the DataContractAttribute attribute
  • Init-only setters are only supported with the use of the DataContractAttribute attribute
  • Read-only fields, properties without a Get and Set method and internal or properties with private Get and Set methods are ignored during serialization
  • Serialization is supported for types that use other complex types that are not themselves marked with the DataContractAttribute attribute through the use of the KnownTypesAttribute attribute
  • If a type is marked with the DataContractAttribute attribute, all members you wish to serialize and deserialize must be decorated with the DataMemberAttribute attribute as well or they’ll be set to their default values

How does deserialization work?

The approach used for deserialization depends on whether or not the type is decorated with the DataContractAttribute attribute. If this attribute isn’t present, an instance of the type is created using the parameterless constructor. Each of the properties and fields are then mapped into the type using their respective setters and the instance is returned to the caller.

If the type is marked with [DataContract], the serializer instead uses reflection to read the metadata of the type and determine which properties or fields should be included based on whether or not they’re marked with the DataMemberAttribute attribute as it’s performed on an opt-in basis. It then allocates an uninitialized object in memory (avoiding the use of any constructors, parameterless or not) and then sets the value directly on each mapped property or field, even if private or uses init-only setters. Serialization callbacks are invoked as applicable throughout this process and then the object is returned to the caller.

Use of the serialization attributes is highly recommended as they grant more flexibility to override names and namespaces and generally use more of the modern C# functionality. While the default serializer can be relied on for primitive types, it’s not recommended for any of your own types, whether they be classes, structs or records. It’s recommended that if you decorate a type with the DataContractAttribute attribute, you also explicitly decorate each of the members you want to serialize or deserialize with the DataMemberAttribute attribute as well.

.NET Classes

Classes are fully supported in the Data Contract Serializer provided that that other rules detailed on this page and the Data Contract Serializer documentation are also followed.

The most important thing to remember here is that you must either have a public parameterless constructor or you must decorate it with the appropriate attributes. Let’s review some examples to really clarify what will and won’t work.

In the following example, we present a simple class named Doodad. We don’t provide an explicit constructor here, so the compiler will provide an default parameterless constructor. Because we’re using supported primitive types (Guid, string and int32) and all our members have a public getter and setter, no attributes are required and we’ll be able to use this class without issue when sending and receiving it from a Dapr actor method.

public class Doodad
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public int Count { get; set; }
}

By default, this will serialize using the names of the members as used in the type and whatever values it was instantiated with:

<Doodad>
  <Id>a06ced64-4f42-48ad-84dd-46ae6a7e333d</Id>
  <Name>DoodadName</Name>
  <Count>5</Count>
</Doodad>

So let’s tweak it - let’s add our own constructor and only use init-only setters on the members. This will fail to serialize and deserialize not because of the use of the init-only setters, but because there’s no parameterless constructors.

// WILL NOT SERIALIZE PROPERLY!
public class Doodad
{
    public Doodad(string name, int count)
    {
        Id = Guid.NewGuid();
        Name = name;
        Count = count;
    }

    public Guid Id { get; set; }
    public string Name { get; init; }
    public int Count { get; init; }
}

If we add a public parameterless constructor to the type, we’re good to go and this will work without further annotations.

public class Doodad
{
    public Doodad()
    {
    }

    public Doodad(string name, int count)
    {
        Id = Guid.NewGuid();
        Name = name;
        Count = count;
    }

    public Guid Id { get; set; }
    public string Name { get; set; }
    public int Count { get; set; }
}

But what if we don’t want to add this constructor? Perhaps you don’t want your developers to accidentally create an instance of this Doodad using an unintended constructor. That’s where the more flexible attributes are useful. If you decorate your type with a DataContractAttribute attribute, you can drop your parameterless constructor and it will work once again.

[DataContract]
public class Doodad
{
    public Doodad(string name, int count)
    {
        Id = Guid.NewGuid();
        Name = name;
        Count = count;
    }

    public Guid Id { get; set; }
    public string Name { get; set; }
    public int Count { get; set; }
}

In the above example, we don’t need to also use the DataMemberAttribute attributes because again, we’re using built-in primitives that the serializer supports. But, we do get more flexibility if we use the attributes. From the DataContractAttribute attribute, we can specify our own XML namespace with the Namespace argument and, via the Name argument, change the name of the type as used when serialized into the XML document.

It’s a recommended practice to append the DataContractAttribute attribute to the type and the DataMemberAttribute attributes to all the members you want to serialize anyway - if they’re not necessary and you’re not changing the default values, they’ll just be ignored, but they give you a mechanism to opt into serializing members that wouldn’t otherwise have been included such as those marked as private or that are themselves complex types or collections.

Note that if you do opt into serializing your private members, their values will be serialized into plain text - they can very well be viewed, intercepted and potentially manipulated based on how you’re handing the data once serialized, so it’s an important consideration whether you want to mark these members or not in your use case.

In the following example, we’ll look at using the attributes to change the serialized names of some of the members as well as introduce the IgnoreDataMemberAttribute attribute. As the name indicates, this tells the serializer to skip this property even though it’d be otherwise eligible to serialize. Further, because I’m decorating the type with the DataContractAttribute attribute, it means that I can use init-only setters on the properties.

[DataContract(Name="Doodad")]
public class Doodad
{
    public Doodad(string name = "MyDoodad", int count = 5)
    {
        Id = Guid.NewGuid();
        Name = name;
        Count = count;
    }

    [DataMember(Name = "id")]
    public Guid Id { get; init; }
    [IgnoreDataMember]
    public string Name { get; init; }
    [DataMember]
    public int Count { get; init; }
}

When this is serialized, because we’re changing the names of the serialized members, we can expect a new instance of Doodad using the default values this to be serialized as:

<Doodad>
  <id>a06ced64-4f42-48ad-84dd-46ae6a7e333d</id>
  <Count>5</Count>
</Doodad>
Classes in C# 12 - Primary Constructors

C# 12 brought us primary constructors on classes. Use of a primary constructor means the compiler will be prevented from creating the default implicit parameterless constructor. While a primary constructor on a class doesn’t generate any public properties, it does mean that if you pass this primary constructor any arguments or have non-primitive types in your class, you’ll either need to specify your own parameterless constructor or use the serialization attributes.

Here’s an example where we’re using the primary constructor to inject an ILogger to a field and add our own parameterless constructor without the need for any attributes.

public class Doodad(ILogger<Doodad> _logger)
{
    public Doodad() {} //Our parameterless constructor

    public Doodad(string name, int count)
    {
        Id = Guid.NewGuid();
        Name = name;
        Count = count;
    }

    public Guid Id { get; set; }
    public string Name { get; set; }
    public int Count { get; set; } 
}

And using our serialization attributes (again, opting for init-only setters since we’re using the serialization attributes):

[DataContract]
public class Doodad(ILogger<Doodad> _logger)
{
    public Doodad(string name, int count)
    {
        Id = Guid.NewGuid();
        Name = name;
        Count = count;
    }

    [DataMember]
    public Guid Id { get; init; }
    [DataMember]
    public string Name { get; init; }
    [DataMember]
    public int Count { get; init; }
}

.NET Structs

Structs are supported by the Data Contract serializer provided that they are marked with the DataContractAttribute attribute and the members you wish to serialize are marked with the DataMemberAttribute attribute. Further, to support deserialization, the struct will also need to have a parameterless constructor. This works even if you define your own parameterless constructor as enabled in C# 10.

[DataContract]
public struct Doodad
{
    [DataMember]
    public int Count { get; set; }
}

.NET Records

Records were introduced in C# 9 and follow precisely the same rules as classes when it comes to serialization. We recommend that you should decorate all your records with the DataContractAttribute attribute and members you wish to serialize with DataMemberAttribute attributes so you don’t experience any deserialization issues using this or other newer C# functionalities. Because record classes use init-only setters for properties by default and encourage the use of the primary constructor, applying these attributes to your types ensures that the serializer can properly otherwise accommodate your types as-is.

Typically records are presented as a simple one-line statement using the new primary constructor concept:

public record Doodad(Guid Id, string Name, int Count);

This will throw an error encouraging the use of the serialization attributes as soon as you use it in a Dapr actor method invocation because there’s no parameterless constructor available nor is it decorated with the aforementioned attributes.

Here we add an explicit parameterless constructor and it won’t throw an error, but none of the values will be set during deserialization since they’re created with init-only setters. Because this doesn’t use the DataContractAttribute attribute or the DataMemberAttribute attribute on any members, the serializer will be unable to map the target members correctly during deserialization.

public record Doodad(Guid Id, string Name, int Count)
{
    public Doodad() {}
}

This approach does without the additional constructor and instead relies on the serialization attributes. Because we mark the type with the DataContractAttribute attribute and decorate each member with its own DataMemberAttribute attribute, the serialization engine will be able to map from the XML document to our type without issue.

[DataContract]
public record Doodad(
        [property: DataMember] Guid Id,
        [property: DataMember] string Name,
        [property: DataMember] int Count)

Supported Primitive Types

There are several types built into .NET that are considered primitive and eligible for serialization without additional effort on the part of the developer:

There are additional types that aren’t actually primitives but have similar built-in support:

Again, if you want to pass these types around via your actor methods, no additional consideration is necessary as they’ll be serialized and deserialized without issue. Further, types that are themselves marked with the (SerializeableAttribute)[https://learn.microsoft.com/dotnet/api/system.serializableattribute] attribute will be serialized.

Enumeration Types

Enumerations, including flag enumerations are serializable if appropriately marked. The enum members you wish to be serialized must be marked with the EnumMemberAttribute attribute in order to be serialized. Passing a custom value into the optional Value argument on this attribute will allow you to specify the value used for the member in the serialized document instead of having the serializer derive it from the name of the member.

The enum type does not require that the type be decorated with the DataContractAttribute attribute - only that the members you wish to serialize be decorated with the EnumMemberAttribute attributes.

public enum Colors
{
    [EnumMember]
    Red,
    [EnumMember(Value="g")]
    Green,
    Blue, //Even if used by a type, this value will not be serialized as it's not decorated with the EnumMember attribute
}

Collection Types

With regards to the data contact serializer, all collection types that implement the IEnumerable interface including arays and generic collections are considered collections. Those types that implement IDictionary or the generic IDictionary<TKey, TValue> are considered dictionary collections; all others are list collections.

Not unlike other complex types, collection types must have a parameterless constructor available. Further, they must also have a method called Add so they can be properly serialized and deserialized. The types used by these collection types must themselves be marked with the DataContractAttribute attribute or otherwise be serializable as described throughout this document.

Data Contract Versioning

As the data contract serializer is only used in Dapr with respect to serializing the values in the .NET SDK to and from the Dapr actor instances via the proxy methods, there’s little need to consider versioning of data contracts as the data isn’t being persisted between application versions using the same serializer. For those interested in learning more about data contract versioning visit here.

Known Types

Nesting your own complex types is easily accommodated by marking each of the types with the DataContractAttribute attribute. This informs the serializer as to how deserialization should be performed. But what if you’re working with polymorphic types and one of your members is a base class or interface with derived classes or other implementations? Here, you’ll use the KnownTypeAttribute attribute to give a hint to the serializer about how to proceed.

When you apply the KnownTypeAttribute attribute to a type, you are informing the data contract serializer about what subtypes it might encounter allowing it to properly handle the serialization and deserialization of these types, even when the actual type at runtime is different from the declared type.

[DataContract]
[KnownType(typeof(DerivedClass))]
public class BaseClass
{
    //Members of the base class
}

[DataContract]
public class DerivedClass : BaseClass 
{
    //Additional members of the derived class
}

In this example, the BaseClass is marked with [KnownType(typeof(DerivedClass))] which tells the data contract serializer that DerivedClass is a possible implementation of BaseClass that it may need to serialize or deserialize. Without this attribute, the serialize would not be aware of the DerivedClass when it encounters an instance of BaseClass that is actually of type DerivedClass and this could lead to a serialization exception because the serializer would not know how to handle the derived type. By specifying all possible derived types as known types, you ensure that the serializer can process the type and its members correctly.

For more information and examples about using [KnownType], please refer to the official documentation.

3.4 - How to: Run and use virtual actors in the .NET SDK

Try out .NET Dapr virtual actors with this example

The Dapr actor package allows you to interact with Dapr virtual actors from a .NET application. In this guide, you learn how to:

  • Create an Actor (MyActor).
  • Invoke its methods on the client application.
MyActor --- MyActor.Interfaces
         |
         +- MyActorService
         |
         +- MyActorClient

The interface project (\MyActor\MyActor.Interfaces)

This project contains the interface definition for the actor. Actor interfaces can be defined in any project with any name. The interface defines the actor contract shared by:

  • The actor implementation
  • The clients calling the actor

Because client projects may depend on it, it’s better to define it in an assembly separate from the actor implementation.

The actor service project (\MyActor\MyActorService)

This project implements the ASP.Net Core web service that hosts the actor. It contains the implementation of the actor, MyActor.cs. An actor implementation is a class that:

  • Derives from the base type Actor
  • Implements the interfaces defined in the MyActor.Interfaces project.

An actor class must also implement a constructor that accepts an ActorService instance and an ActorId, and passes them to the base Actor class.

The actor client project (\MyActor\MyActorClient)

This project contains the implementation of the actor client which calls MyActor’s method defined in Actor Interfaces.

Prerequisites

Step 0: Prepare

Since we’ll be creating 3 projects, choose an empty directory to start from, and open it in your terminal of choice.

Step 1: Create actor interfaces

Actor interface defines the actor contract that is shared by the actor implementation and the clients calling the actor.

Actor interface is defined with the below requirements:

  • Actor interface must inherit Dapr.Actors.IActor interface
  • The return type of Actor method must be Task or Task<object>
  • Actor method can have one argument at a maximum

Create interface project and add dependencies

# Create Actor Interfaces
dotnet new classlib -o MyActor.Interfaces

cd MyActor.Interfaces

# Add Dapr.Actors nuget package. Please use the latest package version from nuget.org
dotnet add package Dapr.Actors

cd ..

Implement IMyActor interface

Define IMyActor interface and MyData data object. Paste the following code into MyActor.cs in the MyActor.Interfaces project.

using Dapr.Actors;
using Dapr.Actors.Runtime;
using System.Threading.Tasks;

namespace MyActor.Interfaces
{
    public interface IMyActor : IActor
    {       
        Task<string> SetDataAsync(MyData data);
        Task<MyData> GetDataAsync();
        Task RegisterReminder();
        Task UnregisterReminder();
        Task<IActorReminder> GetReminder();
        Task RegisterTimer();
        Task UnregisterTimer();
    }

    public class MyData
    {
        public string PropertyA { get; set; }
        public string PropertyB { get; set; }

        public override string ToString()
        {
            var propAValue = this.PropertyA == null ? "null" : this.PropertyA;
            var propBValue = this.PropertyB == null ? "null" : this.PropertyB;
            return $"PropertyA: {propAValue}, PropertyB: {propBValue}";
        }
    }
}

Step 2: Create actor service

Dapr uses ASP.NET web service to host Actor service. This section will implement IMyActor actor interface and register Actor to Dapr Runtime.

Create actor service project and add dependencies

# Create ASP.Net Web service to host Dapr actor
dotnet new web -o MyActorService

cd MyActorService

# Add Dapr.Actors.AspNetCore nuget package. Please use the latest package version from nuget.org
dotnet add package Dapr.Actors.AspNetCore

# Add Actor Interface reference
dotnet add reference ../MyActor.Interfaces/MyActor.Interfaces.csproj

cd ..

Add actor implementation

Implement IMyActor interface and derive from Dapr.Actors.Actor class. Following example shows how to use Actor Reminders as well. For Actors to use Reminders, it must derive from IRemindable. If you don’t intend to use Reminder feature, you can skip implementing IRemindable and reminder specific methods which are shown in the code below.

Paste the following code into MyActor.cs in the MyActorService project:

using Dapr.Actors;
using Dapr.Actors.Runtime;
using MyActor.Interfaces;
using System;
using System.Threading.Tasks;

namespace MyActorService
{
    internal class MyActor : Actor, IMyActor, IRemindable
    {
        // The constructor must accept ActorHost as a parameter, and can also accept additional
        // parameters that will be retrieved from the dependency injection container
        //
        /// <summary>
        /// Initializes a new instance of MyActor
        /// </summary>
        /// <param name="host">The Dapr.Actors.Runtime.ActorHost that will host this actor instance.</param>
        public MyActor(ActorHost host)
            : base(host)
        {
        }

        /// <summary>
        /// This method is called whenever an actor is activated.
        /// An actor is activated the first time any of its methods are invoked.
        /// </summary>
        protected override Task OnActivateAsync()
        {
            // Provides opportunity to perform some optional setup.
            Console.WriteLine($"Activating actor id: {this.Id}");
            return Task.CompletedTask;
        }

        /// <summary>
        /// This method is called whenever an actor is deactivated after a period of inactivity.
        /// </summary>
        protected override Task OnDeactivateAsync()
        {
            // Provides Opporunity to perform optional cleanup.
            Console.WriteLine($"Deactivating actor id: {this.Id}");
            return Task.CompletedTask;
        }

        /// <summary>
        /// Set MyData into actor's private state store
        /// </summary>
        /// <param name="data">the user-defined MyData which will be stored into state store as "my_data" state</param>
        public async Task<string> SetDataAsync(MyData data)
        {
            // Data is saved to configured state store implicitly after each method execution by Actor's runtime.
            // Data can also be saved explicitly by calling this.StateManager.SaveStateAsync();
            // State to be saved must be DataContract serializable.
            await this.StateManager.SetStateAsync<MyData>(
                "my_data",  // state name
                data);      // data saved for the named state "my_data"

            return "Success";
        }

        /// <summary>
        /// Get MyData from actor's private state store
        /// </summary>
        /// <return>the user-defined MyData which is stored into state store as "my_data" state</return>
        public Task<MyData> GetDataAsync()
        {
            // Gets state from the state store.
            return this.StateManager.GetStateAsync<MyData>("my_data");
        }

        /// <summary>
        /// Register MyReminder reminder with the actor
        /// </summary>
        public async Task RegisterReminder()
        {
            await this.RegisterReminderAsync(
                "MyReminder",              // The name of the reminder
                null,                      // User state passed to IRemindable.ReceiveReminderAsync()
                TimeSpan.FromSeconds(5),   // Time to delay before invoking the reminder for the first time
                TimeSpan.FromSeconds(5));  // Time interval between reminder invocations after the first invocation
        }

        /// <summary>
        /// Get MyReminder reminder details with the actor
        /// </summary>
        public async Task<IActorReminder> GetReminder()
        {
            await this.GetReminderAsync("MyReminder");
        }

        /// <summary>
        /// Unregister MyReminder reminder with the actor
        /// </summary>
        public Task UnregisterReminder()
        {
            Console.WriteLine("Unregistering MyReminder...");
            return this.UnregisterReminderAsync("MyReminder");
        }

        // <summary>
        // Implement IRemindeable.ReceiveReminderAsync() which is call back invoked when an actor reminder is triggered.
        // </summary>
        public Task ReceiveReminderAsync(string reminderName, byte[] state, TimeSpan dueTime, TimeSpan period)
        {
            Console.WriteLine("ReceiveReminderAsync is called!");
            return Task.CompletedTask;
        }

        /// <summary>
        /// Register MyTimer timer with the actor
        /// </summary>
        public Task RegisterTimer()
        {
            return this.RegisterTimerAsync(
                "MyTimer",                  // The name of the timer
                nameof(this.OnTimerCallBack),       // Timer callback
                null,                       // User state passed to OnTimerCallback()
                TimeSpan.FromSeconds(5),    // Time to delay before the async callback is first invoked
                TimeSpan.FromSeconds(5));   // Time interval between invocations of the async callback
        }

        /// <summary>
        /// Unregister MyTimer timer with the actor
        /// </summary>
        public Task UnregisterTimer()
        {
            Console.WriteLine("Unregistering MyTimer...");
            return this.UnregisterTimerAsync("MyTimer");
        }

        /// <summary>
        /// Timer callback once timer is expired
        /// </summary>
        private Task OnTimerCallBack(byte[] data)
        {
            Console.WriteLine("OnTimerCallBack is called!");
            return Task.CompletedTask;
        }
    }
}

Register actor runtime with ASP.NET Core startup

The Actor runtime is configured through ASP.NET Core Startup.cs.

The runtime uses the ASP.NET Core dependency injection system to register actor types and essential services. This integration is provided through the AddActors(...) method call in ConfigureServices(...). Use the delegate passed to AddActors(...) to register actor types and configure actor runtime settings. You can register additional types for dependency injection inside ConfigureServices(...). These will be available to be injected into the constructors of your Actor types.

Actors are implemented via HTTP calls with the Dapr runtime. This functionality is part of the application’s HTTP processing pipeline and is registered inside UseEndpoints(...) inside Configure(...).

Paste the following code into Startup.cs in the MyActorService project:

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace MyActorService
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddActors(options =>
            {
                // Register actor types and configure actor settings
                options.Actors.RegisterActor<MyActor>();
            });
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseRouting();

            // Register actors handlers that interface with the Dapr runtime.
            app.MapActorsHandlers();
        }
    }
}

Step 3: Add a client

Create a simple console app to call the actor service. Dapr SDK provides Actor Proxy client to invoke actor methods defined in Actor Interface.

Create actor client project and add dependencies

# Create Actor's Client
dotnet new console -o MyActorClient

cd MyActorClient

# Add Dapr.Actors nuget package. Please use the latest package version from nuget.org
dotnet add package Dapr.Actors

# Add Actor Interface reference
dotnet add reference ../MyActor.Interfaces/MyActor.Interfaces.csproj

cd ..

Invoke actor methods with strongly-typed client

You can use ActorProxy.Create<IMyActor>(..) to create a strongly-typed client and invoke methods on the actor.

Paste the following code into Program.cs in the MyActorClient project:

using System;
using System.Threading.Tasks;
using Dapr.Actors;
using Dapr.Actors.Client;
using MyActor.Interfaces;

namespace MyActorClient
{
    class Program
    {
        static async Task MainAsync(string[] args)
        {
            Console.WriteLine("Startup up...");

            // Registered Actor Type in Actor Service
            var actorType = "MyActor";

            // An ActorId uniquely identifies an actor instance
            // If the actor matching this id does not exist, it will be created
            var actorId = new ActorId("1");

            // Create the local proxy by using the same interface that the service implements.
            //
            // You need to provide the type and id so the actor can be located. 
            var proxy = ActorProxy.Create<IMyActor>(actorId, actorType);

            // Now you can use the actor interface to call the actor's methods.
            Console.WriteLine($"Calling SetDataAsync on {actorType}:{actorId}...");
            var response = await proxy.SetDataAsync(new MyData()
            {
                PropertyA = "ValueA",
                PropertyB = "ValueB",
            });
            Console.WriteLine($"Got response: {response}");

            Console.WriteLine($"Calling GetDataAsync on {actorType}:{actorId}...");
            var savedData = await proxy.GetDataAsync();
            Console.WriteLine($"Got response: {savedData}");
        }
    }
}

Running the code

The projects that you’ve created can now to test the sample.

  1. Run MyActorService

    Since MyActorService is hosting actors, it needs to be run with the Dapr CLI.

    cd MyActorService
    dapr run --app-id myapp --app-port 5000 --dapr-http-port 3500 -- dotnet run
    

    You will see commandline output from both daprd and MyActorService in this terminal. You should see something like the following, which indicates that the application started successfully.

    ...
    ℹ️  Updating metadata for app command: dotnet run
    ✅  You're up and running! Both Dapr and your app logs will appear here.
    
    == APP == info: Microsoft.Hosting.Lifetime[0]
    
    == APP ==       Now listening on: https://localhost:5001
    
    == APP == info: Microsoft.Hosting.Lifetime[0]
    
    == APP ==       Now listening on: http://localhost:5000
    
    == APP == info: Microsoft.Hosting.Lifetime[0]
    
    == APP ==       Application started. Press Ctrl+C to shut down.
    
    == APP == info: Microsoft.Hosting.Lifetime[0]
    
    == APP ==       Hosting environment: Development
    
    == APP == info: Microsoft.Hosting.Lifetime[0]
    
    == APP ==       Content root path: /Users/ryan/actortest/MyActorService
    
  2. Run MyActorClient

    MyActorClient is acting as the client, and it can be run normally with dotnet run.

    Open a new terminal an navigate to the MyActorClient directory. Then run the project with:

    dotnet run
    

    You should see commandline output like:

    Startup up...
    Calling SetDataAsync on MyActor:1...
    Got response: Success
    Calling GetDataAsync on MyActor:1...
    Got response: PropertyA: ValueA, PropertyB: ValueB
    

💡 This sample relies on a few assumptions. The default listening port for an ASP.NET Core web project is 5000, which is being passed to dapr run as --app-port 5000. The default HTTP port for the Dapr sidecar is 3500. We’re telling the sidecar for MyActorService to use 3500 so that MyActorClient can rely on the default value.

Now you have successfully created an actor service and client. See the related links section to learn more.

4 - Dapr Actors (Next) in the .NET SDK

Overview of the modernized Dapr Actors implementation for .NET (Dapr.Actors.Next)

Dapr.Actors.Next is a reimplementation of the Dapr Actors building block for .NET. The original Dapr.Actors package has been in service for a long time and predates much of what the modern Dapr .NET SDK now relies on, so this package updates the actor experience to match: it talks to the runtime over the bidirectional gRPC streaming introduced in Dapr 1.18, removes reflection from the actor path, and brings actors in line with the dependency injection, options, serialization, and source generator patterns used elsewhere in the SDK.

The actor model itself is unchanged. Actors are still virtual, addressed by type and id, with reentrancy, timers, reminders, and proxying. Dapr.Actors.Next is a separate package family and is not API-compatible with Dapr.Actors, but it is fully wire-compatible with the Dapr runtime, so adopting it requires no runtime changes except that this will only work with the Dapr runtime v1.18 or later.

What changed

This rewrite was born out of the two core ideas:

  1. The first is how the SDK interfaces with the runtime. Actor callbacks (invoke, reminder, timer, and deactivate) now arrive over a long-lived bidirectional gRPC stream rather than per-call requests into a hosted actor endpoint, which lowers per-call overhead and removes the need to map inbound actor handlers. This is the same streaming pattern Dapr already uses for pub/sub subscriptions and workflow dispatch.
  2. The second is the amount of work the SDK now does locally, on your side of that connection (both at build and runtime), to make actor code easier and safer to write. Source generators replace the reflection-based proxy and dispatch machinery, which both removes a runtime cost and enables Native AOT. Serialization moves to the same pluggable IDaprSerializer used by State management and Workflows. State migration, a state machine programming model, pub/sub-driven actors, and a deterministic in-memory test runtime are all provided in the box. The result is meant to make it straightforward to build high-performance actor applications using the rest of the modern Dapr .NET SDK rather than a separate set of actor-specific conventions.

Source generators and compile-time checks

Source generators do most of the wiring that you used to write or that the old SDK did with reflection. From your actor interface and implementation, the generator produces the client proxy, the server-side dispatcher, the activation factory, the registration, and an actor registry describing every actor and its method signatures. None of this is hand-written, and none of it runs through reflection at call time, which is what keeps the path AOT-friendly.

Alongside the generators, the package ships analyzers and code fixes that catch actor-specific mistakes while you type: methods that aren’t serializable or don’t return a task type, blocking calls or thread escapes inside a turn, time read from the clock instead of an injected TimeProvider, a state change that would break deserialization of existing data, or a gap in a state migration chain. Where a fix is mechanical, the analyzer offers one. The full set is listed in the diagnostics reference.

How it differs from Dapr.Actors

ConcernDapr.Actors (original)Dapr.Actors.Next
Runtime connectivityInbound actor-handler endpoints the runtime calls into per invocationActor callbacks over an outbound persistent SubscribeActorEvents stream; no actor endpoints to map (the app’s gRPC server port remains)
Proxies and dispatchReflection (DispatchProxy, runtime IL)Source-generated; no reflection on the hot path
Native AOT and trimmingNot supportedSupported; runtime packages are trim/AOT clean
Nullable reference typesPartialEnabled across the public surface
RegistrationManual RegisterActor<T>() per typeSource-generated discovery; actors are not registered by hand
Hosting setupapp.MapActorsHandlers() endpoint mappingAddDaprActors() only; the host dials the runtime
Dependency injectionBespokePer-activation DI scope; constructor injection of registered services
Proxy flavorsRemoting proxy (.NET-to-.NET) and non-remoting proxy (cross-app)One proxy; no remoting/non-remoting distinction
SerializationDataContract via the remoting proxy, or System.Text.Json via the non-remoting proxyPluggable IDaprSerializer (default System.Text.Json) for every call; no DataContract built in
ConfigurationBespokeOptions pattern (IOptions<DaprActorsOptions>)
TestingIntegration tests against a sidecarIn-memory deterministic runtime plus optional Coyote deep testing
State versioningManualState-envelope schema versioning with upcaster chains
State machinesNot providedFirst-class StateMachineActor<TState, TData> model
Pub/sub integrationNot providedBuilt-in [Subscribe] subscription streams
Per-actor state cacheYesYes (retained)

Nullability and modernization

The public surface is annotated for nullable reference types, so the compiler distinguishes an actor method that may return no value from one that always returns a result, and flags the difference at build time rather than at call time. Combined with source-generated proxies and serialization, this removes a category of error the reflection-based SDK could only surface at runtime.

The modernization is functional rather than cosmetic. Removing reflection is what makes Native AOT possible and lowers startup and memory cost. The options pattern replaces bespoke configuration. A real per-activation DI scope means an actor’s dependencies are resolved and disposed on the same lifecycle as the actor. A single pluggable serializer means actors, state management, and workflows serialize the same way.

Core concepts

Each concept has its own page:

  • Author, register, and call actors: attributes, what the source generator produces, the minimum you must register, and cross-assembly and cross-app scenarios.
  • Serialization: the pluggable serializer, and the per-actor cache.
  • State migration: The state envelope and versioning state with upcasters.
  • State machine actors: the StateMachineActor model, when to choose it over a workflow, and how to test it.
  • Subscription streams: driving actors from pub/sub topics with [Subscribe].
  • Dynamic invocation: calling actors by type, id, and method with no compile-time contract, for gateways, tooling, and cross-language callers.
  • Testing: the in-memory test runtime, xUnit usage, and optional Coyote integration for deep concurrency testing.

Learn by example

If you would rather see the improvements one at a time with runnable code, the tutorial walks through six worked examples, each paired with its test, with the code in the solution under /examples/Actor.Next/. It is the fastest way to understand what changed and why.

Next steps

4.1 - Migrating from Dapr.Actors to Dapr.Actors.Next

A concept-by-concept guide for moving an existing Dapr.Actors (and Dapr.Actors.AspNetCore) actor layer to Dapr.Actors.Next

This guide is for developers who already run actors on Dapr.Actors (typically hosted with Dapr.Actors.AspNetCore) and want to move to Dapr.Actors.Next. It walks each concept you use today, shows the equivalent in the new SDK, explains how the concept itself changed, calls out those opportunities to make impactful mistakes, and gives before/after code. This document assumes you know how Dapr.Actors works; it does not re-teach the actor model, and it deliberately skips features that have no analog in the old SDK (state machines, subscription streams, the in-memory test runtime, and state migration each have their own page).

Dapr.Actors.Next is a new package family with a different API. Adopting it is a rewrite of your actor layer, not an in-place package bump. The good news is that it is wire-compatible with the Dapr runtime and addresses actors by the same type name, so the old and new SDKs can coexist and you can move one actor type at a time rather than all at once.

Before you start: the two things most likely to bite

Read these first. Everything else in the migration is mechanical by comparison.

  1. Persisted state must remain readable. This is the highest-stakes item, because existing actors already have state in your store. The good news is that Dapr.Actors already serializes actor state with System.Text.Json by default, and so does Dapr.Actors.Next, so state is broadly compatible. What can still break you is a difference in JsonSerializerOptions (property naming policy, enum-as-string versus number, how nulls and defaults are emitted) or a custom state serializer you configured on the old side. Treat the serializer configuration as part of your at-rest contract, verify it lines up, and where a shape genuinely changes, use the built-in state migration rather than hoping the bytes match.

  2. Live cross-SDK calls only line up on the JSON path. During coexistence, a call from a Dapr.Actors.Next proxy is serialized with System.Text.Json. That matches the old non-remoting proxy (also JSON) but not the old remoting proxy (DataContract). If you have a mixed fleet mid-migration where one app calls another over the actor protocol, the two ends must agree on the wire format. A clean way to avoid this class of problem is to move an actor type and its callers together, so you are never straddling two serialization formats for the same type.

Quick mapping

ConcernDapr.Actors (+ Dapr.Actors.AspNetCore)Dapr.Actors.Next
PackagesDapr.Actors + Dapr.Actors.AspNetCoreDapr.Actors.Next (meta-package)
Runtime connectivityInbound HTTP actor-handler endpointsOutbound persistent SubscribeActorEvents gRPC stream
Hosting wire-upAddActors(...) + app.MapActorsHandlers()AddDaprActors(...) only
Registrationoptions.Actors.RegisterActor<T>() per typeSource-generated discovery; no per-type call needed
Contract interfaceinterface I : IActor (no attribute)[GenerateActorClient] interface I : IActor
Implementation[Actor(TypeName="…")] class : Actor with Actor(ActorHost) ctor[DaprActor("…")] class : Actor with injected ActorActivationContext
Identity inside actorthis.Id (from Host)protected override ActorId Id => context.ActorId
State accessthis.StateManager (IActorStateManager)this.State (IActorStateAccessor)
Evict per-actor instance state cachethis.StateManager.UnloadStateAsync(string stateName, UnloadStateOptions options)IActorStateAccessor.EvictCacheAsync(DaprEvictStateOptions options); evicts cache entries without persisting them
Explicit saveSaveStateAsync() (or auto at end of turn)State.SaveStateAsync() when needed; otherwise automatic end-of-turn save
Proxy flavorsRemoting (Create<T>) and non-remoting (Create + InvokeMethodAsync)One proxy: IActorProxyFactory.Create<T>(id, type)
Payload serializationDataContract (remoting) or System.Text.Json (non-remoting)Pluggable IDaprSerializer path (System.Text.Json default)
TimersRegisterTimerAsync(name, callbackMethodName, byte[] state, …)IActorTimerScheduler.ScheduleAsync(…, operationName, arguments, …)
RemindersRegisterReminderAsync(…) + implement IRemindableIActorReminderScheduler.ScheduleAsync(…) routed to a named actor method
OptionsActorRuntimeOptions / ActorReentrancyConfigDaprActorsOptions (options pattern)
DIScope-per-activation via the AspNetCore DI activator; ctor takes ActorHostPer-activation scope; plain constructor injection
TestingIntegration tests against a sidecarIn-memory deterministic runtime (own page)
One interface, many actor typesNot practicalSupported
One actor type, many interfacesNot supportedSupported
Multi-parameter non-remoting methodsNot possible (single payload arg)Supported

Packages and hosting

In the old world you referenced Dapr.Actors for the client and runtime types and Dapr.Actors.AspNetCore for hosting, then mapped actor endpoints on an HTTP port so the sidecar could call into your app over HTTP. In the new world you reference a single Dapr.Actors.Next package and map a gRPC endpoint; at application startup, the host opens a persistent stream with the runtime through which all callbacks are received instead.

<!-- Before -->
<ItemGroup>
  <PackageReference Include="Dapr.Actors" Version="…" />
  <PackageReference Include="Dapr.Actors.AspNetCore" Version="…" />
</ItemGroup>
<!-- After: one reference brings the runtime, source generators, and analyzers -->
<ItemGroup>
  <PackageReference Include="Dapr.Actors.Next" Version="…" />
</ItemGroup>
// Before (Program.cs): register, then map the actor HTTP endpoints.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddActors(options =>
{
    options.Actors.RegisterActor<CartActor>();
    options.ActorIdleTimeout = TimeSpan.FromMinutes(30);
});

var app = builder.Build();
app.MapActorsHandlers();   // maps dapr/config, deactivation, method, remind, timer, healthz
app.Run();
// After: no endpoint mapping. AddDaprActors starts the host that streams from the runtime.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDaprActors(options =>
{
    options.ActorIdleTimeout = TimeSpan.FromMinutes(30);
});

var app = builder.Build();
app.Run();

How this changed: callbacks (invoke, reminder, timer, deactivate) used to arrive as inbound HTTP requests to endpoints you mapped with MapActorsHandlers(). Now they arrive over a long-lived bidirectional gRPC stream the app opens to the runtime. There is nothing equivalent to MapActorsHandlers().

Registration and discovery

Dapr.Actors required you to enumerate every actor type by hand inside the options delegate. Dapr.Actors.Next discovers them at build time with a source generator, so the per-type call disappears for the common case.

// Before: register each actor explicitly.
builder.Services.AddActors(options =>
{
    options.Actors.RegisterActor<CartActor>();
    options.Actors.RegisterActor<OrderActor>();
});
// After: discovery is automatic; AddDaprActors wires up every [DaprActor] in scope.
builder.Services.AddDaprActors();

You can still register a type under an explicit name (for example, sourced from configuration) with options.Actors.RegisterActor<T>(name), and you can turn automatic hosting off with EnableAutoActorRegistration = false in the registration options to build a gateway that calls actors without hosting any. Those are covered under Controlling automatic registration.

Defining an actor

The two-part shape (a contract interface plus an implementation) is unchanged, but both parts now carry an attribute, and the implementation gets its identity and state from an injected context object instead of an ActorHost passed to the base constructor.

// Before
public interface ICartActor : IActor          // derives from Dapr.Actors.IActor, no attribute
{
    Task AddItem(CartItem item);
    Task<CartSummary> GetSummary();
}

[Actor(TypeName = "CartActor")]
public class CartActor : Actor, ICartActor
{
    public CartActor(ActorHost host) : base(host) { }   // required base ctor

    public async Task AddItem(CartItem item)
    {
        var cart = await StateManager.GetOrAddStateAsync("cart", new CartState());
        cart.Items.Add(item);
        await StateManager.SetStateAsync("cart", cart);  // and/or rely on end-of-turn save
    }

    public async Task<CartSummary> GetSummary()
    {
        var cart = await StateManager.GetOrAddStateAsync("cart", new CartState());
        return new CartSummary(cart.Items.Count);
    }
}
// After
using Dapr.Actors.Next;

[GenerateActorClient]                          // required marker on the contract
public interface ICartActor : IActor
{
    Task AddItem(CartItem item, CancellationToken ct = default);
    Task<CartSummary> GetSummary(CancellationToken ct = default);
}

[DaprActor]                        // required marker on the implementation, optional name argument, supports more than one IActor interface
public sealed class CartActor(ActorActivationContext context) : Actor, ICartActor
{
    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.Add(item);            // mutate through .Value; saved at end of turn
    }

    public async Task<CartSummary> GetSummary(CancellationToken ct = default)
    {
        var cart = await State.GetOrCreateAsync("cart", () => new CartState(), ct);
        return new CartSummary(cart.Value.Items.Count);
    }
}

What changed and why:

  • [Actor(TypeName = "…")] becomes [DaprActor("…")]. The name argument is optional in both; if you omit it, the type name is used. The actor type name is still the durable address the runtime routes on, so keep it identical to what your existing instances were created under.
  • The interface now needs [GenerateActorClient]. Without it, the generator does not see the contract, and an implementation that references no decorated interface generates nothing at all (no proxy, no dispatcher, no registration) with no error to point at. This is the single most common “my actor silently does nothing” cause.
  • Actor(ActorHost host) is gone. Inject an ActorActivationContext and override Id and State from it. There is no Host, no ProxyFactory property on the base, and no Logger property; inject IActorProxyFactory and ILogger<T> if you need them.

Lifecycle hooks

The hooks map almost one-to-one, with two differences: they return ValueTask and take a CancellationToken, and the separate OnActorMethodFailedAsync is folded into OnPostActorMethodAsync, which now receives a nullable Exception.

// Before
protected override Task OnActivateAsync() {  }
protected override Task OnDeactivateAsync() {  }
protected override Task OnPreActorMethodAsync(ActorMethodContext context) {  }
protected override Task OnPostActorMethodAsync(ActorMethodContext context) {  }
protected override Task OnActorMethodFailedAsync(ActorMethodContext context, Exception e) {  }
// After
protected override ValueTask OnActivateAsync(CancellationToken ct = default) {  }
protected override ValueTask OnDeactivateAsync(CancellationToken ct = default) {  }
protected override ValueTask OnPreActorMethodAsync(ActorMethodContext context, CancellationToken ct = default) {  }
protected override ValueTask OnPostActorMethodAsync(ActorMethodContext context, Exception? exception, CancellationToken ct = default) {  }
// no separate OnActorMethodFailedAsync — inspect the exception parameter above

State management

State is the part that most deserves your attention, both because it is where your durable data lives and because the shape of the API changed. IActorStateManager becomes IActorStateAccessor, exposed as State rather than StateManager. A read now returns an IActorState<T> wrapper whose value you reach and mutate through .Value; pending changes are saved automatically at the end of each turn, and State.SaveStateAsync() is available when you need to persist them earlier.

// Before
var cart = await StateManager.GetOrAddStateAsync("cart", new CartState());
cart.Items.Add(item);
await StateManager.SetStateAsync("cart", cart);   // explicit set; save happens at end of turn
// After
var cart = await State.GetOrCreateAsync("cart", () => new CartState(), ct);
cart.Value.Items.Add(item);                        // mutate .Value; no set needed

Method mapping:

IActorStateManager (old)IActorStateAccessor (new)Notes
GetStateAsync<T>(name) (throws if missing)TryGetAsync<T>(name) then check, or GetOrCreateAsync<T>(name, factory)New read is null-returning or create-on-miss; there is no throw-on-missing read
TryGetStateAsync<T>(name)ConditionalValue<T>TryGetAsync<T>(name)IActorState<T>? (null if absent).Value on the wrapper holds the value
GetOrAddStateAsync<T>(name, value)GetOrCreateAsync<T>(name, () => value)Factory is a delegate, evaluated only on miss
SetStateAsync<T>(name, value)SetAsync<T>(name, value), or mutate .ValuePrefer mutating .Value inside a turn
AddStateAsync / TryAddStateAsyncGetOrCreateAsync / SetAsyncNo dedicated add-only method
AddOrUpdateStateAsync<T>(…)Mutate .Value (or SetAsync)The add/update dance is unnecessary with the wrapper
RemoveStateAsync / TryRemoveStateAsyncRemoveAsync(name)
ContainsStateAsync(name)TryGetAsync<T>(name) is not null
SaveStateAsync()SaveStateAsync()Optional; use only when you need a mid-turn write. End-of-turn save is still automatic
ClearCacheAsync()EvictCacheAsync()Drops cached entries without persisting them so the next read hits the store

How this changed: the old cache was also saved at the end of a turn, so auto-save is not new. What is new is the ergonomics (mutate .Value instead of get-mutate-set). You usually do not call save explicitly; call State.SaveStateAsync() only when later work in the same method depends on the state already being durable. A successful explicit save marks the cache clean, so the end-of-turn save does not write the same data again unless the actor changes it afterward. Multiple mutations before a save still collapse into a single write.

Do not use EvictCacheAsync as a save operation. It removes entries from the current activation’s cache and causes later reads to re-read durable state. By default it refuses to evict dirty entries; with EvictOnDirtyState = true, dirty entries are discarded rather than persisted.

Calling actors: one proxy, no remoting split

Dapr.Actors gave you two ways to call an actor: a strongly-typed remoting proxy (ActorProxy.Create<T>), which used DataContract and only worked .NET-to-.NET, and a non-remoting proxy (ActorProxy.Create returning ActorProxy, then InvokeMethodAsync<TRequest, TResponse>(method, data)), which used JSON and could cross app boundaries. Dapr.Actors.Next collapses this into a single proxy obtained from IActorProxyFactory, serialized through IDaprSerializer for every call.

// Before — remoting (DataContract, .NET-to-.NET only)
var cart = ActorProxy.Create<ICartActor>(ActorId.Create(userId), "CartActor");
await cart.AddItem(item);

// Before — non-remoting (JSON, cross-app; single payload argument)
var cart = ActorProxy.Create(ActorId.Create(userId), "CartActor");
await cart.InvokeMethodAsync<CartItem>("AddItem", item);
var summary = await cart.InvokeMethodAsync<CartSummary>("GetSummary");
// After — one proxy, injected factory, strongly typed, works cross-app too
public sealed class CheckoutService(IActorProxyFactory proxies)
{
    public async Task Add(string userId, CartItem item)
    {
        var cart = proxies.Create<ICartActor>(ActorId.Create(userId), "CartActor");
        await cart.AddItem(item);
    }
}

How this changed:

  • The strongly-typed proxy here resembles the old remoting client in calling syntax only. It does not use DataContract. Every call, typed or dynamic, serializes with System.Text.Json.
  • Prefer the injected IActorProxyFactory over the static ActorProxy.Create<T>(…) facade. The static facade still exists (and keeps the familiar shape for people coming from Dapr.Actors.Client.ActorProxy.Create), but it relies on process-wide mutable state; see Avoid the static ActorProxy facade.
  • Both the generic interface and the actor type name are required on Create<T>(id, type), because one interface can back several actor types (see below). The interface names the contract; the type name selects which registered actor you mean.

Timers

Timers move from a method on the actor base (RegisterTimerAsync) to an injected IActorTimerScheduler. The old API took the name of a callback method as a string plus a byte[] state blob; the new API takes the target operation name and either a typed argument serialized by the configured actor wire serializer or a caller-provided byte[], and routes the callback to that operation.

// Before — callback is a method name string; state is a byte[]; validated by reflection
protected override async Task OnActivateAsync()
{
    await RegisterTimerAsync(
        timerName: "refresh-prices",
        callback: nameof(RefreshPrices),
        callbackParams: Encoding.UTF8.GetBytes(sku),
        dueTime: TimeSpan.FromMinutes(1),
        period: TimeSpan.FromMinutes(5));
}

public Task RefreshPrices(byte[] data) {  }   // 0 or 1 param, must return Task
// After — inject the scheduler; route to a named operation with a typed argument
[DaprActor("Cart")]
public sealed class CartActor(ActorActivationContext context, IActorTimerScheduler timers)
    : Actor, ICartActor
{
    protected override ActorId Id => context.ActorId;
    protected override IActorStateAccessor State => context.State;

    public async Task AddItem(CartItem item, CancellationToken ct = default)
    {
        await timers.RescheduleAsync(
            actorType: "Cart",
            actorId: Id,
            name: "refresh-prices",
            dueTime: TimeSpan.FromMinutes(1),
            operationName: nameof(RefreshPrices),
            arguments: item.Sku,
            period: TimeSpan.FromMinutes(5),
            cancellationToken: ct);
    }

    public Task RefreshPrices(string sku, CancellationToken ct = default) => Task.CompletedTask;
}

Notes: dueTime still controls the first firing. Omitting period (or setting it to TimeSpan.Zero / Timeout.InfiniteTimeSpan) makes it one-shot; a positive period makes it periodic. ttl is optional and, when supplied, must be at least dueTime. Cancel with timers.CancelAsync(actorType, id, name, ct). The durability caveat is unchanged: a timer is in-memory and tied to the activation, so it does not survive deactivation or restart.

Reminders

Reminders are the callback shape that changed the most. In Dapr.Actors you implemented IRemindable.ReceiveReminderAsync — a single method that received every reminder and had to branch on reminderName to decide what to do, with the payload as a byte[]. In Dapr.Actors.Next a reminder is scheduled through IActorReminderScheduler and routes directly to a named actor method; there is no IRemindable and no central dispatch switch.

// Before — implement IRemindable and branch on the name
[Actor(TypeName = "Cart")]
public class CartActor : Actor, ICartActor, IRemindable
{
    public CartActor(ActorHost host) : base(host) { }

    public async Task StartAbandonWatch()
    {
        await RegisterReminderAsync(
            reminderName: "abandon-cart",
            state: Array.Empty<byte>(),
            dueTime: TimeSpan.FromMinutes(30),
            period: TimeSpan.Zero);
    }

    public async Task ReceiveReminderAsync(string reminderName, byte[] state, TimeSpan dueTime, TimeSpan period)
    {
        switch (reminderName)          // one method fans out to many reminders
        {
            case "abandon-cart":
                var cart = await StateManager.GetOrAddStateAsync("cart", new CartState());
                cart.Abandoned = true;
                await StateManager.SetStateAsync("cart", cart);
                break;
        }
    }
}
// After — schedule through the injected scheduler; the reminder name IS the operation
[DaprActor("Cart")]
public sealed class CartActor(ActorActivationContext context, IActorReminderScheduler reminders)
    : Actor, ICartActor
{
    protected override ActorId Id => context.ActorId;
    protected override IActorStateAccessor State => context.State;

    public async Task StartAbandonWatch(CancellationToken ct = default)
    {
        await reminders.ScheduleAsync(
            actorType: "Cart",
            actorId: Id,
            name: nameof(AbandonCart),   // the reminder name routes to this method
            dueTime: TimeSpan.FromMinutes(30),
            period: TimeSpan.Zero,
            arguments: Array.Empty<byte>(),
            ttl: TimeSpan.FromDays(1),
            overwrite: true,
            cancellationToken: ct);
    }

    public async Task AbandonCart(CancellationToken ct = default)
    {
        var cart = await State.GetOrCreateAsync("cart", () => new CartState(), ct);
        cart.Value.Abandoned = true;   // no IRemindable, no switch, no explicit save
    }
}

How this changed and what to watch for:

  • There is no IRemindable. Delete the interface and the central ReceiveReminderAsync switch; each reminder targets its own method.
  • The reminder name is both the durable reminder identity and the operation routed on callback. Choose a stable name and do not rename it casually. If you rename the method, already-registered reminders keep calling the old operation name until they are re-registered or cancelled.
  • Reminder payloads use the same overload pattern as timers: pass a typed value to serialize through the actor wire serializer, or pass byte[] when you have already serialized the payload yourself.
  • overwrite controls whether re-registering an existing name replaces the prior registration; Dapr defaults to overwrite, and overwrite: false lets a duplicate registration fail instead.
  • Durability is unchanged: reminders survive deactivation and restart and are delivered by the Dapr scheduler.

Options and reentrancy

ActorRuntimeOptions becomes DaprActorsOptions, configured through the standard options pattern in the AddDaprActors delegate. Most knobs carry over by name; reentrancy is the notable reshape.

// Before
builder.Services.AddActors(options =>
{
    options.ActorIdleTimeout = TimeSpan.FromMinutes(30);
    options.DrainOngoingCallTimeout = TimeSpan.FromSeconds(30);
    options.DrainRebalancedActors = true;
    options.ReentrancyConfig = new ActorReentrancyConfig
    {
        Enabled = true,
        MaxStackDepth = 32,
    };
});
// After
builder.Services.AddDaprActors(options =>
{
    options.ActorIdleTimeout = TimeSpan.FromMinutes(30);
    options.DrainRebalancedActorsTimeout = TimeSpan.FromSeconds(30);
    options.DrainRebalancedActors = true;
    options.EnableReentrancy = true;      // was ReentrancyConfig.Enabled
    options.MaxReentrantDepth = 32;       // was ReentrancyConfig.MaxStackDepth
});

Two things to know about reentrancy beyond the renamed properties. First, Dapr.Actors.Next propagates the call-chain metadata for you — actor authors no longer touch reentrancy headers (ActorReentrancyContextAccessor has no equivalent you call). Second, enabling reentrancy is deliberate: with it off, a call that loops back into an actor already in the chain blocks on the lock the first turn still holds. See Reentrancy is automatic, but must be enabled.

Options that no longer exist as such: UseJsonSerialization and JsonSerializerOptions on the runtime options are gone, because serialization is now the injected IDaprSerializer — configure it there instead (Serialization). DaprApiToken and HttpEndpoint are not actor-options concerns in the new model, which talks to the runtime over the gRPC stream.

Delivery semantics are the same contract — now stated plainly

Dapr.Actors actor calls were already at-least-once, and reminders already re-fired until acknowledged; many teams simply did not design for it. Dapr.Actors.Next does not change the contract, but it makes it explicit and testable: an invoke can be redelivered, and a reminder re-fires until the handling turn commits, so every actor method, reminder, and subscription handler must be safe to run more than once. Turn-local state changes are safe automatically; external side effects (publishing, calling another actor, charging a card) are your responsibility to make idempotent. Unlike the old world, you can now prove your handlers are re-run-safe in a unit test by injecting the redelivery. See Delivery guarantees you must design for.

Testing

This is less a migration step than an opportunity. On Dapr.Actors, exercising an actor meant an integration test against a running sidecar, which made reentrancy, timer-versus-call races, redelivery, and mid-save failures effectively untestable. Dapr.Actors.Next ships an in-memory, deterministic ActorTestRuntime that drives your real actors through the same generated dispatchers used in production, with virtual time and fault injection, no sidecar required. When you move an actor type, this is the moment to add the unit tests you could not write before. See Testing.

Limits that are lifted

Three constraints you may have designed around in Dapr.Actors are gone. You do not have to use these, but knowing they exist can simplify a migration and remove old workarounds.

Multiple parameters on a JSON-invoked method. The old non-remoting path took a single payload argument (InvokeMethodAsync<TRequest, TResponse>(method, data)), so multi-argument operations had to be packed into a wrapper type to be callable that way. In Dapr.Actors.Next there is no non-remoting/remoting distinction, and a strongly-typed method may take multiple parameters directly.

// Previously often forced into a single wrapper payload; now a natural signature.
Task<Quote> PriceItem(string sku, int quantity, string currency, CancellationToken ct = default);

One interface backing several actor types. The actor type name, not the interface, is the identity of a registered actor, so more than one [DaprActor] class may implement the same [GenerateActorClient] interface, each its own actor type with its own state and placement. This makes a shared contract across specialized actors practical.

[GenerateActorClient]
public interface IQueueActor : IActor { Task Enqueue(WorkItem item, CancellationToken ct = default); }

[DaprActor]                                   // actor type "OrdersQueueActor"
public sealed class OrdersQueueActor : Actor, IQueueActor { /* … */ }

[DaprActor]                                   // actor type "EmailsQueueActor", same contract
public sealed class EmailsQueueActor : Actor, IQueueActor { /* … */ }

One actor type implementing several interfaces. The relationship also runs the other way: a single implementation may implement several [GenerateActorClient] interfaces — a read side and a write side, or a public contract and an admin one — all served by one actor type with one identity and one set of state. The only rule is that method names must be unique across the contracts, since the runtime routes by method name.

[DaprActor("Document")]
public sealed class DocumentActor(ActorActivationContext context)
    : Actor, IDocumentReader, IDocumentWriter
{
    protected override ActorId Id => context.ActorId;
    protected override IActorStateAccessor State => context.State;
    // Read() and Write() on the two contracts share one activation and one state.
}

See One interface can back several actor types and One actor can implement several contracts.

A suggested migration sequence

  1. Confirm the runtime is on 1.18+ everywhere the actor will run.
  2. Pick one actor type to move first — ideally one with modest state and few callers.
  3. Create the contract with [GenerateActorClient]. If callers live in other apps, put the interface in a shared assembly both reference.
  4. Rewrite the implementation: [DaprActor], inject ActorActivationContext, override Id/State, translate StateManager calls to State, convert timers to IActorTimerScheduler, convert reminders to IActorReminderScheduler + named methods, drop IRemindable, and keep SaveStateAsync only where you need a mid-turn durable write.
  5. Verify state readability with a test that seeds an old-shaped value and reads it under the new code; introduce an upcaster if the shape changed.
  6. Move the callers of that type to IActorProxyFactory so the call and the actor share one serialization format, avoiding a remoting/JSON straddle.
  7. Add unit tests with ActorTestRuntime for the paths you could not test before.
  8. Repeat for the next type. Keep Dapr.Actors referenced for types not yet moved.

Red-flag checklist

  • Missing attributes. A [DaprActor] with no [GenerateActorClient] interface generates nothing, silently. (DAPR1421)
  • Changed actor type name. The type name is part of the durable address; changing it strands existing instances. Keep it identical.
  • Serializer option drift. State is JSON on both sides, but a different naming policy, enum handling, or a custom old-side serializer can make existing state unreadable. Verify, and use state migration for real shape changes.
  • Remoting/JSON straddle during coexistence. A Next proxy (JSON) does not line up with an old remoting caller (DataContract). Move an actor type and its callers together.
  • Reminder method renames. The reminder name is the routed operation; renaming a method leaves registered reminders pointing at the old name.
  • Assuming exactly-once. Handlers must be safe to run more than once; make external side effects idempotent.
  • Static ActorProxy facade. It works, but its process-wide mutable state is a footgun; inject IActorProxyFactory instead.
  • Actors in a referenced project. Discovery scans only the executing assembly unless you set DaprActorsScanReferences.

Next steps

4.2 - How to: Author, register, and call actors with the .NET SDK

Define actors, let the source generator wire them up, and call them with Dapr.Actors.Next

This article shows how to define an actor, what attributes drive the source generator, the minimum you must register, and how to call actors, including cross-assembly and cross-app scenarios.

Prerequisites

Dapr runtime v1.18.0 or later, a port on your application for gRPC inbound connections, and the Dapr.Actors.Next package. Dapr.Actors.Next is a meta-package that brings the runtime, source generators, and analyzers in one reference.

<ItemGroup>
  <PackageReference Include="Dapr.Actors.Next" Version="..." />
</ItemGroup>

Define an actor

An actor has two parts: a contract interface that derives from IActor and is decorated with [GenerateActorClient], and an implementation that derives from Actor and is decorated with [DaprActor].

using Dapr.Actors.Next;

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

[DaprActor]
public sealed class CartActor(ActorActivationContext context, IPricingClient pricing) : Actor, ICartActor
{
    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.Add(item);            // mutate through .Value; saved at end of turn
    }

    public async Task<CartSummary> GetSummary(CancellationToken ct = default)
    {
        var cart = await State.GetOrCreateAsync("cart", () => new CartState(), ct);
        return new(cart.Value.Items.Count, await pricing.Total(cart.Value, ct));
    }
}

On the Actor base, Id and State are abstract, so each actor supplies them. The established pattern is to inject an ActorActivationContext and override the two from it, as shown above. State is accessed by name through IActorStateAccessor: a read returns an IActorState<T> wrapper whose value you reach through .Value, and mutations to .Value are saved once at the end of the turn unless you explicitly call State.SaveStateAsync() earlier.

Both attributes are required, and they work together

The two attributes are not interchangeable, and neither is optional.

[GenerateActorClient] on the interface is what selects it for the generator. The generator builds its set of actor contracts only from interfaces that both derive from IActor and carry [GenerateActorClient]; an interface that derives from IActor alone is invisible to it. The attribute takes no arguments; it is purely the marker that says “generate a client for this contract.”

[DaprActor] on the implementation is discovered separately, but a [DaprActor] class only becomes a generated actor if it implements one of those [GenerateActorClient] interfaces. This is the part that may come as a surprise:

What the source generator produces

You write the decorated interface and the decorated implementation; the source generator produces everything needed to host and call the actor, with no reflection at call time.

You writeThe source generator produces
[GenerateActorClient] on interface ICartActor : IActorA strongly-typed client proxy for calling the actor, and the contract’s entry in the interface manifest
[DaprActor] on an implementation of a decorated interfaceA dispatcher that routes incoming invocations to your methods
[DaprActor] on an implementation of a decorated interfaceA typed activation factory that constructs the actor from DI
[DaprActor] on an implementation of a decorated interfaceAn entry in the generated registration and the actor registry (its type, methods, and signatures)

You do not implement proxies, dispatchers, or factories, and you do not write registration code per actor.

One interface can back several actor types

The actor type name, not the interface, is the identity of a registered actor. More than one [DaprActor] class may implement the same [GenerateActorClient] interface, and each becomes its own distinct actor type with its own name, its own state, its own placement, and its own registration. The shared interface is only the callable shape they have in common.

[GenerateActorClient]
public interface IQueueActor : IActor { Task Enqueue(WorkItem item, CancellationToken ct = default); }

[DaprActor]                                   // actor type "OrdersQueue"
public sealed class OrdersQueueActor : Actor, IQueueActor { /* ... */ }

[DaprActor]                                   // actor type "EmailsQueue", same contract, different actor
public sealed class EmailsQueueActor : Actor, IQueueActor { /* ... */ }

This is why creating a proxy takes the actor type name as well as the interface (see Call an actor): the interface names the contract, and the type name selects which registered actor you mean. When several actor types implement one interface, the type name is what chooses among them; a lookup by interface returns all the actor types that implement it, and you pick the one you intend by its type name.

Because the type name is the identity, the implementations that share an interface must each resolve to a distinct name. An analyzer (DAPR1420) flags the collision when two actors implementing the same contract would register under the same actor type name, and points you to fix it by giving them distinct [DaprActor] names or explicit registration aliases.

One actor can implement several contracts

The relationship also runs the other way: a single [DaprActor] implementation may implement more than one [GenerateActorClient] interface, and all of them are served by that one actor type. This is useful when a single entity has distinct facets you would rather express as separate contracts — a read side and a write side, or a public contract and an administrative one — without splitting the entity into separate actors.

[GenerateActorClient]
public interface IDocumentReader : IActor
{
    Task<DocumentSummary> Read(CancellationToken ct = default);
}

[GenerateActorClient]
public interface IDocumentWriter : IActor
{
    Task Write(DocumentPatch patch, CancellationToken ct = default);
}

[DaprActor("Document")]
public sealed class DocumentActor(ActorActivationContext context) : Actor, IDocumentReader, IDocumentWriter
{
    protected override ActorId Id => context.ActorId;
    protected override IActorStateAccessor State => context.State;

    public Task<DocumentSummary> Read(CancellationToken ct = default) => /* ... */;
    public Task Write(DocumentPatch patch, CancellationToken ct = default) => /* ... */;
}

The generator produces one strongly-typed proxy per interface (IDocumentReader and IDocumentWriter each get their own), but the actor is hosted as a single actor type with one registration and one dispatcher that covers the union of every implemented contract’s methods. Because there is one actor type, there is one identity, one activation, and one set of state; the interfaces are just different callable shapes over the same actor.

Call it by creating whichever proxy you need, each addressed with the same actor type name:

var reader = proxies.Create<IDocumentReader>(ActorId.Create(docId), "Document");
var summary = await reader.Read();

var writer = proxies.Create<IDocumentWriter>(ActorId.Create(docId), "Document");
await writer.Write(patch);

Both proxies route to the same activation, so a write through IDocumentWriter is visible to a subsequent read through IDocumentReader on the same id.

Register at startup, the minimum

The only required call is AddDaprActors. The options delegate is optional.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDaprActors();          // discovers and wires every [DaprActor] in this assembly

var app = builder.Build();
app.Run();

That single call starts the actor host (the persistent SubscribeActorEvents streams to the runtime) as a hosted service, registers every discovered actor’s proxy, dispatcher, and factory, registers any state upcasters (see State migration), and populates the actor registry.

Configuring options

builder.Services.AddDaprActors(options =>
{
    options.ActorIdleTimeout = TimeSpan.FromMinutes(30);
    // ...
});

Controlling automatic registration

By default, AddDaprActors() discovers every [DaprActor] in scope and registers it for hosting: it wires the dispatcher and activation factory and advertises the type to the runtime so the runtime delivers callbacks for it. It also discovers and registers every state upcaster. Two options let you turn those automatic behaviors off independently:

builder.Services.AddDaprActors(options =>
{
    options.EnableAutoActorRegistration = true;           // default; auto-host discovered [DaprActor] types
    options.EnableAutoStateMigrationRegistration = true;  // default; auto-register discovered upcasters
});

The distinction that matters is hosting versus calling. Calling an actor uses a source-generated proxy, obtained from IActorProxyFactory (or the static ActorProxy.Create<T>() facade), and does not depend on registration; it and IDynamicActorClient work as long as the contract is referenced. Registration is what makes this app host a type: advertise it, receive its callbacks, and construct instances. Turning EnableAutoActorRegistration off gives you an app that can still call actors but hosts none automatically, which is the gateway half of a dual-mode app.

A single app that runs as a gateway in one mode and hosts actors in another installs Dapr.Actors.Next once (both modes need the proxy hardware) and chooses per mode:

// Gateway mode: forward calls to actors hosted elsewhere; host nothing here.
builder.Services.AddDaprActors(options =>
{
    options.EnableAutoActorRegistration = false;
    options.EnableAutoStateMigrationRegistration = false;   // hosting nothing, so no state migrations run
});

// Hosted mode: host the discovered actors and receive their callbacks from the runtime.
builder.Services.AddDaprActors();   // defaults: both on

In gateway mode no streams are opened to the runtime for hosting, because no type is advertised; the app holds only the calling infrastructure.

Registering an actor under a custom name

The actor type name is the address the runtime routes on. To choose it explicitly, for example from configuration, register the actor by name in the options:

[DaprActor]                                      // attribute required; the name argument is optional
public sealed class QueueActor : Actor, IQueueActor { /* ... */ }

// ...

var queueActorTypeName = builder.Configuration.GetValue("QUEUE_ACTOR_TYPE_NAME", "QueueActor");

builder.Services.AddDaprActors(options =>
{
    options.Actors.RegisterActor<QueueActor>(queueActorTypeName);   // this is what the type is hosted under
});

The hosted name resolves by precedence: an explicit alias from RegisterActor<T>(name) if you give one, otherwise the name on the [DaprActor] attribute if you set one, otherwise the actor implementation’s type name. An explicit alias always wins over the other two, and it wins whether or not EnableAutoActorRegistration is enabled. With auto-registration off, only the types you register explicitly are hosted; RegisterActor<T>() with no name registers the type under its resolved default, which is the attribute name or, failing that, the type-name fallback.

Cross-assembly discovery

By default the generator scans only the executing assembly. If your actors or their state upcasters live in a referenced project, opt in to reference scanning by adding the following to the host application’s .csproj:

<ItemGroup>
  <CompilerVisibleProperty Include="DaprActorsScanReferences" />
</ItemGroup>

When enabled, the generator discovers and registers [DaprActor] types and IActorStateUpcaster<,> implementations from referenced assemblies as well, not just from the executing assembly. It is off by default because scanning references increases build time. Note that upcaster discovery is also subject to EnableAutoStateMigrationRegistration: if you have turned automatic migration registration off, the scan will not auto-register upcasters, whether they are local or in a referenced project.

Inject dependencies

Registering the actor infrastructure requires only AddDaprActors(), optionally with the options overload. Nothing else is needed to host and run actors:

builder.Services.AddDaprActors();

Constructor injection into an actor is supported, and any injected service resolves from the actor’s per-activation scope and is disposed with it. Injection does require that you register the service yourself: AddDaprActors() wires up the actor infrastructure only, and does not register any Dapr building-block clients on your behalf.

For example, to use the state management client inside an actor for application state stores (which are separate from the actor’s own State), add the Dapr.StateManagement package, register DaprStateManagementClient, and inject it:

builder.Services.AddDaprActors();
builder.Services.AddDaprStateManagementClient();   // from the Dapr.StateManagement package
[DaprActor]
public sealed class CartActor(ActorActivationContext context, IPricingClient pricing, DaprStateManagementClient stateStores)
    : Actor, ICartActor
{
    protected override ActorId Id => context.ActorId;
    protected override IActorStateAccessor State => context.State;
    // pricing and stateStores resolve per activation, provided you registered them, and are disposed on deactivation
}

Lifecycle hooks

Override the per-actor lifecycle hooks for setup and teardown. These run on the actor’s own turns and are separate from the cross-cutting filter pipeline.

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

    protected override ValueTask OnActivateAsync(CancellationToken ct = default) => /* runs when the instance is activated */;
    protected override ValueTask OnDeactivateAsync(CancellationToken ct = default) => /* runs before deactivation */;
    protected override ValueTask OnPreActorMethodAsync(ActorMethodContext context, CancellationToken ct = default) => /* before each method */;
    protected override ValueTask OnPostActorMethodAsync(ActorMethodContext context, Exception? exception, CancellationToken ct = default) => /* after each method */;
}

For cross-cutting concerns that span many actor types, you can implement an IActorTurnFilter, but it is rarely the right tool; see the next section.

Cross-cutting turn filters

An IActorTurnFilter wraps every actor turn, across every actor type, as a middleware pipeline around invocation. It applies to a narrow category of work, and most applications never need one.

Reserve a turn filter for concerns that are infrastructural rather than behavioral, and that must apply uniformly across many actor types: enforcing that a tenant or auth header is present before any turn runs, or enriching every turn’s trace and metrics. These are things you would otherwise copy into every actor, and that genuinely belong outside any single actor.

Avoid a turn filter for anything else, for several reasons:

  • It runs on every turn of every actor type, so it sits on the hot path globally; logic placed there is paid for everywhere, whether or not it is relevant.
  • It moves behavior away from the actor. You can no longer read a single actor and understand what a turn does, because part of the behavior lives in a filter the actor never mentions.
  • Ordering across multiple filters becomes a subtle, global concern that is easy to get wrong and hard to see.
  • Business logic in a filter is harder to discover and harder to test than the same logic in the actor, which you can drive directly with the test runtime.
  • The turn pipeline also performs framework work such as context restore and the end-of-turn state save. Heavy or failure-prone logic in a filter shares a lifecycle with that work and can put the integrity of the turn at risk.

For concerns specific to one actor, use that actor’s lifecycle hooks (OnPreActorMethodAsync, OnPostActorMethodAsync, OnActivateAsync, OnDeactivateAsync) instead; they are discoverable on the actor and testable in isolation. For behavior shared by several actors, prefer composing it into the actors through injected services rather than intercepting their turns.

An analyzer (DAPR1416, informational) flags when an IActorTurnFilter appears to contain business logic, to keep filters thin and infrastructural.

Timers and reminders

Register timers and reminders from inside the actor by injecting the scheduler you need. Timers use IActorTimerScheduler; durable reminders use IActorReminderScheduler.

using Dapr.Actors.Next;
using Dapr.Actors.Next.Core.Timers;

[DaprActor("Cart")]
public sealed class CartActor(
    ActorActivationContext context,
    IActorTimerScheduler timers,
    IActorReminderScheduler reminders) : Actor, ICartActor
{
    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.Add(item);

        // One-shot activation-local timer. If the activation goes away before
        // this fires, the callback is lost.
        await timers.RescheduleAsync(
            actorType: "Cart",
            actorId: Id,
            name: "refresh-prices-once",
            dueTime: TimeSpan.FromMinutes(1),
            operationName: nameof(RefreshPrices),
            arguments: item.Sku,
            cancellationToken: ct);

        // Periodic activation-local timer, limited by TTL.
        await timers.RescheduleAsync(
            actorType: "Cart",
            actorId: Id,
            name: "refresh-prices-periodic",
            dueTime: TimeSpan.FromMinutes(1),
            operationName: nameof(RefreshPrices),
            arguments: item.Sku,
            period: TimeSpan.FromMinutes(5),
            ttl: TimeSpan.FromMinutes(30),
            cancellationToken: ct);

        await reminders.ScheduleAsync(
            actorType: "Cart",
            actorId: Id,
            name: nameof(AbandonCart),
            dueTime: TimeSpan.FromMinutes(30),
            period: TimeSpan.Zero,
            arguments: Array.Empty<byte>(),
            ttl: TimeSpan.FromDays(1),
            overwrite: true,
            cancellationToken: ct);
    }

    public Task RefreshPrices(string sku, CancellationToken ct = default)
    {
        return Task.CompletedTask;
    }

    public async Task AbandonCart(CancellationToken ct = default)
    {
        var cart = await State.GetOrCreateAsync("cart", () => new CartState(), ct);
        cart.Value.Abandoned = true;
    }
}

The durability difference is the thing to design around. A timer is in-memory and tied to the activation: it does not survive deactivation or a process restart, so treat it as a best-effort convenience within a live activation, not a durable schedule. A reminder is durable and delivered by the Dapr scheduler, so it fires even if the actor was idle and had to be reactivated. Use a reminder whenever the wake-up must happen regardless of activation state, and a timer only for work that is fine to lose when the actor deactivates.

A timer registration has both a timer name and an operationName; the runtime routes the callback to operationName. A reminder registration has no separate callback field. The reminder name is both the durable reminder identity and the actor operation routed on callback, so choose a stable operation name and do not rename it casually. If the actor method is renamed, any already-registered durable reminders using the old name will continue to call the old operation name until they are updated or canceled. It’s recommended to keep the original callback method and have it replace the reminder to directly register the new method.

Timer and reminder payloads can be scheduled as typed values or as raw byte[]. Passing a typed value uses the configured actor wire serializer, so the callback method can declare the matching typed argument. Use the byte[] overload when you have already serialized the payload yourself or when the callback takes no payload and you want to pass Array.Empty<byte>().

For timers, dueTime controls the first firing. If you omit period, or set it to Timeout.InfiniteTimeSpan or TimeSpan.Zero, the SDK treats the timer as one-shot and cancels it after the callback turn commits. Set a positive period to create a periodic timer. ttl is optional and limits how long the timer can live; when supplied, it must be greater than or equal to dueTime. Cancel a timer with the same actor type, actor id, and timer name:

await timers.CancelAsync("Cart", Id, "refresh-prices-periodic", ct);

For reminders, dueTime controls the first firing and period controls repeat firing; use TimeSpan.Zero for a one-shot reminder. ttl is optional and limits how long the reminder can live. overwrite controls whether registering an existing reminder name replaces the previous registration; Dapr defaults this to overwrite, and setting overwrite: false lets duplicate registration fail instead. Cancel a durable reminder with the same actor type, actor id, and name:

await reminders.CancelAsync("Cart", Id, nameof(AbandonCart), ct);

Timer and reminder callbacks arrive over the same actor event stream as normal invokes. The SDK acks the callback only after the actor turn commits and pending state has been saved. For one-shot timers, a successful ack includes cancel: true so the runtime does not fire the timer again. Periodic timers and reminders continue according to their registration until canceled, expired by TTL, or replaced. If the handler throws or the stream drops before the ack reaches the runtime, the runtime can redeliver the callback. Keep handlers idempotent, especially when they perform external side effects.

See Testing for advancing virtual time to fire timers and reminders in tests. In the in-memory test runtime, IActorTimerScheduler and IActorReminderScheduler are backed by the virtual time provider, so actor code that schedules either through DI can be tested without a sidecar.

Saving actor state

Actor state uses an activation-scoped write-behind cache. In the common case, mutate state through .Value or SetAsync, return from the actor method, and let the runtime save pending changes at the end of the turn:

public async Task AddItem(CartItem item, CancellationToken ct = default)
{
    var cart = await State.GetOrCreateAsync("cart", () => new CartState(), ct);
    cart.Value.Items.Add(item);
}

Call State.SaveStateAsync(ct) only when you need the current changes to reach the underlying state store before the method continues, for example before making an external call that must observe durable actor state:

public async Task AddItemAndNotify(CartItem item, CancellationToken ct = default)
{
    var cart = await State.GetOrCreateAsync("cart", () => new CartState(), ct);
    cart.Value.Items.Add(item);

    await State.SaveStateAsync(ct);
    await notifications.CartUpdated(Id.Value, ct);
}

After SaveStateAsync succeeds, the saved cache entries are marked clean. The end-of-turn save does not write them again unless the actor changes them after the explicit save. Multiple changes before a save still collapse into one write.

EvictCacheAsync is different: it evicts cached entries from the current actor activation so the next read goes back to durable state. It does not persist dirty values. By default it rejects dirty cache entries; if you pass new DaprEvictStateOptions { EvictOnDirtyState = true }, those dirty in-memory values are discarded.

Delivery guarantees you must design for

Actor callbacks are at-least-once, and this shapes how you write actor methods. It is worth understanding before you build anything non-trivial, because it is the assumption most likely to be wrong if you carry a mental model of a method that runs exactly once.

An invoke can be redelivered. If the runtime delivered a call but did not receive the response (for example the stream dropped mid-turn), it treats the call as failed and retries it. Because pending actor state is saved at the end of the turn and the response is sent after that save, a retry can re-execute a turn against state the previous attempt already committed. Your actor methods must therefore be safe to run more than once. A method that only reads and mutates actor State is naturally safe, because a re-run lands on the same state. A method that also performs an external side effect (publishing an event, calling another actor, charging a card) is exposed, and that side effect must be made idempotent, for example by keying it on a stable request identity or by making the downstream operation naturally idempotent.

A reminder re-fires until it is acked. A durable reminder that comes due is redelivered every second, indefinitely, until the turn that handles it commits. Two conclusions follow from this:

  1. Reminder handlers must be idempotent, because you will see the same reminder more than once under any failure or delay.
  2. A reminder handler that reliably throws produces a one-second retry loop for that actor, so treat a consistently failing reminder as something to surface and fix, not to swallow.

Call an actor

Calling an actor has two independent questions: what you know at compile time (which decides between the strongly-typed proxy and the dynamic client), and where you are calling from (a client, or another actor). The actor type name is the addressing key the runtime uses for placement in every case, and serialization is the same IDaprSerializer path regardless.

Use the strongly-typed proxy when you have the actor’s contract interface available at compile time, which is the common case in application code. It gives you compile-time signature checking and editor completion. Use the dynamic client when the actor type or method exists in another app or not known until run time, for example in a gateway or a tool that calls actors it doesn’t reference.

Get a proxy from IActorProxyFactory

The DI-friendly way to obtain a proxy is to inject IActorProxyFactory and call Create<TActor>(id, actorType):

public sealed class CheckoutService(IActorProxyFactory proxies)
{
    public async Task Add(string userId, CartItem item)
    {
        var cart = proxies.Create<ICartActor>(ActorId.Create(userId), "CartActor");
        await cart.AddItem(item);
    }
}

The generic argument is the contract interface; the string is the actor type name you are addressing. Both are needed because a single interface can back more than one actor type (see One interface can back several actor types), so the interface alone does not identify which registered actor you mean. Pass the type name of the specific actor you intend to call.

There is also a static ActorProxy.Create<TActor>(...), backed by ActorProxy.Configure(...) and ActorProxy.Reset(), but prefer the injected IActorProxyFactory in essentially all application and test code.

Call an actor from inside an actor

An actor calls another actor the same way, by injecting IActorProxyFactory into its constructor. An actor reads its own identity from the protected ActorId Id supplied by activation, which is useful for deriving a target id or passing a correlation:

[DaprActor]
public sealed class CartActor(ActorActivationContext context, IActorProxyFactory proxies, IPricingClient pricing)
    : Actor, ICartActor
{
    protected override ActorId Id => context.ActorId;
    protected override IActorStateAccessor State => context.State;

    public async Task Checkout(CancellationToken ct = default)
    {
        var orders = proxies.Create<IOrderActor>(ActorId.Create($"order-{Id}"), "OrderActor");
        await orders.Place(Id, ct);   // Id is this cart's own actor id
    }
}

Three things are true of a call made from inside a turn, and two are easy to miss:

  • Awaiting the call does not release your turn. The runtime holds this actor’s lock for the whole turn, including while you await a call to another actor. The other actor runs its own turn; yours stays open until your method returns.
  • The call is at-least-once, like any invoke. If your turn is redelivered (see delivery guarantees), the outbound call runs again, so the target must be idempotent or the call must be safe to repeat.
  • Reentrancy is handled for you when it is enabled. Actor authors do not manually supply reentrancy headers. When reentrancy is enabled for the actor runtime, Dapr.Actors.Next automatically propagates the call-chain metadata across actor calls made during an actor turn, so a chain that re-enters an actor already in the chain (A calls B, B calls back into A) is recognized as reentrant rather than blocking on the lock the first turn still holds.

Call an actor in another Dapr app

From the caller’s side, calling an actor hosted by a different app looks identical to calling one in your own app. You create the same proxy with the same type name and await it; placement routes the call to whichever app hosts that type, and there is no special cross-app client, no endpoint, and no app id to manage in the common case.

// Same call, whether "CartActor" is hosted here or in another app:
var cart = proxies.Create<ICartActor>(ActorId.Create(userId), "CartActor");
await cart.AddItem(item);   // placement routes it to wherever "CartActor" lives

What makes this work across app boundaries is only how you share the contract:

  • Put the actor contract interface, with its [GenerateActorClient] attribute, in a shared assembly that both apps reference. Because the attribute travels with the interface, the calling app’s own build generates the client proxy from it, even though that app has no implementation.
  • The hosting app references the implementation and calls AddDaprActors().
  • The calling app references only the contract; it does not host the actor and does not need EnableAutoActorRegistration.

Because serialization is the same IDaprSerializer path on both sides and not tied to the proxy, the strongly-typed proxy works for cross-app calls too, which the old remoting proxy did not.

Dynamic invocation

The less common case is calling without a compile-time contract at all. When the actor type or method is not known until run time, such as in a gateway, in admin tooling, in a cross-language caller, or in an agent, call through IDynamicActorClient and discover what is callable at runtime through IActorRegistry, instead of the strongly-typed proxy. This is a distinct, language-agnostic capability with its own page: see Dynamic invocation.

Analyzers and diagnostics

The package ships analyzers that catch actor-specific mistakes at build time, several with code fixes. They use the DAPR14xx range.

IDFlagsCategorySeverity
DAPR1410A state shape change that breaks deserialization of shipped stateCompatibilityWarning
DAPR1411Work that escapes the actor scheduler, such as Task.RunConcurrencyWarning
DAPR1412A blocking call inside an actor turnConcurrencyWarning
DAPR1413Reading wall-clock time instead of an injected TimeProviderDeterminismWarning
DAPR1414An unseeded nondeterministic source, such as Guid.NewGuid() or RandomDeterminismWarning
DAPR1415A state migration target that is unreachable because no upcaster migrates to itCompatibilityWarning
DAPR1416Business logic inside an IActorTurnFilterDesignInfo
DAPR1417An actor interface method with an unsupported return typeUsageWarning
DAPR1418An in-place change to a shipped actor interface wire contractCompatibilityWarning
DAPR1419Mutable shared state held in an actor instance fieldConcurrencyWarning
DAPR1420Multiple actors that share a contract register the same actor type nameUsageWarning
DAPR1421A [DaprActor] implementation that does not expose a [GenerateActorClient] contractUsageWarning
DAPR1423A state type that looks like part of a migration family but is not connected to itCompatibilityWarning
DAPR1424A gap between ordered fragments in an actor state migration chainCompatibilityWarning
DAPR1425A non-additive state migration step that requires a hand-authored upcasterCompatibilityWarning
DAPR1426An actor state migration family with more than one fold path to a targetCompatibilityWarning
DAPR1427One persisted actor state name used with multiple migration familiesUsageWarning
DAPR1428Actor code that targets an older state type while a later reachable version existsUsageInfo
DAPR1429A scheduled reminder/timer callback that matches no dispatchable actor methodUsageError
DAPR1430A scheduled reminder/timer that targets an actor type not found in this applicationUsageWarning
DAPR1431A scheduled reminder/timer callback method that is not exposed through a generated actor clientUsageError

Best practices

  • Add only AddDaprActors() and let the generator do the rest; register building-block clients such as DaprStateManagementClient separately if you use them.
  • Keep actor state in State, keep fields immutable, and keep injected services stateless.
  • Use the strongly-typed proxy when you have the contract at compile time via IActorProxyFactory, and reserve IDynamicActorClient for gateways and tools that genuinely need late binding.
  • Pick the actor type name once and never change it.
  • Put [DaprActor] on the implementation and derive the contract from IActor; do not hand-write proxies or registration.

Next steps

4.3 - Serialization in the .NET SDK

Pluggable serialization, modern C# type support, and the per-actor state cache

Dapr.Actors.Next uses the same pluggable serializer as Dapr State management and Workflows, works with modern C# types out of the box, and retains the per-actor state cache. This page covers those. Evolving state shapes across releases is a separate, deeper topic with its own page: see State migration.

Pluggable serialization

Actor payloads and state are serialized through IDaprSerializer, resolved from dependency injection. The default implementation is JsonDaprSerializer (System.Text.Json), and you can replace it without changing any actor code.

// Default: System.Text.Json, no configuration required.
builder.Services.AddDaprActors();

To plug in a custom serializer, register your own IDaprSerializer before AddDaprActors(). The actor infrastructure registers the default with TryAdd, so an implementation you register first is the one that is used:

builder.Services.AddSingleton<IDaprSerializer, MyProtobufSerializer>();
builder.Services.AddDaprActors();

To keep the actor path trim- and AOT-safe, register an IDaprSerializer backed by a source-generated JsonSerializerContext rather than relying on the reflection-based default. Configuring JsonSerializerOptions with Configure<JsonSerializerOptions>(...) does not reach the actor serializer, because the default JsonDaprSerializer is constructed with its own options; register the serializer yourself instead:

[JsonSerializable(typeof(CartState))]
[JsonSerializable(typeof(CartItem))]
internal sealed partial class MyActorJsonContext : JsonSerializerContext;

builder.Services.AddSingleton<IDaprSerializer>(new JsonDaprSerializer(
    new JsonSerializerOptions(JsonSerializerDefaults.Web) { TypeInfoResolver = MyActorJsonContext.Default }));
builder.Services.AddDaprActors();

For a fully ahead-of-time-compiled host, also register a matching IActorWireSerializer, which serializes at the invocation wire boundary, so no reflection-based path remains.

Modern C# types work as written

With the default System.Text.Json serializer, the C# shapes you most likely reach for as payload and state types work with no serialization-specific attributes: records, and classes with a primary constructor, are both fine as-is.

This is a real change from Dapr.Actors, whose strongly-typed client uses the Data Contract Serializer. That serializer requires every type to have a public parameterless constructor or be explicitly decorated with [DataContract] and [DataMember], and init-only setters need the contract attributes too. A record or a primary-constructor class has no parameterless constructor, so under Dapr.Actors you had to add a redundant one or annotate the type. Here you do not:

// Works as-is as an actor method payload or state type under the default serializer:
public record CartItem(string Sku, int Quantity);

public sealed class CartState(string ownerId)
{
    public string OwnerId { get; } = ownerId;
    public List<CartItem> Items { get; } = [];
}

You still reach for a serializer-specific attribute when you want a specific serializer feature, for example [JsonPolymorphic] and [JsonDerivedType] for a polymorphic hierarchy, or [JsonConverter] for a custom conversion. The point is that you add those to opt into a feature, not to satisfy a constructor requirement the language no longer wants you to meet.

The per-actor state cache

State reads are served from an in-memory, write-behind cache scoped to the activation. Mutations are not written through on every change; instead pending state is saved once at the end of each turn, which minimizes calls to the state store.

public async Task AddItem(CartItem item, CancellationToken ct = default)
{
    var cart = await State.GetOrCreateAsync("cart", () => new CartState(), ct);  // cache hit after first load
    cart.Value.Items.Add(item);                        // mutate through .Value within the turn
    // no Save() needed; the turn saves once when it completes
}

You rarely call save explicitly, and multiple mutations in a single method (or across a reentrant call chain within the same turn) collapse into a single write.

Evolving state shapes: see State migration

When a state type’s shape changes between releases, older persisted data must still load under the new code. That is a first-class, opt-in feature with its own page rather than a subsection here, because it carries real depth: upcaster chains, additive auto-generation, a graduation off-ramp, and build-time and run-time integrity guarantees. The short version is that you write one small upcaster per shape change (or none, for additive changes), and the runtime folds stored data up to the shape your code asks for on read, re-persisting lazily. For the full model and best practices, see State migration.

Best practices

  • Register a JsonSerializerContext for your actor payloads to stay on the AOT-safe path.
  • Keep serializer configuration for state types stable, and treat a change that alters the persisted shape as a versioning decision (see State migration).
  • Keep per-actor state reasonably sized, since the whole state saves as one value per turn.
  • Keep mutations in State rather than in external side effects, so a re-run turn stays safe under at-least-once delivery.

Next steps

4.4 - State migration in the .NET SDK

Evolve actor state safely across releases with upcaster chains, additive auto-generation, and a graduation offramp

Actors have no replay, so they have no code-versioning problem: a new deployment simply is the new behavior. The only thing that must survive a deploy is the actor’s persisted state, so migration here means one specific thing, keeping state written under an older shape loadable and usable under newer code. It is deserialization compatibility on read, not a workflow-style code-version system.

The feature is opt-in by default. If a state type’s shape does not change, there is nothing to migrate and no machinery to configure; the type behaves exactly as it does today. You enroll a type in migration only by authoring an upcaster that touches it, or by letting the generator synthesize a trivial additive upcaster.

The mental model

Five ideas carry the whole feature.

  • Nodes: each versioned shape of a state type is a node, such as CartState, CartStateV2, CartStateV3.
  • Hops: an IActorStateUpcaster<TFrom, TTo> is a directed edge from one node to the next. Hops are the only migration code you write, and additive hops can be generated for you.
  • Families: a family is the connected set of nodes reachable from each other through hops (the CartState family is {CartState, CartStateV2, CartStateV3}). An actor can persist several independent named entries, each its own family; families never collide.
  • Current is the type your code asks for: when you read GetOrCreateAsync<CartStateV3>("cart", ...), the runtime discovers whatever is stored, folds it up to CartStateV3, and hands you that.
  • Lazy re-persist: folding happens on read, in memory. The migrated value is written back only on the next save, the actor’s normal end-of-turn state save. Over time the store heals to the current shape with no bulk migration job.

Authoring: write the hops, nothing else

You write one upcaster per shape change. Upcasters are resolved from dependency injection, so a hop may take an injected service as a constructor parameter, and they are async (UpcastAsync returns a ValueTask).

// The shapes.
public sealed class CartStateV1 { public List<string>   Skus  { get; init; } = []; }
public sealed class CartStateV2 { public List<CartLine> Lines { get; init; } = []; }
public sealed class CartStateV3 { public List<CartLine> Lines { get; init; } = []; public int TotalQuantity { get; init; } }
public sealed record CartLine(string Sku, int Quantity);

// The hops: the only migration code you write.
public sealed class CartStateV1ToV2 : IActorStateUpcaster<CartStateV1, CartStateV2>
{
    public ValueTask<CartStateV2> UpcastAsync(CartStateV1 s, CancellationToken ct = default) =>
        ValueTask.FromResult(new CartStateV2
        {
            Lines = s.Skus.GroupBy(x => x, StringComparer.Ordinal)
                          .Select(g => new CartLine(g.Key, g.Count())).ToList(),
        });
}

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

The actor contains no migration logic. It reads and writes the current shape, and the folding happens underneath:

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

    // Whatever version is stored (V1/V2/V3) folds up to CartStateV3 transparently,
    // and re-persists at the current shape on the next save.
    public async Task<CartStateV3> GetState(CancellationToken ct = default) =>
        (await State.GetOrCreateAsync("cart", () => new CartStateV3(), ct)).Value;

    public Task Clear(CancellationToken ct = default) => State.RemoveAsync("cart", ct).AsTask();
}

Upcasters are discovered and registered for you by the source generator (subject to EnableAutoStateMigrationRegistration, see Author, register, and call actors); you do not register them by hand.

Additive changes need no upcaster

If a new version only adds members, and those new members are all defaultable, the generator synthesizes the hop for you: it copies the shared members and defaults the new ones. You write the new shape and nothing else.

public sealed class MyState   { public string Name { get; init; } = ""; }
public sealed class MyStateV2 { public string Name { get; init; } = ""; public int  Age    { get; init; } }
public sealed class MyStateV3 { public string Name { get; init; } = ""; public int  Age    { get; init; } public bool Active { get; init; } }
// No upcasters authored. The generator slots MyState as the origin, orders the chain, and emits
// MyState -> MyStateV2 and MyStateV2 -> MyStateV3 automatically.

Non-additive changes (renames, removals, restructures, or a cross-name shift like CartStateV3 to ShoppingCartV4) cannot be synthesized. You author those hops, and an analyzer offers a scaffold when one is missing.

Reading and writing state

The state accessor migrates by default; the ordinary read and write methods do the folding and enrollment for you.

MethodBehavior
TryGetAsync<T>(name, ct)Reads and folds stored data up to T. Legacy or plain bytes are returned as T. Returns null when nothing is stored.
GetOrCreateAsync<T>(name, factory, ct)As above, or creates a new value from factory when nothing is stored.
SetAsync<T>(name, value, ct)Writes value and auto-enrolls it at T’s node. There is no schema-version parameter; the discriminator is derived from the graph.
GraduateAsync<T>(name, ct)The offramp: folds to T, writes it plain, and stops enrolling this entry. Works even when migration is disabled.
RemoveAsync(name, ct)Deletes the entry.

Importing external legacy data

To seed a legacy-shaped value that arrived from outside the store (an HTTP body, a bulk import, another system), write it with SetAsync, which accepts any node type in a family, not only the latest:

await State.SetAsync("cart", legacyV1Value, ct);   // enrolls at V1's node
// the next read folds it forward to the current shape, lazily.

Naming and ordering: the versioning strategy

How a type name maps to a family and an order is a compile-time concern, used by the generator and the analyzer. It is pluggable and configured application-wide, and follows the same version-naming conventions used elsewhere in Dapr for consistency.

// Program.cs: pick how version names are parsed and ordered, app-wide.
builder.Services.UseActorStateVersioning<NumericVersionStrategy>();   // "V{N}" suffix, the default

The strategy parses a type name into a canonical family name plus a version and orders versions; the built-in NumericVersionStrategy maps CartStateV3 to family CartState at version 3, and treats an unnumbered origin like CartState as version 0 that sorts first. SemVerVersionStrategy and DateVersionStrategy are also provided, and you can write your own.

Edges win. An explicit upcaster edge is authoritative for family membership; the strategy is only the convention used to derive families, order, and additive hops when edges are absent. So names that follow the convention need nothing, and if two types should be one family but their names would split them, authoring a hop between them connects them regardless of naming. You almost never need an explicit override; the rare case is supplying a version for a name the strategy cannot parse, or selecting a different strategy for one family.

Opt-outs and the off-ramp

There are three levels of control, from letting the SDK do everything to leaving the feature entirely.

Full auto is opting out of authoring: for additive-only families, write no upcasters and let the generator synthesize them. You are still using migration.

Full disable is opting out of migration: a switch on DaprActorsOptions turns the feature off, so values are stored plain with no envelope enrichment and no auto-generation. Reads stay tolerant, so they can still read anything already written, which makes the switch safe to flip.

Graduation is the per-entry offramp. GraduateAsync<T>(name, ct) folds the entry to T, writes it plain (no discriminator), and stops enrolling that entry. It is per state name, not all-or-nothing, and it always works, even when migration is globally disabled, because it is the reliable way to leave the feature. Once every instance of an entry has graduated, you can delete its upcasters.

Safety: what the SDK guarantees, and what you must not do

The single rule you must follow is append-only: you add new nodes and hops to a chain and never re-edit one that has already shipped. A hop may do anything internally (add, rename, remove, retype, restructure); what must not change is a node or hop that already shipped, because persisted instances may already have been written under it.

The SDK backs that rule with two lines of defense.

At build time, the generator fingerprints every node shape and every upcaster into the project baseline (the committed DaprActorsNext.Shipped.txt and DaprActorsNext.Unshipped.txt files that the DAPR1410 shape guard reads). If a superseded node or an already-used upcaster diverges from the committed baseline, you get a warning that you modified something already participating in migration, which risks corrupting persisted state. Updating the committed baseline is your deliberate acknowledgment that you meant to. The check is intentionally conservative, since the generator cannot see the dev/production boundary, so it treats any change to a node as risky.

At run time, each stored value carries a structural fingerprint of its node’s shape. If the stored fingerprint no longer matches the current build’s shape for that node, the value was written under a different shape of that node, and the runtime throws rather than silently deserializing stale data into a mismatched shape. Failing loud is the correct outcome here: silent partial deserialization is precisely the production incident this feature exists to prevent.

Renames are caught from the other direction. Renaming an older type does not change its shape, so it does not trip the fingerprint guard; instead it breaks graph connectivity, the renamed type is no longer reachable to the target, which the analyzer surfaces as a missing hop or an unconnected type.

Analyzers

Migration correctness is enforced primarily at build time, and the diagnostics live in the actors DAPR14xx range alongside the shape-baseline (DAPR1410), broken-chain (DAPR1415), and determinism (DAPR1413, DAPR1414) analyzers. See the diagnostics reference for the assigned IDs.

Build-time guidance covers the situations you will actually hit: a missing hop to a target you use in state (with a code fix that scaffolds the upcaster, pre-filling shared and additive members), a type that looks like it belongs to a family but is not fully connected, a gap in the middle of a chain, a non-additive step that cannot be auto-generated, an ambiguous fold path where several origins or a diamond make the path non-unique, and the corruption guard described above. Because the analyzer and the generator share the same version-parsing and fingerprinting helpers, build-time guidance and generated output never disagree, and IDE hints show which family a type participates in so the (now implicit) migration behavior stays discoverable.

Testing

Migration is deterministically testable in-memory with ActorTestRuntime, with no sidecar and no state store.

[Fact]
public async Task V1_cart_folds_to_current_on_read()
{
    await using var rt = new ActorTestRuntime();
    await rt.SeedStateAsync("MigratingCart", ActorId.Create("u1"), "cart",
        new CartStateV1 { Skus = ["sku-1", "sku-1", "sku-2"] });

    var cart = rt.CreateActor<IMigratingCartActor>(ActorId.Create("u1"), "MigratingCart");
    var state = await cart.GetState();

    Assert.Equal(3, state.TotalQuantity);   // folded V1 -> V2 -> V3
}

SeedStateAsync plants either enveloped or plain forms directly in the in-memory store, so you can seed a “V1” instance and assert the actor reads it back at the current shape. The fault injector adds hooks during a migrating read and between hops (FailNextMigration<T>(), FailNextUpcastHop<TFrom, TTo>()) so the failure paths through the fold are unit-testable, and the state snapshot API handles both enveloped and graduated forms so a test can assert the store healed to the current node after a migrating read and save. A useful test matrix is fold-by-default, an auto-generated additive chain, the corruption guard, multi-family isolation within one actor, exactly-one re-persist at the target, a SetAsync import that then folds, graduation followed by re-import, a fault during migration, and the full-disable opt-out. See Testing for the runtime surface.

Interpreted actors

Interpreted (runtime-defined) actors carry a dynamic state bag rather than typed CLR shapes, so typed upcasters do not apply to them. Their state travels the plain path and is versioned, if at all, as the version of the machine-definition document. The migration system never assumes all state is typed, so this composes without special handling; see the tutorial’s runtime-defined state machines part.

Best practices

  • Prefer additive changes (new defaultable members), which need no upcaster at all.
  • When a change is not additive, add one hop per shape change and let the chain compose; keep each hop small.
  • Keep upcasters pure and deterministic, and let them take injected dependencies rather than reaching for ambient state.
  • Never re-edit a node or hop that has shipped; add a new node and a new hop instead, and commit the updated baseline deliberately when the analyzer flags a change.
  • Keep version-conditional logic out of actor code; a branch on version is a missing hop.
  • Retire an old version only after its instances have been read forward (and so re-persisted) or drained; keep older shapes and their hops as long as instances written under them may still exist.
  • Use graduation to take an entry off the feature cleanly, and remember it is reversible if you need to re-enroll later.
  • Remember that upcasting exclusively happens lazily - state is only migrated when it is retrieved by an actor; migration never runs against the entirely of state written by the actor in its state store.

Next steps

4.5 - State machine actors in the .NET SDK

Author long-lived reactive entities as state machines with StateMachineActor, and test them deterministically

Some actors are genuinely state machines: a connection, a device, an auction, a subscription, a lease. Each is a long-lived, addressable entity that reacts to events and moves through explicit states over an indefinite lifetime. Dapr.Actors.Next provides a first-class model for these, StateMachineActor<TState, TData>, that stays an ordinary strongly-typed Dapr actor on the outside while letting you author the behavior as an explicit state machine on the inside.

When to use a state machine actor

Reach for this model when an actor has a small number of distinct states and the rules for what it may do depend on which state it is in. Encoding those states and transitions explicitly makes the rules visible, keeps illegal transitions from happening by accident, and (because the machine is a table rather than scattered if statements) lets the test runtime check the structure for you.

If an actor is a simple data holder, or its methods do not depend on an explicit lifecycle state, a plain [DaprActor] actor is the better choice; the state machine model adds structure you would not be using.

How it relates to Dapr Workflows

State machine actors and Dapr Workflows solve different problems, and confusing the two is the most common modeling mistake, because a stateful, multi-step thing can look like either at first glance. The distinction is what the unit of work actually is.

A workflow instance is an orchestration: code whose progress is durably tracked by replaying its recorded history. Workflows are strong at orchestrating multiple steps and services, with retries, timeouts, fan-out and fan-in, and potentially compensation when something fails partway. An orchestration can be short- or long-lived; it may loop indefinitely and continue as a new execution to keep its history bounded, so it is not required to run to a single completion.

A state machine actor is an entity: a single addressable thing that lives indefinitely and that many short executions act on. It is strong at holding current state, answering synchronous reads of that state immediately, and reacting to a stream of events over its lifetime.

AspectState machine actorDapr Workflow
Unit of workA long-lived, addressable entityAn orchestration of steps across services
LifetimeIndefinite; transitions and persists across activationsLong-lived; can loop indefinitely and continue as new executions, so it is not required to terminate
ExecutionTurn-based, single-threaded turns over persisted stateCode replayed against recorded history, compacted by continuing as new
Reading current stateSynchronous, from memory in one turnQueryable orchestration status
Event rateHandles high-frequency events as cheap turnsFrequent external events grow replay history, mitigated by compaction
OrientationIs the thing others interact withOrchestrates other work

The auction is the canonical example of something that looks like a process but is really an entity. It receives a high rate of bids, every bidder needs an instant read of the current high bid, and a last-second bid that extends the deadline races the close timer. A workflow would grow a large replay history under that bid rate, could not answer the synchronous reads cleanly, and would model the timer-versus-bid race awkwardly. A state machine actor handles all of it as ordinary turns.

How they complement one another

The two compose, and a well-designed system often uses both together, each doing what it is good at.

An entity hands off to a workflow when it enters a state that begins a finite process. When the auction transitions to Sold, its entry action starts a fulfillment workflow to charge the winner, notify the losers, and arrange shipping, with compensation if a payment fails. The actor remains the live, addressable entity; the workflow runs the bounded, multi-step orchestration and then ends.

A workflow drives entities as part of its orchestration. A provisioning or order workflow can call actor methods to reserve inventory or update a device, and signal an actor when it completes. The workflow coordinates the steps; the actors are the addressable things those steps act on.

A useful rule of thumb: the reactive, addressable, indefinitely-living thing is a state machine actor, and the finite multi-step process that it launches, or that coordinates it, is a workflow. Named states make the boundary between them clear, because the transition into a state is the natural place to start the workflow that state implies.

The model

A state-machine actor has a discrete state (TState, an enum) and extended state (TData, your typed data), both persisted together. You describe the machine in Configure, which builds a transition table; that is, a data structure rather than control flow, which the runtime executes.

public enum AuctionState { Open, Sold, Expired }

public sealed record AuctionData
{
    public decimal HighBid { get; init; }
    public string? HighBidder { get; init; }
}

[DaprActor("Auction")]
public sealed class AuctionActor(ActorActivationContext context, IActorTimerScheduler timers)
    : StateMachineActor<AuctionState, AuctionData>(context, timers, "Auction", new AuctionData()), IAuctionActor
{
    protected override void Configure(IStateMachine<AuctionState, AuctionData> sm)
    {
        sm.InitialState(AuctionState.Open);

        sm.In(AuctionState.Open)
          .OnEntry(ScheduleSoftClose)                                    // fires only on a live transition into Open
          .On<Bid>()
              .When((d, e) => e.Amount > d.HighBid).Do(AcceptBid)        // internal: stays Open
              .Otherwise().Reply(BidResult.TooLow)                       // guard fallthrough, no transition
          .On<StateMachineTimerFired>()
              .When(ctx => ctx.Event.Name == "soft-close").GoTo(AuctionState.Sold)   // timer fired: OnExit(Open) then OnEntry(Sold)
          .OnExit(CancelSoftClose);

        sm.In(AuctionState.Sold)
          .OnEntry(StartFulfillment)                                     // begins fulfillment (see below)
          .Ignore<Bid>();                                                // late bids dropped, not errored

        sm.OnUnhandled(ctx => throw new InvalidActorEventException(ctx.State, ctx.Event));
    }

    // A command method reifies its payload as an event; the return comes from the matched handler's Reply.
    public Task<BidResult> PlaceBid(Bid bid, CancellationToken ct = default) => Raise<BidResult>(bid, ct);

    private void ScheduleSoftClose(IEffectContext<AuctionState, AuctionData, object> ctx) =>
        ctx.Timers.Schedule("soft-close", TimeSpan.FromSeconds(30));

    private void CancelSoftClose(IEffectContext<AuctionState, AuctionData, object> ctx) =>
        ctx.Timers.Cancel("soft-close");

    private void AcceptBid(IEffectContext<AuctionState, AuctionData, Bid> ctx)
    {
        ctx.Update(d => d with { HighBid = ctx.Event.Amount, HighBidder = ctx.Event.Bidder });
        ctx.Timers.Reschedule("soft-close", TimeSpan.FromSeconds(30));   // resets the soft-close on each bid
        ctx.Reply(BidResult.Accepted);
    }

    private void StartFulfillment(IEffectContext<AuctionState, AuctionData, object> ctx)
    {
        // Where a real deployment starts the fulfillment workflow (charge, notify, ship).
    }
}

Method invocations are events

A command method on the actor interface reifies its payload as an event into the machine; the method’s return value is whatever the matched handler replies. Query methods that only read CurrentState or Data bypass the machine. This is what lets a state-machine actor remain a normal Dapr actor: proxies, the registry, dynamic invocation, and subscription streams all work unchanged.

Driving timed transitions

A state-machine actor has two ways to fire a transition on a timer, and both are durable across deactivation.

In-machine timers (used above). Schedule from inside an effect with ctx.Timers.Schedule, Reschedule, or Cancel (each takes a name and a due time). When the timer comes due it re-enters the machine as a StateMachineTimerFired event carrying that name, which you handle like any other event:

sm.In(AuctionState.Open)
  .On<Bid>().When((d, e) => e.Amount > d.HighBid)
      .Do(ctx => ctx.Timers.Reschedule("soft-close", TimeSpan.FromSeconds(30)))
  .On<StateMachineTimerFired>()
      .When(ctx => ctx.Event.Name == "soft-close").GoTo(AuctionState.Sold);

For a plain state timeout, .After(timeout) arms one declaratively; it arrives as StateTimeout<TState>, and you re-arm it from an effect with ctx.Timers.Reschedule(StateMachineConstants.StateTimeoutTimerName, timeout):

sm.In(AuctionState.Open)
  .After(TimeSpan.FromSeconds(30))
  .On<StateTimeout<AuctionState>>().GoTo(AuctionState.Sold);

Injected schedulers. Inject IActorTimerScheduler (or, for a durable retry-until-ack reminder, IActorReminderScheduler) and schedule a callback that targets one of the actor’s own methods, which raises a domain event:

// inside an effect, using the injected scheduler instead of ctx.Timers:
await timers.RescheduleAsync("Auction", Id, "soft-close", TimeSpan.FromSeconds(30), nameof(Close), arguments: Array.Empty<byte>());

public Task Close(CancellationToken ct = default) => Raise<object?>(new CloseAuction(), ct);
// handled by: sm.In(AuctionState.Open).On<CloseAuction>().GoTo(AuctionState.Sold);

Reach for the injected scheduler when you want the timer to raise a specific domain event of your own (rather than the framework’s StateMachineTimerFired/StateTimeout), when you need a durable reminder, or when you share a scheduling path with non-state-machine code. The auction tutorial uses this second style. Either way, the actor injects an IActorTimerScheduler and passes it to the base constructor, because ctx.Timers is backed by it.

DSL reference

VerbMeaning
InitialState(s)The state a freshly created instance starts in.
In(state)Begin configuring a state.
OnEntry(a) / OnExit(a)Actions run on a live transition into or out of the state.
On<TEvent>()Handle an event in this state.
.When(pred) / .Otherwise()Guard chain; first match wins; Otherwise is the fallthrough.
.GoTo(state)External transition: OnExit(current), then effect, then OnEntry(target).
.Do(effect)Run an effect; with no GoTo it is an internal transition (stay, no entry/exit).
.Reply(value)Supply the invoking method’s return value.
.Raise<TEvent>(e)Queue an internal event, processed within the same turn.
.Ignore<TEvent>()Drop the event with no error and no transition.
.Defer<TEvent>()Durable deferral (see below).
.After(timeout)Declarative state timeout, delivered as StateTimeout<TState> (a named ctx.Timers timer instead fires as StateMachineTimerFired).
SubstateOf(parent)Hierarchy: events unhandled by the child bubble to the parent.
OnUnhandled(h)Global fallback for an event no state handles.

OnEntry, OnExit, Do, and OnUnhandled each accept an inline lambda or a method-group delegate over IEffectContext<…>. They also accept a string effect name, but that form resolves through an ICapabilityRegistry and applies to interpreted (data-defined) machines; a compiled StateMachineActor throws if it reaches a named effect or guard with no registry.

Semantics you must know

These three rules are the difference between a correct state-machine actor and a subtly broken one.

Run-to-completion: an event, plus any internally raised events, is processed to a stable state before the next event is accepted. This maps directly onto Dapr’s turn-based guarantee.

Silent rehydration: when an idle actor is reactivated, CurrentState and Data are restored without running OnEntry or OnExit. Entry and exit actions fire only on live transitions. Anything that must run on every activation belongs in OnActivateAsync, not in OnEntry; otherwise side effects such as re-arming timers or re-emitting events would fire every time the actor wakes.

Durable deferral: because an incoming event is a method call that must return, Defer cannot hold a turn open waiting for a future message, which would deadlock. Defer<TEvent>() instead persists the event into the actor’s state, returns an accepted or deferred acknowledgment, and re-raises it on the next transition that no longer defers it.

Hierarchical states

Declare a parent state to share handlers across several states. An event unhandled by the child bubbles to the parent.

sm.In(AuctionState.Open).SubstateOf(AuctionState.Live);
sm.In(AuctionState.Paused).SubstateOf(AuctionState.Live);
sm.In(AuctionState.Live).On<Cancel>().GoTo(AuctionState.Expired);   // applies to Open and Paused

Versioning a state machine

CurrentState and Data persist together. Data evolves through the same upcaster chains described in Serialization and state migration. If a deploy removes or renames an enum value that a persisted instance currently occupies, map the stranded state to a legal one in an upcaster on activation; the state-version hook does double duty for the machine’s logical position.

Testing state machine actors

Because the machine is a table, the test runtime can both exercise its behavior deterministically and validate its structure without running it.

Behavioral testing

Drive events and advance virtual time to test transitions and the order in which turns run, with no sidecar. The soft-close race, where a last-second bid extends the deadline, becomes a plain, repeatable test. The bid and the close-timer firing are two separate turns whose arrival order is not fixed; the runtime never overlaps them, it runs one then the other, and the test asserts the outcome is correct whichever order they run in.

[Fact]
public async Task Last_second_bid_extends_close_and_is_not_lost()
{
    await using var rt = new ActorTestRuntime();
    var auction = rt.CreateActor<IAuctionActor>(ActorId.Create("a1"), "AuctionActor");

    await auction.PlaceBid(new Bid(100m, "alice"));
    rt.Time.Advance(TimeSpan.FromSeconds(29));               // just before soft-close
    var late = auction.PlaceBid(new Bid(110m, "bob"));       // a bid turn and a close-timer turn are now both pending
    rt.Time.Advance(TimeSpan.FromSeconds(2));
    await rt.RunToIdle();                                    // scheduler explores the order of the two turns

    Assert.Equal(BidResult.Accepted, await late);
    Assert.Equal(AuctionState.Open, rt.StateOf(auction).CurrentState<AuctionState>());   // not prematurely Sold
}

Structural validation

The transition table can be analyzed directly, so you can catch defects in the machine’s shape before it ever runs. ActorStateMachine.Analyze<TActor>() inspects the table and reports states no transition can reach, states with no way out, guard branches that can never be selected, and entry/exit ordering problems across hierarchy. AssertNoStructuralDefects() turns any finding into a test failure, so a refactor that strands a state breaks the build rather than surfacing in production.

[Fact]
public void Auction_machine_is_structurally_sound()
    => ActorStateMachine.Analyze<AuctionActor>().AssertNoStructuralDefects();

Analyze also returns the full report if you would rather inspect or assert on specific findings than fail on any. Structural analysis is part of the in-memory test runtime; see Testing for how it sits alongside the behavioral, concurrency, and fault-injection tools.

Best practices

  • Use a state machine actor when state-dependent rules are the point; use a plain actor otherwise.
  • Put reactivation logic in OnActivateAsync, never in OnEntry.
  • Keep effects small and deterministic; use IEffectContext.Update to evolve Data and ctx.Timers for time.
  • Use Ignore for events that are legitimately irrelevant in a state, and OnUnhandled to make true protocol violations loud.
  • Start a workflow from a state’s entry action when that state begins a finite, multi-step process; keep the entity itself in the actor.
  • Run ActorStateMachine.Analyze<T>().AssertNoStructuralDefects() as a unit test so structural regressions fail the build.

Next steps

4.6 - Subscription streams in the .NET SDK

Drive actors from pub/sub topics with [Subscribe] using Dapr.Actors.Next

Subscription streams let an actor react to messages from an existing Dapr pub/sub component. You annotate an actor method with [Subscribe], and a delivered topic event wakes the actor and invokes that method, much like a timer or reminder firing.

How it works

The subscription lives at the host, not in the actor body. The package opens a streaming subscription to the topic, receives filtered events, and forwards each one to the target actor as a normal, placement-routed proxy invocation. That invocation is what wakes the actor, wherever placement has put it, and runs it turn-based with all the usual guarantees.

Because the forward is an ordinary invocation, the instance that receives the topic event does not need to host the target actor. An event delivered to one replica can drive an actor placed on another.

Usage

Annotate an actor method with [Subscribe], naming the pub/sub component, the topic, and how to extract the routing key, which is the actor id that should receive the event.

[DaprActor]
public sealed class CartActor(ActorActivationContext context) : Actor, ICartActor
{
    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 e, CancellationToken ct)
    {
        var cart = await State.GetOrCreateAsync("cart", () => new CartState(), ct);
        cart.Value.MarkAvailable(e.Sku);
    }
}

The only thing you must supply is the routing key, which determines the actor id that gets the event. Everything else, including opening the subscription, deserializing the event, and forwarding the invocation, is generated, so the surface reads as though the actor subscribes directly. The routing key can be a property of the event, a CloudEvents attribute such as subject, or a content path.

Subscriptions are generated from your [Subscribe] methods, but the streaming host that opens them is a separate service you opt into. Register it alongside actors at startup:

builder.Services.AddDaprActors();
builder.Services.AddDaprActorStreams();   // Necessary to register the appropriate types

Delivery semantics

The subscription’s acknowledgment is gated on the outcome of the forward invocation, so a message is not acknowledged before the actor has processed it.

  • Actor turn succeeds: ack.
  • Transient failure: retry.
  • Poison message: drop or dead-letter, per the component’s configuration.

Single delivery across replicas is provided by the pub/sub component’s own consumer and queue semantics; the package does not reinvent fan-out. Each event is delivered to one application instance, which performs one placement-routed invocation to the one target actor.

Tracing

The CloudEvents traceparent is extracted from the event and attached to the forward invocation, so the inbound delivery and the actor wake-up appear as a single trace rather than two disjoint ones. The host hop is abstracted away in a trace view as well as in code.

Testing subscription streams

There is no fake broker to stand up. You drive the subscription runner directly against the in-memory runtime, which exercises the same forward-invoke and Ack/Retry/Drop path the streaming host uses. Describe the subscription, build a runner over the runtime’s invocation client, and feed it ActorStreamEvents:

private static readonly ActorStreamSubscription Subscription = new(
    "orders-pubsub", "inventory-restocked", "CartActor",
    nameof(ICartActor.OnRestock), nameof(RestockEvent.CartId));

private static readonly JsonSerializerOptions WebJson = new(JsonSerializerDefaults.Web);

// The runner forwards a delivered event to the placement-routed actor. It needs the
// runtime's invocation client, which the in-memory runtime exposes internally.
private static ActorStreamSubscriptionRunner Runner(ActorTestRuntime rt)
{
    var invocationClient = (IActorInvocationClient)rt.GetType()
        .GetProperty("Runtime", BindingFlags.Instance | BindingFlags.NonPublic)!
        .GetValue(rt)!;
    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, WebJson)),
    new Dictionary<string, string>());
[Fact]
public async Task Restock_event_routes_to_the_cart_in_its_route_key()
{
    await using var rt = new ActorTestRuntime(services => services.AddDaprActors(_ => { }));
    var cart = rt.CreateActor<ICartActor>(ActorId.Create("u1"), "CartActor");

    var delivery = Runner(rt).ProcessEventAsync(Subscription,
        Event(new RestockEvent { CartId = "u1", Sku = "sku-1" }));
    await rt.RunToIdle();

    Assert.Equal(ActorStreamDeliveryAction.Ack, await delivery);
    Assert.Contains("sku-1", (await cart.GetSummary()).Available);
}

[Fact]
public async Task Transient_failure_is_retried_not_acked()
{
    await using var rt = new ActorTestRuntime(services => services.AddDaprActors(_ => { }));
    _ = rt.CreateActor<ICartActor>(ActorId.Create("u1"), "CartActor");

    rt.Faults.FailNextStateWrite<CartState>();   // first delivery fails mid-turn
    var delivery = Runner(rt).ProcessEventAsync(Subscription,
        Event(new RestockEvent { CartId = "u1", Sku = "sku-1" }));
    await rt.RunToIdle();

    Assert.Equal(ActorStreamDeliveryAction.Retry, await delivery);   // retried, not acked
}

The types used here (ActorStreamSubscription, ActorStreamSubscriptionRunner, ActorStreamForwarder, ActorStreamRoutingKeyExtractor, DefaultActorStreamFailureClassifier, ActorStreamEvent, and the ActorStreamDeliveryAction result) live in the Dapr.Actors.Next.Streams namespace.

End-to-end behavior against a real component is validated with the Testcontainers integration suite; see Testing.

Best practices

  • Choose a routing key that is stable and present on every event for the topic; an event with no resolvable key cannot be routed.
  • Keep [Subscribe] handlers idempotent. Delivery is at-least-once, and a retried message may be reprocessed.
  • Treat a subscription handler like any other turn: keep it short and let it transition state. For state-machine actors the event flows into the machine exactly like any other event.
  • Rely on the component’s delivery and ordering guarantees rather than building your own fan-out.

Next steps

4.7 - Dynamic actor invocation in the .NET SDK

Call actors by type, id, and method with no compile-time contract, for gateways, tooling, and cross-language callers

Most application code calls actors through the strongly-typed proxy, which needs the actor’s contract interface at compile time. Dynamic invocation is the other path: call an actor by its type name, id, and method with a serialized payload, without a compile-time contract. This is the actor protocol itself rather than a .NET authoring feature, which is what makes it the language-agnostic way to call actors and a distinctly different capability from the rest of the SDK.

Calling is not hosting

The most important thing to understand first: calling an actor does not require hosting, registering, or referencing it. A caller supplies a type name, an id, a method name, and a payload, and the runtime routes the call to whichever app in the cluster hosts that type. The caller does not implement the actor, does not call AddDaprActors() to call, and does not need the actor’s assembly.

This is the inverse of the advertisement requirement on the hosting side. An app that hosts actors advertises the types it hosts so the runtime can route inbound calls to it. A caller advertises nothing; it just calls. The strongly-typed proxy and the dynamic client are both callers, and neither hosts anything by virtue of calling.

The .NET surface

Two pieces, discovery and invocation.

IActorRegistry is discovery. The source generator emits it from the actors this app hosts, carrying each type, its methods, and their parameter and return types. This is genuinely new: the Dapr runtime metadata lists only actor type names, while the generated registry carries full signatures, so code in this app can enumerate what it exposes and how to call it.

public interface IActorRegistry
{
    IReadOnlyList<ActorTypeDescriptor> Actors { get; }
    bool TryGet(string actorType, out ActorTypeDescriptor descriptor);
}

IDynamicActorClient is invocation. It calls by string, with the arguments as JSON and the result as JSON.

public interface IDynamicActorClient
{
    Task<string?> InvokeAsync(string actorType, string actorId, string method,
                              string argsJson, CancellationToken cancellationToken = default);
}

A small gateway that lists what it hosts and forwards a call:

public sealed class ActorGateway(IActorRegistry registry, IDynamicActorClient client)
{
    public IReadOnlyList<ActorTypeDescriptor> Catalog() => registry.Actors;   // types + signatures

    public Task<string?> Forward(string type, string id, string method, string argsJson)
        => client.InvokeAsync(type, id, method, argsJson);
}

Note the scope of each: IActorRegistry reflects the types this app hosts, so it is discovery of what this app exposes. IDynamicActorClient can call any type the cluster hosts, whether or not this app hosts it and whether or not it appears in this app’s registry. When you call a type hosted by another app, you supply its type and method from a contract you already know, not from local discovery.

Cross-language calling

Because a call is a type name, an id, a method name, and a serialized payload with a content type, a .NET app can dynamically invoke an actor implemented in another language, and be invoked from one, over the same protocol. This is the strongest reason dynamic invocation exists as its own capability.

The .NET SDK controls only the .NET side of that exchange. It serializes and deserializes the .NET payloads it sends and receives through IDaprSerializer and sets and reads the content type. It has no control over how another language serializes its payloads, names its methods, or marshals its errors. Cross-language interop is therefore a payload-schema coordination problem that you own: agree on a wire format (JSON is the natural default for the dynamic path), agree on the content type, and validate what arrives. Treat the other side’s conventions as an external contract, not something the SDK settles.

Everything below this point is about what the .NET SDK does on its own side of the call.

Serialization is the same wire, without a proxy in between

The payload on the wire is identical to the strongly-typed path: serialized bytes plus a content type, with no remoting-versus-non-remoting distinction. The difference is only where serialization happens on the calling side. The strongly-typed proxy serializes your typed arguments for you through IDaprSerializer; the dynamic client takes arguments you have already serialized to JSON and sends them as-is. On the receiving side there is no difference at all: the hosting actor’s dispatcher deserializes the payload into the method’s parameter type through IDaprSerializer exactly as it would for a strongly-typed call.

The result model still applies

A dynamically invoked method that raises an application error returns that error the same way a strongly-typed call does, as an error payload the runtime passes back to the caller verbatim. The difference is reconstruction. The strongly-typed proxy rebuilds and rethrows the error as its CLR exception type, because it knows the contract. The dynamic client has no contract to rebuild against, so it surfaces the raw error payload and its content type and leaves interpretation to you. Plan to inspect the returned error rather than to catch a typed exception.

The delivery contract still applies

Dynamic invocation does not change delivery semantics, because those are a property of the invoke path, not of how you called it. A dynamically invoked method is at-least-once: a call the runtime pulled but could not confirm is retried, so the actor method must be safe to run more than once. This is the same contract that governs the strongly-typed path, reminders, and subscription deliveries. If you expose dynamic invocation through a gateway, the actors behind it still need idempotent handlers.

When to use it

  • A gateway or API layer that forwards calls to actors it does not reference at compile time.
  • Admin or operational tooling that invokes actors generically by type and method.
  • A cross-language caller, where a compile-time .NET contract does not exist by definition.
  • An agent that discovers callable actors from the registry and invokes them, one consumer of this surface among the others.

For actors whose behavior is defined at runtime rather than compiled, dynamic invocation is how you call them, but hosting them is a separate feature; see the agentic material in the overview and the interpreted state machine model. This page is about calling, not about runtime-defined hosting.

Testing

A dynamic call is drivable in the in-memory test runtime the same way a strongly-typed call is, so you can test a gateway or a generic router without a sidecar.

// IDynamicActorClient and IActorRegistry are resolved from the runtime's service
// provider, which the in-memory runtime holds in a private field.
private static IServiceProvider Provider(ActorTestRuntime rt) =>
    (IServiceProvider)rt.GetType()
        .GetField("provider", BindingFlags.Instance | BindingFlags.NonPublic)!
        .GetValue(rt)!;

[Fact]
public async Task Gateway_forwards_a_dynamic_call_to_the_actor()
{
    await using var rt = new ActorTestRuntime(services => services.AddDaprActors(_ => { }));
    rt.CreateActor<ICartActor>(ActorId.Create("u1"), "CartActor");   // host the target in the runtime
    var client = Provider(rt).GetRequiredService<IDynamicActorClient>();

    var json = await client.InvokeAsync("CartActor", "u1", "GetSummary", "{}");

    Assert.NotNull(json);
}

[Fact]
public async Task Registry_exposes_hosted_types_and_their_signatures()
{
    await using var rt = new ActorTestRuntime(services => services.AddDaprActors(_ => { }));
    var registry = Provider(rt).GetRequiredService<IActorRegistry>();

    Assert.True(registry.TryGet("CartActor", out var cart));
    Assert.Contains(cart.Methods, m => m.Name == nameof(ICartActor.GetSummary));
}

The first test proves the forward path end to end without infrastructure; the second proves discovery returns the signatures a gateway or agent would route on. See Testing for the full runtime.

Boundaries to keep in mind

  • There is no compile-time signature checking. A wrong method name or a malformed argsJson fails at runtime, not at build time, which is the cost of not having a contract.
  • Contract drift is not caught for you. Because the caller holds no compile-time reference to the target, a change to the target actor’s method signature will not break the caller’s build; it will surface as a runtime failure or, worse, a silently mismatched payload. Coordinate contract changes out of band, and use the ContractVersion on the registry descriptor (where the target is one you host) to detect drift deliberately rather than relying on the compiler to.
  • The delivery contract still applies. A dynamically invoked method is at-least-once like any invoke, so the target handler must be re-run-safe; calling dynamically does not opt out of that (see delivery guarantees).
  • The payload is only as safe as your validation. A gateway or agent that accepts a type, method, and JSON from outside must validate and authorize before invoking; dynamic invocation is a general capability with no built-in allow-list.
  • Application errors come back as raw payloads, not reconstructed CLR exceptions (see the result model above).
  • Discovery is local. IActorRegistry lists the types this app hosts; calling a type hosted elsewhere is by a contract you already know, not by discovery.

Next steps

4.8 - Testing actors in the .NET SDK

Deterministically test actor concurrency, timers, reminders, and failures with the in-memory runtime, xUnit v3, and Coyote

Testing is the headline capability of Dapr.Actors.Next. The hard parts of actors, including reentrancy, a timer that comes due while a call is in progress, duplicate or out-of-order delivery, and a failure in the middle of a state write, are normally only reachable with a running sidecar and wall-clock timing, which makes them effectively untestable. This SDK makes them deterministic, fast, and reproducible by isolating the three sources of nondeterminism (transport, scheduling, and time) behind abstractions, so the same actor code runs against either the real runtime or an in-memory test runtime.

This page covers what is available at development time, how to write tests with xUnit v3, how to add Coyote for deeper exploration, and how to run integration tests against a real sidecar.

What is available at development time

ActorTestRuntime gives you a controlled scheduler that explores the ordering of whole turns across actor ids, and reentrancy within a single call chain, deterministically and reproducibly from a seed, with strategies that include a seeded random walk and a priority-based strategy. Turns for a single actor id never overlap; the runtime holds the actor lock and delivers at most one non-reentrant callback per id at a time, so the scheduler explores the order in which whole turns run, never a point inside a turn. It gives you virtual time through TimeProvider, so timers and reminders come due only when you advance the clock and run as their own turns in a deterministic order relative to other turns. It gives you fault injection for state writes, state migration, and actor invocation, transient or permanent, covered in Fault injection. It gives you introspection, including an actor’s current state (and, for state machines, its current TState and Data) and a transcript of the explored ordering for replay. For state-machine actors, it adds structural analysis to detect unreachable states, dead ends, and entry/exit defects, covered in Structural analysis for state machines.

None of this requires a sidecar, a state store, or Docker.

A set of analyzers keeps actors inside the model the controlled scheduler can explore exhaustively. They flag work that escapes the scheduler such as Task.Run (DAPR1411), blocking calls (DAPR1412), wall-clock time used instead of TimeProvider (DAPR1413), and unseeded nondeterministic sources such as Guid.NewGuid() or Random (DAPR1414). Staying clean against these is what makes the in-memory exploration complete rather than best-effort; see the diagnostics reference.

Testing with xUnit v3

The Dapr .NET SDK tests with xUnit v3, and these examples assume it. Add the Dapr .NET Actors SDK and xUnit v3 packages to your test project:

<ItemGroup>
  <PackageReference Include="Dapr.Actors.Next" Version="..." />
  <PackageReference Include="xunit.v3" Version="..." />
  <PackageReference Include="xunit.runner.visualstudio" Version="..." />
  <PackageReference Include="Microsoft.NET.Test.Sdk" Version="..." />
</ItemGroup>

Create a runtime, create an actor proxy, drive it, and assert. The [Fact] and Assert surface is the same as earlier xUnit versions.

A basic test

public class CartActorTests
{
    [Fact]
    public async Task Adding_an_item_updates_the_summary()
    {
        await using var rt = new ActorTestRuntime();
        var cart = rt.CreateActor<ICartActor>(ActorId.Create("u1"), "CartActor");

        await cart.AddItem(new CartItem("sku-1", qty: 2));

        Assert.Equal(1, (await cart.GetSummary()).ItemCount);
    }
}

Because an alias is often sourced from configuration, and it is common to keep separate Debug and Release (or other environment) configuration profiles, resolve the alias in the test from the same source the host uses, or from a single shared constant, rather than hard-coding a literal. If the host reads QUEUE_ACTOR_TYPE_NAME from configuration, a test that hard-codes "OrdersQueue" may match the profile where that value happens to line up and then silently address the default or a different name under another profile. Reading the name the same way the host does keeps the test consistent with whatever profile it runs under.

// Resolve the alias the same way the host does, so the test tracks the active profile.
var actorType = configuration.GetValue("QUEUE_ACTOR_TYPE_NAME", "QueueActor");
var queue = rt.CreateActor<IQueueActor>(ActorId.Create("q1"), actorType);

Injecting dependencies

Configure services on the runtime the same way you would in your host. Test doubles are injected into the actor’s per-activation scope.

await using var rt = new ActorTestRuntime(services =>
{
    services.AddSingleton<IPricingClient>(new FakePricingClient(total: 42m));
});

var cart = rt.CreateActor<ICartActor>(ActorId.Create("u1"), "CartActor");
Assert.Equal(42m, (await cart.GetSummary()).Total);

Timers and reminders with virtual time

Advance the clock to fire a timer or reminder; nothing fires until you do.

[Fact]
public async Task Abandoned_cart_reminder_fires_after_thirty_minutes()
{
    await using var rt = new ActorTestRuntime();
    var cart = rt.CreateActor<ICartActor>(ActorId.Create("u1"), "CartActor");

    await cart.AddItem(new CartItem("sku-1", 1));
    rt.Time.Advance(TimeSpan.FromMinutes(30));    // fires the abandon-cart reminder
    await rt.RunToIdle();

    Assert.True((await cart.GetSummary()).IsAbandoned);
}

Fault injection and recovery

[Fact]
public async Task State_write_failure_does_not_corrupt_or_double_apply()
{
    await using var rt = new ActorTestRuntime();
    var cart = rt.CreateActor<ICartActor>(ActorId.Create("u1"), "CartActor");

    rt.Faults.FailNextStateWrite<CartState>();    // first end-of-turn save fails
    var add = cart.AddItem(new CartItem("sku-1", 1));
    await rt.RunToIdle();
    await add;

    Assert.Equal(1, (await cart.GetSummary()).ItemCount);   // exactly once
}

Turn ordering: a reminder runs after the in-flight turn, not during it

Turns for a single actor id never overlap. A method call is one turn and a reminder is another; if a reminder becomes due while a method turn is in progress, it waits for that turn to finish and then runs as its own turn. The scheduler explores the order in which whole turns run, never a point inside a turn. The test below pins that guarantee: a reminder that comes due during a method turn runs only after that turn commits, and observes its committed state.

[Fact]
public async Task Reminder_due_during_a_turn_runs_after_it_and_sees_committed_state()
{
    await using var rt = new ActorTestRuntime();
    var cart = rt.CreateActor<ICartActor>(ActorId.Create("u1"), "CartActor");

    // Start a method turn, and make a reminder come due before that turn completes.
    var add = cart.AddItem(new CartItem("sku-1", 1));
    rt.Time.Advance(TimeSpan.FromMinutes(30));   // the reminder is now due, but AddItem owns the turn
    await rt.RunToIdle();
    await add;

    // AddItem ran to completion first; the reminder turn ran next and saw the committed item.
    Assert.Equal(1, (await cart.GetSummary()).ItemCount);
}

The only thing that ever re-enters a turn already in progress is a reentrant call, a deliberate continuation of the same call chain identified by the dapr-reentrant-id header and bounded by DaprActorsOptions.MaxReentrantDepth, not an independent turn racing the first.

A legitimate race does exist, but it is a race over the order of two whole turns, not two turns overlapping. In the auction (see State machine actors), a late bid and the close-timer firing are two separate turns whose arrival order is not fixed, so the scheduler explores which turn runs first. Each still runs to completion on its own; the test asserts the outcome is correct whichever order they run in.

Reproducing a failure from a seed

When a scheduled exploration finds a failing ordering, it reports the seed. Pin the seed to replay it exactly.

await using var rt = new ActorTestRuntime(options: new ActorTestRuntimeOptions
{
    // Replay the exact ordering by re-seeding the scheduler the failing run reported.
    Scheduler = new PriorityActorScheduler(seed: 1234567),
});

Running many orderings

Run a scenario across many seeds to surface order-dependent bugs. Rebuild the runtime with a fresh SeededRandomActorScheduler(seed) each iteration; the two bids below are issued concurrently by their callers, but they run as two serialized turns on the auction, and each seed explores a different order for those turns. When an assertion fails, the seed that produced it is the reproduction.

[Fact]
public async Task Concurrent_bids_never_lose_the_high_bid()
{
    for (var seed = 0; seed < 1000; seed++)
    {
        await using var rt = new ActorTestRuntime(
            services => services.AddDaprActors(_ => { }),
            new ActorTestRuntimeOptions { Scheduler = new SeededRandomActorScheduler(seed) });
        var auction = rt.CreateActor<IAuctionActor>(ActorId.Create("a1"), "Auction");

        var a = auction.PlaceBid(new Bid(100m, "alice"));
        var b = auction.PlaceBid(new Bid(110m, "bob"));
        await rt.RunToIdle();
        await Task.WhenAll(a, b);

        Assert.Equal(110m, rt.StateOf(auction).Data<AuctionData>()!.HighBid);   // the seed pins the failing order
    }
}

Fault injection

rt.Faults injects failures so you can test recovery paths deterministically rather than hoping to observe them. A fault is scheduled against the next matching operation, or the next few, transient or permanent, and is explored alongside scheduling, so a fault that lands during a particular ordering is reproducible from the run’s seed.

State writes:

  • Faults.FailNextStateWrite<TState>() fails the next end-of-turn save of that state type. It is transient by default (it clears after one attempt); pass transient: false to keep failing, and stateName: to scope the fault to a single named entry.

State migration:

  • Faults.FailNextMigration<TState>() fails the next migrating read that folds to TState, and Faults.FailNextUpcastHop<TFrom, TTo>() fails a specific hop in the chain, so the fold’s failure paths are unit-testable (see State migration). Both take transient: and stateName:.

Actor invocation:

  • Faults.FailNextInvocation() fails the next actor invocation, so you can test how a caller handles a downstream failure. Scope it with actorType: and methodName:, and use transient: to control whether it clears after one attempt.

For example, a transient store failure that clears on retry, asserted to apply exactly once:

await using var rt = new ActorTestRuntime();
var cart = rt.CreateActor<ICartActor>(ActorId.Create("u1"), "CartActor");

rt.Faults.FailNextStateWrite<CartState>();    // transient by default: the first save fails, the retry succeeds
var add = cart.AddItem(new CartItem("sku-1", 1));
await rt.RunToIdle();
await add;

Assert.Equal(1, (await cart.GetSummary()).ItemCount);

Structural analysis for state machines

State machine actors built on StateMachineActor<TState, TData> carry their transitions as a table, so the test runtime can check the machine’s structure without executing it. ActorStateMachine.Analyze<TActor>() inspects the table and reports defects: states no transition can reach, states with no way out, guard branches that can never be selected, and entry/exit ordering problems across hierarchy. AssertNoStructuralDefects() turns any finding into a test failure.

[Fact]
public void Order_machine_is_structurally_sound()
    => ActorStateMachine.Analyze<OrderActor>().AssertNoStructuralDefects();

Run it as a unit test so a refactor that strands a state fails the build instead of surfacing in production. See State machine actors for authoring detail.

Deep testing with Coyote

The built-in scheduler explores the structured nondeterminism of the actor model: message order, reentrancy, and timer-versus-message races. That covers the large majority of actor bugs with no extra tooling. For the remainder, such as handlers that perform complex in-process async or whole-program data-race detection, you can run the same tests under Microsoft Coyote, which takes systematic control of Task, await, and locks across the entire call graph.

Install Coyote

Add the Coyote packages to your test project and install the CLI tool:

<ItemGroup>
  <PackageReference Include="Microsoft.Coyote" Version="..." />
  <PackageReference Include="Microsoft.Coyote.Test" Version="..." />
</ItemGroup>
dotnet tool install --global Microsoft.Coyote.CLI

Enable the Coyote bridge

The testing apparatus in Dapr.Actors.Next includes an opt-in Coyote bridge that routes the runtime’s scheduling decisions through Coyote’s systematic engine. It is compiled in through the DAPR_ACTORS_NEXT_COYOTE build constant rather than toggled per test, so a dedicated test configuration turns it on for the whole assembly:

<PropertyGroup Condition="'$(Configuration)' == 'Coyote'">
  <DefineConstants>$(DefineConstants);DAPR_ACTORS_NEXT_COYOTE</DefineConstants>
</PropertyGroup>

When the constant is set, CoyoteBridge.IsEnabled is true and the runtime’s scheduling flows through Coyote; otherwise the built-in scheduler is used. A test can assert which mode it is running under with CoyoteBridge.IsEnabled.

Run the suite under systematic exploration

For the deepest mode, where Coyote also controls in-handler async and locks, rewrite the compiled test assembly and run it under the Coyote tester. Tests run on the JIT, so this never conflicts with your AOT production build.

# Rewrite the test assembly so Coyote controls all concurrency
coyote rewrite ./bin/Debug/net10.0/MyActors.Tests.dll

# Systematically explore a test entry point for N iterations
coyote test ./bin/Debug/net10.0/MyActors.Tests.dll \
  --method CartActorTests.Concurrent_bids_never_lose_the_high_bid \
  --iterations 10000

You can also drive Coyote in-process from a test with Microsoft.Coyote.SystematicTesting.TestingEngine if you prefer to keep everything inside dotnet test.

Integration testing with Testcontainers

The in-memory runtime proves your actor logic. Integration tests prove the real protocol: the SubscribeActorEvents streams, serialization against a real sidecar, reminders through the real scheduler, and placement. Use the Dapr.Testcontainers package to stand these up.

An actor integration fixture is multi-container by necessity. Actors require the placement service, reminders require the scheduler service, and you need a state store in addition to the sidecar and the app under test.

public sealed class DaprActorFixture : IAsyncLifetime
{
    // Inspect the Dapr.Testcontainers API in your solution to compose:
    //   redis (state store) + placement + scheduler + daprd + the actor-hosting app,
    // on a shared network, with readiness gating.
    public async ValueTask InitializeAsync() { /* start containers */ }
    public async ValueTask DisposeAsync()    { /* stop containers */ }
}

public class CartActorIntegrationTests(DaprActorFixture fixture) : IClassFixture<DaprActorFixture>
{
    [Fact]
    public async Task Reminder_fires_through_the_real_scheduler()
    {
        var cart = ActorProxy.Create<ICartActor>(ActorId.Create("u1"), "CartActor");
        await cart.AddItem(new CartItem("sku-1", 1));
        // ... wait for the real scheduler to deliver the reminder, then assert
    }
}

Reserve integration tests for what only a real sidecar can prove, such as stream round-trip, real serialization, reminder and placement behavior, and end-to-end subscription delivery. Keep the bulk of your coverage in the fast in-memory tests.

Coverage

Because the hard paths are reachable in-memory, high coverage is practical. Keep the bulk of coverage in fast unit tests against ActorTestRuntime, with integration tests on top for the protocol. Exclude generated files and the source-generator and analyzer assemblies from the coverage denominator; generators are covered by snapshot and run tests, and analyzers by analyzer tests.

Best practices

  • Default to the in-memory runtime, and add Coyote only for the remaining deep cases.
  • Drive timers and reminders with rt.Time.Advance(...); never use real delays in tests.
  • When a scheduled exploration fails, capture the reported seed and add a pinned-seed regression test.
  • Test failure paths explicitly with rt.Faults, not just the happy path.
  • For state-machine actors, assert Analyze<T>().AssertNoStructuralDefects() as a test so structural regressions fail the build.
  • Keep integration tests few and focused on real-protocol behavior, and keep unit tests many and fast.

Next steps

4.9 - 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

4.9.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

4.9.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

4.9.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.9.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

4.9.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

4.9.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

5 - Dapr AI .NET SDK

Get up and running with the Dapr AI .NET SDK

With the Dapr AI package, you can interact with the Dapr AI workloads from a .NET application.

Today, Dapr provides the Conversational API to engage with large language models. To get started with this workload, walk through the Dapr Conversational AI how-to guide.

5.1 - Dapr AI Client

Learn how to create Dapr AI clients

The Dapr AI client package allows you to interact with the AI capabilities provided by the Dapr sidecar.

Lifetime management

A DaprConversationClient is a version of the Dapr client that is dedicated to interacting with the Dapr Conversation API. It can be registered alongside a DaprClient and other Dapr clients without issue.

It maintains access to networking resources in the form of TCP sockets used to communicate with the Dapr sidecar.

For best performance, create a single long-lived instance of DaprConversationClient and provide access to that shared instance throughout your application. DaprConversationClient instances are thread-safe and intended to be shared.

This can be aided by utilizing the dependency injection functionality. The registration method supports registration as a singleton, a scoped instance or as transient (meaning it’s recreated every time it’s injected), but also enables registration to utilize values from an IConfiguration or other injected services in a way that’s impractical when creating the client from scratch in each of your classes.

Avoid creating a DaprConversationClient for each operation.

Configuring DaprConversationClient via DaprConversationClientBuilder

A DaprConversationClient can be configured by invoking methods on the DaprConversationClientBuilder class before calling .Build() to create the client itself. The settings for each DaprConversationClient are separate and cannot be changed after calling .Build().

var daprConversationClient = new DaprConversationClientBuilder()
    .UseDaprApiToken("abc123") // Specify the API token used to authenticate to other Dapr sidecars
    .Build();

The DaprConversationClientBuilder contains settings for:

  • The HTTP endpoint of the Dapr sidecar
  • The gRPC endpoint of the Dapr sidecar
  • The JsonSerializerOptions object used to configure JSON serialization
  • The GrpcChannelOptions object used to configure gRPC
  • The API token used to authenticate requests to the sidecar
  • The factory method used to create the HttpClient instance used by the SDK
  • The timeout used for the HttpClient instance when making requests to the sidecar

The SDK will read the following environment variables to configure the default values:

  • DAPR_HTTP_ENDPOINT: used to find the HTTP endpoint of the Dapr sidecar, example: https://dapr-api.mycompany.com
  • DAPR_GRPC_ENDPOINT: used to find the gRPC endpoint of the Dapr sidecar, example: https://dapr-grpc-api.mycompany.com
  • DAPR_HTTP_PORT: if DAPR_HTTP_ENDPOINT is not set, this is used to find the HTTP local endpoint of the Dapr sidecar
  • DAPR_GRPC_PORT: if DAPR_GRPC_ENDPOINT is not set, this is used to find the gRPC local endpoint of the Dapr sidecar
  • DAPR_API_TOKEN: used to set the API token

Configuring gRPC channel options

Dapr’s use of CancellationToken for cancellation relies on the configuration of the gRPC channel options. If you need to configure these options yourself, make sure to enable the ThrowOperationCanceledOnCancellation setting.

var daprConversationClient = new DaprConversationClientBuilder()
    .UseGrpcChannelOptions(new GrpcChannelOptions { ... ThrowOperationCanceledOnCancellation = true })
    .Build();

Using cancellation with DaprConversationClient

The APIs on DaprConversationClient perform asynchronous operations and accept an optional CancellationToken parameter. This follows a standard .NET practice for cancellable operations. Note that when cancellation occurs, there is no guarantee that the remote endpoint stops processing the request, only that the client has stopped waiting for completion.

When an operation is cancelled, it will throw an OperationCancelledException.

Configuring DaprConversationClient via dependency injection

Using the built-in extension methods for registering the DaprConversationClient in a dependency injection container can provide the benefit of registering the long-lived service a single time, centralize complex configuration and improve performance by ensuring similarly long-lived resources are re-purposed when possible (e.g. HttpClient instances).

There are three overloads available to give the developer the greatest flexibility in configuring the client for their scenario. Each of these will register the IHttpClientFactory on your behalf if not already registered, and configure the DaprConversationClientBuilder to use it when creating the HttpClient instance in order to re-use the same instance as much as possible and avoid socket exhaustion and other issues.

In the first approach, there’s no configuration done by the developer and the DaprConversationClient is configured with the default settings.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDaprConversationClient(); //Registers the `DaprConversationClient` to be injected as needed
var app = builder.Build();

Sending a conversation request

Use ConversationInput and ConversationOptions to send prompts to your conversation component:

var inputs = new[]
{
    new ConversationInput(new IConversationMessage[]
    {
        new SystemMessage("You are a helpful assistant."),
        new UserMessage("Summarize the following text...")
    })
};

var options = new ConversationOptions("my-conversation-component")
{
    Temperature = 0.2
};

var response = await daprConversationClient.ConverseAsync(inputs, options);

Response format (JSON schema)

You can provide a JSON schema to coerce responses into a specific format using ConversationOptions.ResponseFormat. This feature requires Dapr runtime v1.17.0 or later.

using Google.Protobuf.WellKnownTypes;

var responseFormat = new Struct
{
    Fields =
    {
        ["type"] = new Value { StringValue = "object" },
        ["properties"] = new Value
        {
            StructValue = new Struct
            {
                Fields =
                {
                    ["answer"] = new Value
                    {
                        StructValue = new Struct
                        {
                            Fields =
                            {
                                ["type"] = new Value { StringValue = "string" }
                            }
                        }
                    }
                }
            }
        },
        ["required"] = new Value
        {
            ListValue = new ListValue { Values = { new Value { StringValue = "answer" } } }
        }
    }
};

var options = new ConversationOptions("my-conversation-component")
{
    ResponseFormat = responseFormat
};

Prompt cache retention

If your conversation component supports prompt caching, you can request a cache retention window using ConversationOptions.PromptCacheRetention. This feature requires Dapr runtime v1.17.0 or later.

var options = new ConversationOptions("my-conversation-component")
{
    PromptCacheRetention = TimeSpan.FromMinutes(30)
};

Token usage statistics

The response includes optional token usage statistics when supported by the component and runtime. Usage data is available on ConversationResponseResult.Usage and includes both totals and detailed breakdowns:

var response = await daprConversationClient.ConverseAsync(inputs, options);
var result = response.Outputs[0];

if (result.Usage is not null)
{
    var totalTokens = result.Usage.TotalTokens;
    var promptCachedTokens = result.Usage.PromptTokensDetails?.CachedTokens;
    var reasoningTokens = result.Usage.CompletionTokensDetails?.ReasoningTokens;
}

Sometimes the developer will need to configure the created client using the various configuration options detailed above. This is done through an overload that passes in the DaprConversationClientBuiler and exposes methods for configuring the necessary options.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDaprConversationClient((_, daprConversationClientBuilder) => {
   //Set the API token
   daprConversationClientBuilder.UseDaprApiToken("abc123");
   //Specify a non-standard HTTP endpoint
   daprConversationClientBuilder.UseHttpEndpoint("http://dapr.my-company.com");
});

var app = builder.Build();

Finally, it’s possible that the developer may need to retrieve information from another service in order to populate these configuration values. That value may be provided from a DaprClient instance, a vendor-specific SDK or some local service, but as long as it’s also registered in DI, it can be injected into this configuration operation via the last overload:

var builder = WebApplication.CreateBuilder(args);

//Register a fictional service that retrieves secrets from somewhere
builder.Services.AddSingleton<SecretService>();

builder.Services.AddDaprConversationClient((serviceProvider, daprConversationClientBuilder) => {
    //Retrieve an instance of the `SecretService` from the service provider
    var secretService = serviceProvider.GetRequiredService<SecretService>();
    var daprApiToken = secretService.GetSecret("DaprApiToken").Value;

    //Configure the `DaprConversationClientBuilder`
    daprConversationClientBuilder.UseDaprApiToken(daprApiToken);
});

var app = builder.Build();

5.2 - How to: Create and use Dapr AI Conversations in the .NET SDK

Learn how to create and use the Dapr Conversational AI client using the .NET SDK

Prerequisites

Installation

To get started with the Dapr AI .NET SDK client, install the Dapr.AI package from NuGet:

dotnet add package Dapr.AI

A DaprConversationClient maintains access to networking resources in the form of TCP sockets used to communicate with the Dapr sidecar.

Dependency Injection

The AddDaprAiConversation() method will register the Dapr client ASP.NET Core dependency injection and is the recommended approach for using this package. This method accepts an optional options delegate for configuring the DaprConversationClient and a ServiceLifetime argument, allowing you to specify a different lifetime for the registered services instead of the default Singleton value.

The following example assumes all default values are acceptable and is sufficient to register the DaprConversationClient:

services.AddDaprAiConversation();

The optional configuration delegate is used to configure the DaprConversationClient by specifying options on the DaprConversationClientBuilder as in the following example:

services.AddSingleton<DefaultOptionsProvider>();
services.AddDaprAiConversation((serviceProvider, clientBuilder) => {
     //Inject a service to source a value from
     var optionsProvider = serviceProvider.GetRequiredService<DefaultOptionsProvider>();
     var standardTimeout = optionsProvider.GetStandardTimeout();
     
     //Configure the value on the client builder
     clientBuilder.UseTimeout(standardTimeout);
});

Manual Instantiation

Rather than using dependency injection, a DaprConversationClient can also be built using the static client builder.

For best performance, create a single long-lived instance of DaprConversationClient and provide access to that shared instance throughout your application. DaprConversationClient instances are thread-safe and intended to be shared.

Avoid creating a DaprConversationClient per-operation.

A DaprConversationClient can be configured by invoking methods on the DaprConversationClientBuilder class before calling .Build() to create the client. The settings for each DaprConversationClient are separate and cannot be changed after calling .Build().

var daprConversationClient = new DaprConversationClientBuilder()
    .UseJsonSerializerSettings( ... ) //Configure JSON serializer
    .Build();

See the .NET documentation here for more information about the options available when configuring the Dapr client via the builder.

Try it out

Put the Dapr AI .NET SDK to the test. Walk through the samples to see Dapr in action:

SDK SamplesDescription
SDK samplesClone the SDK repo to try out some examples and get started.

Building Blocks

This part of the .NET SDK allows you to interface with the Conversations API to send and receive messages from large language models.

5.3 - How to: Using Microsoft's AI extensions with Dapr's .NET Conversation SDK

Learn how to create and use Dapr with Microsoft’s AI extensions

Prerequisites

Installation

To get started with this SDK, install both the Dapr.AI and Dapr.AI.Microsoft.Extensions packages from NuGet:

dotnet add package Dapr.AI
dotnet add package Dapr.AI.Microsoft.Extensions

The DaprChatClient is a Dapr-based implementation of the IChatClient interface provided in the Microsoft.Extensions.AI.Abstractions package using Dapr’s [conversation building block]({{ ref conversation-overview.md }}). It allows developers to build against the types provided by Microsoft’s abstraction while providing the greatest conformity to the Dapr conversation building block available. As both approaches adopt OpenAI’s API approach, these are expected to increasingly converge over time.

About Microsoft.Extensions.AI

The Dapr.AI.Microsoft.Extensions package implements the Microsoft.Extensions.AI abstractions, providing a unified API for AI services in .NET applications. Microsoft.Extensions.AI is designed to offer a consistent programming model across different AI providers and scenarios. For detailed information about Microsoft.Extensions.AI, refer to the official documentation.

Service Registration

The DaprChatClient can be registered with the dependency injection container using several extension methods. First, ensure that you reigster the DaprConversationClient that’s part of the Dapr.AI package from NuGet:

services.AddDaprConversationClient();

Then register the DaprChatClient with your conversation component name:

services.AddDaprChatClient("my-conversation-component");

Configuration Options

You can confiugre the DaprChatClient using the DaprChatClientOptions though the current implementation only provides configuration for the component name itself. This is expected to change in future releases.

services.AddDaprChatClient("my-conversation-component", options => 
{
   // Configure additional options here 
});

You can also configure the service lifetime (this defaults to ServiceLifetime.Scoped):

services.AddDaprChatClient("my-conversation-component", ServiceLifetime.Singleton);

Usage

Once registered, you can inject and use IChatClient in your services:

public class ChatService(IChatClient chatClient)
{
    public async Task<IReadOnlyList<string>> GetResponseAsync(string message)
    {
        var response = await chatClient.GetResponseAsync([
            new ChatMessage(ChatRole.User,
                "Please write me a poem in iambic pentameter about the joys of using Dapr to develop distributed applications with .NET")
            ]);
        
        return response.Messages.Select(msg => msg.Text).ToList();
    }
}

Streaming Conversations

The DaprChatClient does not yet support streaming responses and use of the corresponding GetStreamingResponseAsync methods will throw a NotImplemenetedException. This is expected to change in a future release once the Dapr runtime supports this functionality.

Tool Integration

The client supports function calling through the Microsoft.Extensions.AI tool integration. Tools registered with the conversation will be automatically available to the large language model.

string GetCurrentWeather() => Random.Shared.NextDouble() > 0.5 ? "It's sunny today!" : "It's raining today!";
var toolChatOptions = new ChatOptions { Tools = [AIFunctionFactory.Create(GetCurrentWeather, "weather")] };
var toolResponse = await chatClient.GetResponseAsync("What's the weather like today?", toolChatOptions);
foreach (var toolResp in toolResponse.Messages)
{
    Console.WriteLine(toolResp);
}

Error Handling

The DaprChatClient integrates with Dapr’s error handling and will throw appropriate exceptions when issues occur.

Configuration and Metadata

The underlying Dapr conversation component can be configured with metadata and parameters through the Dapr conversation building block configuration. The DaprChatClient will respect these settings when making calls to the conversation component.

Best Practices

  1. Service Lifetime: Use ServiceLifetime.Scoped or ServiceLifetime.Singleton for the DaprChatClient registration to avoid creating multiple instances unnecessarily.

  2. Error Handling: Always wrap calls in appropriate try-catch blocks to handle both Dapr-specific and general exceptions.

  3. Resource Management: The DaprChatClient properly implements IDisposable through its base classes, so resources are automatically managed when using dependency injection.

  4. Configuration: Configure your Dapr conversation component properly to ensure optimal performance and reliability.

6 - Dapr Jobs .NET SDK

Get up and running with Dapr Jobs and the Dapr .NET SDK

With the Dapr Job package, you can interact with the Dapr Job APIs from a .NET application to trigger future operations to run according to a predefined schedule with an optional payload.

To get started, walk through the Dapr Jobs how-to guide and refer to best practices documentation for additional guidance.

6.1 - How to: Author and manage Dapr Jobs in the .NET SDK

Learn how to author and manage Dapr Jobs using the .NET SDK

Let’s create an endpoint that will be invoked by Dapr Jobs when it triggers, then schedule the job in the same app. We’ll use the simple example provided here, for the following demonstration and walk through it as an explainer of how you can schedule one-time or recurring jobs using either an interval or Cron expression yourself. In this guide, you will:

  • Deploy a .NET Web API application (JobsSample)
  • Utilize the Dapr .NET Jobs SDK to schedule a job invocation and set up the endpoint to be triggered

In the .NET example project:

  • The main Program.cs file comprises the entirety of this demonstration.

Prerequisites

Set up the environment

Clone the .NET SDK repo.

git clone https://github.com/dapr/dotnet-sdk.git

From the .NET SDK root directory, navigate to the Dapr Jobs example.

cd examples/Jobs

Run the application locally

To run the Dapr application, you need to start the .NET program and a Dapr sidecar. Navigate to the JobsSample directory.

cd JobsSample

We’ll run a command that starts both the Dapr sidecar and the .NET program at the same time.

dapr run --app-id jobsapp --dapr-grpc-port 4001 --dapr-http-port 3500 -- dotnet run

Dapr listens for HTTP requests at http://localhost:3500 and internal Jobs gRPC requests at http://localhost:4001.

Register the Dapr Jobs client with dependency injection

The Dapr Jobs SDK provides an extension method to simplify the registration of the Dapr Jobs client. Before completing the dependency injection registration in Program.cs, add the following line:

var builder = WebApplication.CreateBuilder(args);

//Add anywhere between these two lines
builder.Services.AddDaprJobsClient();

var app = builder.Build();

Note that in today’s implementation of the Jobs API, the app that schedules the job will also be the app that receives the trigger notification. In other words, you cannot schedule a trigger to run in another application. As a result, while you don’t explicitly need the Dapr Jobs client to be registered in your application to schedule a trigger invocation endpoint, your endpoint will never be invoked without the same app also scheduling the job somehow (whether via this Dapr Jobs .NET SDK or an HTTP call to the sidecar).

It’s possible that you may want to provide some configuration options to the Dapr Jobs client that should be present with each call to the sidecar such as a Dapr API token, or you want to use a non-standard HTTP or gRPC endpoint. This is possible through use of an overload of the registration method that allows configuration of a DaprJobsClientBuilder instance:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDaprJobsClient((_, daprJobsClientBuilder) =>
{
    daprJobsClientBuilder.UseDaprApiToken("abc123");
    daprJobsClientBuilder.UseHttpEndpoint("http://localhost:8512"); //Non-standard sidecar HTTP endpoint
});

var app = builder.Build();

Still, it’s possible that whatever values you wish to inject need to be retrieved from some other source, itself registered as a dependency. There’s one more overload you can use to inject an IServiceProvider into the configuration action method. In the following example, we register a fictional singleton that can retrieve secrets from somewhere and pass it into the configuration method for AddDaprJobClient so we can retrieve our Dapr API token from somewhere else for registration here:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<SecretRetriever>();
builder.Services.AddDaprJobsClient((serviceProvider, daprJobsClientBuilder) =>
{
    var secretRetriever = serviceProvider.GetRequiredService<SecretRetriever>();
    var daprApiToken = secretRetriever.GetSecret("DaprApiToken").Value;
    daprJobsClientBuilder.UseDaprApiToken(daprApiToken);

    daprJobsClientBuilder.UseHttpEndpoint("http://localhost:8512");
});

var app = builder.Build();

Use the Dapr Jobs client using IConfiguration

It’s possible to configure the Dapr Jobs client using the values in your registered IConfiguration as well without explicitly specifying each of the value overrides using the DaprJobsClientBuilder as demonstrated in the previous section. Rather, by populating an IConfiguration made available through dependency injection the AddDaprJobsClient() registration will automatically use these values over their respective defaults.

Start by populating the values in your configuration. This can be done in several different ways as demonstrated below.

Configuration via ConfigurationBuilder

Application settings can be configured without using a configuration source and by instead populating the value in-memory using a ConfigurationBuilder instance:

var builder = WebApplication.CreateBuilder();

//Create the configuration
var configuration = new ConfigurationBuilder()
    .AddInMemoryCollection(new Dictionary<string, string> {
            { "DAPR_HTTP_ENDPOINT", "http://localhost:54321" },
            { "DAPR_API_TOKEN", "abc123" }
        })
    .Build();

builder.Configuration.AddConfiguration(configuration);
builder.Services.AddDaprJobsClient(); //This will automatically populate the HTTP endpoint and API token values from the IConfiguration

Configuration via Environment Variables

Application settings can be accessed from environment variables available to your application.

The following environment variables will be used to populate both the HTTP endpoint and API token used to register the Dapr Jobs client.

KeyValue
DAPR_HTTP_ENDPOINThttp://localhost:54321
DAPR_API_TOKENabc123
var builder = WebApplication.CreateBuilder();

builder.Configuration.AddEnvironmentVariables();
builder.Services.AddDaprJobsClient();

The Dapr Jobs client will be configured to use both the HTTP endpoint http://localhost:54321 and populate all outbound requests with the API token header abc123.

Configuration via prefixed Environment Variables

However, in shared-host scenarios where there are multiple applications all running on the same machine without using containers or in development environments, it’s not uncommon to prefix environment variables. The following example assumes that both the HTTP endpoint and the API token will be pulled from environment variables prefixed with the value “myapp_”. The two environment variables used in this scenario are as follows:

KeyValue
myapp_DAPR_HTTP_ENDPOINThttp://localhost:54321
myapp_DAPR_API_TOKENabc123

These environment variables will be loaded into the registered configuration in the following example and made available without the prefix attached.

var builder = WebApplication.CreateBuilder();

builder.Configuration.AddEnvironmentVariables(prefix: "myapp_");
builder.Services.AddDaprJobsClient();

The Dapr Jobs client will be configured to use both the HTTP endpoint http://localhost:54321 and populate all outbound requests with the API token header abc123.

Use the Dapr Jobs client without relying on dependency injection

While the use of dependency injection simplifies the use of complex types in .NET and makes it easier to deal with complicated configurations, you’re not required to register the DaprJobsClient in this way. Rather, you can also elect to create an instance of it from a DaprJobsClientBuilder instance as demonstrated below:


public class MySampleClass
{
    public void DoSomething()
    {
        var daprJobsClientBuilder = new DaprJobsClientBuilder();
        var daprJobsClient = daprJobsClientBuilder.Build();

        //Do something with the `daprJobsClient`
    }
}

Set up a endpoint to be invoked when the job is triggered

It’s easy to set up a jobs endpoint if you’re at all familiar with minimal APIs in ASP.NET Core as the syntax is the same between the two.

Once dependency injection registration has been completed, configure the application the same way you would to handle mapping an HTTP request via the minimal API functionality in ASP.NET Core. Implemented as an extension method, pass the name of the job it should be responsive to and a delegate. Services can be injected into the delegate’s arguments as you wish and the job payload can be accessed from the ReadOnlyMemory<byte> originally provided to the job registration.

There are two delegates you can use here. One provides an IServiceProvider in case you need to inject other services into the handler:

//We have this from the example above
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDaprJobsClient();

var app = builder.Build();

//Add our endpoint registration
app.MapDaprScheduledJob("myJob", (IServiceProvider serviceProvider, string jobName, ReadOnlyMemory<byte> jobPayload) => {
    var logger = serviceProvider.GetService<ILogger>();
    logger?.LogInformation("Received trigger invocation for '{jobName}'", "myJob");

    //Do something...
});

app.Run();

The other overload of the delegate doesn’t require an IServiceProvider if not necessary:

//We have this from the example above
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDaprJobsClient();

var app = builder.Build();

//Add our endpoint registration
app.MapDaprScheduledJob("myJob", (string jobName, ReadOnlyMemory<byte> jobPayload) => {
    //Do something...
});

app.Run();

Support cancellation tokens when processing mapped invocations

You may want to ensure that timeouts are handled on job invocations so that they don’t indefinitely hang and use system resources. When setting up the job mapping, there’s an optional TimeSpan parameter that can be provided as the last argument to specify a timeout for the request. Every time the job mapping invocation is triggered, a new CancellationTokenSource will be created using this timeout parameter and a CancellationToken will be created from it to put an upper bound on the processing of the request. If a timeout isn’t provided, this defaults to CancellationToken.None and a timeout will not be automatically applied to the mapping.

//We have this from the example above
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDaprJobsClient();

var app = builder.Build();

//Add our endpoint registration
app.MapDaprScheduledJob("myJob", (string jobName, ReadOnlyMemory<byte> jobPayload) => {
    //Do something...
}, TimeSpan.FromSeconds(15)); //Assigns a maximum timeout of 15 seconds for handling the invocation request

app.Run();

Register the job

Finally, we have to register the job we want scheduled. Note that from here, all SDK methods have cancellation token support and use a default token if not otherwise set.

There are three different ways to set up a job that vary based on how you want to configure the schedule. The following shows the different arguments available when scheduling a job:

Argument NameTypeDescriptionRequired
jobNamestringThe name of the job being scheduled.Yes
scheduleDaprJobScheduleThe schedule defining when the job will be triggered.Yes
payloadReadOnlyMemoryJob data provided to the invocation endpoint when triggered.No
startingFromDateTimeThe point in time from which the job schedule should start.No
repeatsintThe maximum number of times the job should be triggered.No
ttlWhen the job should expires and no longer trigger.No
overwriteboolA flag indicating whether an existing job should be overwritten when submitted or false to require that an existing job with the same name be deleted first.No
cancellationTokenCancellationTokenUsed to cancel out of the operation early, e.g. because of an operation timeout.No

DaprJobSchedule

All jobs are scheduled via the SDK using the DaprJobSchedule which creates an expression passed to the runtime to schedule jobs. There are several static methods exposed on the DaprJobSchedule used to faciliate easy registration of each of the kinds of job schedules available as follows. This separates specifying the job schedule itself from any additional options like repeating the operation or providing a cancellation token.

One-time job

A one-time job is exactly that; it will run at a single point in time and will not repeat.

This approach requires that you select a job name and specify a time it should be triggered.

DaprJobSchedule.FromDateTime(DateTimeOffset scheduledTime)

One-time jobs can be scheduled from the Dapr Jobs client as in the following example:

public class MyOperation(DaprJobsClient daprJobsClient)
{
    public async Task ScheduleOneTimeJobAsync(CancellationToken cancellationToken)
    {
        var today = DateTimeOffset.UtcNow;
        var threeDaysFromNow = today.AddDays(3);

        var schedule = DaprJobSchedule.FromDateTime(threeDaysFromNow);
        await daprJobsClient.ScheduleJobAsync("job", schedule, cancellationToken: cancellationToken);
    }
}

Interval-based job

An interval-based job is one that runs on a recurring loop configured as a fixed amount of time, not unlike how reminders work in the Actors building block today.

DaprJobSchedule.FromDuration(TimeSpan interval)

Interval-based jobs can be scheduled from the Dapr Jobs client as in the following example:

public class MyOperation(DaprJobsClient daprJobsClient)
{

    public async Task ScheduleIntervalJobAsync(CancellationToken cancellationToken)
    {
        var hourlyInterval = TimeSpan.FromHours(1);

        //Trigger the job hourly, but a maximum of 5 times
        var schedule = DaprJobSchedule.FromDuration(hourlyInterval);
        await daprJobsClient.ScheduleJobAsync("job", schedule, repeats: 5, cancellationToken: cancellationToken);
    }
}

Cron-based job

A Cron-based job is scheduled using a Cron expression. This gives more calendar-based control over when the job is triggered as it can used calendar-based values in the expression.

DaprJobSchedule.FromCronExpression(string cronExpression)

There are two different approaches supported to scheduling a Cron-based job in the Dapr SDK.

Provide your own Cron expression

You can just provide your own Cron expression via a string via DaprJobSchedule.FromExpression():

public class MyOperation(DaprJobsClient daprJobsClient)
{
    public async Task ScheduleCronJobAsync(CancellationToken cancellationToken)
    {
        //At the top of every other hour on the fifth day of the month
        const string cronSchedule = "0 */2 5 * *";
        var schedule = DaprJobSchedule.FromExpression(cronSchedule);

        //Don't start this until next month
        var now = DateTime.UtcNow;
        var oneMonthFromNow = now.AddMonths(1);
        var firstOfNextMonth = new DateTime(oneMonthFromNow.Year, oneMonthFromNow.Month, 1, 0, 0, 0);

        await daprJobsClient.ScheduleJobAsync("myJobName", )
        await daprJobsClient.ScheduleCronJobAsync("myJobName", schedule, dueTime: firstOfNextMonth, cancellationToken: cancellationToken);
    }
}

Use the CronExpressionBuilder

Alternatively, you can use our fluent builder to produce a valid Cron expression:

public class MyOperation(DaprJobsClient daprJobsClient)
{
    public async Task ScheduleCronJobAsync(CancellationToken cancellationToken)
    {
        //At the top of every other hour on the fifth day of the month
        var cronExpression = new CronExpressionBuilder()
            .Every(EveryCronPeriod.Hour, 2)
            .On(OnCronPeriod.DayOfMonth, 5)
            .ToString();
        var schedule = DaprJobSchedule.FromExpression(cronExpression);

        //Don't start this until next month
        var now = DateTime.UtcNow;
        var oneMonthFromNow = now.AddMonths(1);
        var firstOfNextMonth = new DateTime(oneMonthFromNow.Year, oneMonthFromNow.Month, 1, 0, 0, 0);

        await daprJobsClient.ScheduleJobAsync("myJobName", )
        await daprJobsClient.ScheduleCronJobAsync("myJobName", schedule, dueTime: firstOfNextMonth, cancellationToken: cancellationToken);
    }
}

Get details of already-scheduled job

If you know the name of an already-scheduled job, you can retrieve its metadata without waiting for it to be triggered. The returned JobDetails exposes a few helpful properties for consuming the information from the Dapr Jobs API:

  • If the Schedule property contains a Cron expression, the IsCronExpression property will be true and the expression will also be available in the CronExpression property.
  • If the Schedule property contains a duration value, the IsIntervalExpression property will instead be true and the value will be converted to a TimeSpan value accessible from the Interval property.

This can be done by using the following:

public class MyOperation(DaprJobsClient daprJobsClient)
{
    public async Task<JobDetails> GetJobDetailsAsync(string jobName, CancellationToken cancellationToken)
    {
        var jobDetails = await daprJobsClient.GetJobAsync(jobName, canecllationToken);
        return jobDetails;
    }
}

Delete a scheduled job

To delete a scheduled job, you’ll need to know its name. From there, it’s as simple as calling the DeleteJobAsync method on the Dapr Jobs client:

public class MyOperation(DaprJobsClient daprJobsClient)
{
    public async Task DeleteJobAsync(string jobName, CancellationToken cancellationToken)
    {
        await daprJobsClient.DeleteJobAsync(jobName, cancellationToken);
    }
}

6.2 - DaprJobsClient usage

Essential tips and advice for using DaprJobsClient

Lifetime management

A DaprJobsClient is a version of the Dapr client that is dedicated to interacting with the Dapr Jobs API. It can be registered alongside a DaprClient and other Dapr clients without issue.

It maintains access to networking resources in the form of TCP sockets used to communicate with the Dapr sidecar and implements IDisposable to support the eager cleanup of resources.

For best performance, create a single long-lived instance of DaprJobsClient and provide access to that shared instance throughout your application. DaprJobsClient instances are thread-safe and intended to be shared.

This can be aided by utilizing the dependency injection functionality. The registration method supports registration using as a singleton, a scoped instance or as transient (meaning it’s recreated every time it’s injected), but also enables registration to utilize values from an IConfiguration or other injected service in a way that’s impractical when creating the client from scratch in each of your classes.

Avoid creating a DaprJobsClient for each operation and disposing it when the operation is complete.

Configuring DaprJobsClient via the DaprJobsClientBuilder

A DaprJobsClient can be configured by invoking methods on the DaprJobsClientBuilder class before calling .Build() to create the client itself. The settings for each DaprJobsClient are separate and cannot be changed after calling .Build().

var daprJobsClient = new DaprJobsClientBuilder()
    .UseDaprApiToken("abc123") // Specify the API token used to authenticate to other Dapr sidecars
    .Build();

The DaprJobsClientBuilder contains settings for:

  • The HTTP endpoint of the Dapr sidecar
  • The gRPC endpoint of the Dapr sidecar
  • The JsonSerializerOptions object used to configure JSON serialization
  • The GrpcChannelOptions object used to configure gRPC
  • The API token used to authenticate requests to the sidecar
  • The factory method used to create the HttpClient instance used by the SDK
  • The timeout used for the HttpClient instance when making requests to the sidecar

The SDK will read the following environment variables to configure the default values:

  • DAPR_HTTP_ENDPOINT: used to find the HTTP endpoint of the Dapr sidecar, example: https://dapr-api.mycompany.com
  • DAPR_GRPC_ENDPOINT: used to find the gRPC endpoint of the Dapr sidecar, example: https://dapr-grpc-api.mycompany.com
  • DAPR_HTTP_PORT: if DAPR_HTTP_ENDPOINT is not set, this is used to find the HTTP local endpoint of the Dapr sidecar
  • DAPR_GRPC_PORT: if DAPR_GRPC_ENDPOINT is not set, this is used to find the gRPC local endpoint of the Dapr sidecar
  • DAPR_API_TOKEN: used to set the API token

Configuring gRPC channel options

Dapr’s use of CancellationToken for cancellation relies on the configuration of the gRPC channel options. If you need to configure these options yourself, make sure to enable the ThrowOperationCanceledOnCancellation setting.

var daprJobsClient = new DaprJobsClientBuilder()
    .UseGrpcChannelOptions(new GrpcChannelOptions { ... ThrowOperationCanceledOnCancellation = true })
    .Build();

Using cancellation with DaprJobsClient

The APIs on DaprJobsClient perform asynchronous operations and accept an optional CancellationToken parameter. This follows a standard .NET practice for cancellable operations. Note that when cancellation occurs, there is no guarantee that the remote endpoint stops processing the request, only that the client has stopped waiting for completion.

When an operation is cancelled, it will throw an OperationCancelledException.

Configuring DaprJobsClient via dependency injection

Using the built-in extension methods for registering the DaprJobsClient in a dependency injection container can provide the benefit of registering the long-lived service a single time, centralize complex configuration and improve performance by ensuring similarly long-lived resources are re-purposed when possible (e.g. HttpClient instances).

There are three overloads available to give the developer the greatest flexibility in configuring the client for their scenario. Each of these will register the IHttpClientFactory on your behalf if not already registered, and configure the DaprJobsClientBuilder to use it when creating the HttpClient instance in order to re-use the same instance as much as possible and avoid socket exhaustion and other issues.

In the first approach, there’s no configuration done by the developer and the DaprJobsClient is configured with the default settings.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDaprJobsClient(); //Registers the `DaprJobsClient` to be injected as needed
var app = builder.Build();

Sometimes the developer will need to configure the created client using the various configuration options detailed above. This is done through an overload that passes in the DaprJobsClientBuiler and exposes methods for configuring the necessary options.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDaprJobsClient((_, daprJobsClientBuilder) => {
   //Set the API token
   daprJobsClientBuilder.UseDaprApiToken("abc123");
   //Specify a non-standard HTTP endpoint
   daprJobsClientBuilder.UseHttpEndpoint("http://dapr.my-company.com");
});

var app = builder.Build();

Finally, it’s possible that the developer may need to retrieve information from another service in order to populate these configuration values. That value may be provided from a DaprClient instance, a vendor-specific SDK or some local service, but as long as it’s also registered in DI, it can be injected into this configuration operation via the last overload:

var builder = WebApplication.CreateBuilder(args);

//Register a fictional service that retrieves secrets from somewhere
builder.Services.AddSingleton<SecretService>();

builder.Services.AddDaprJobsClient((serviceProvider, daprJobsClientBuilder) => {
    //Retrieve an instance of the `SecretService` from the service provider
    var secretService = serviceProvider.GetRequiredService<SecretService>();
    var daprApiToken = secretService.GetSecret("DaprApiToken").Value;

    //Configure the `DaprJobsClientBuilder`
    daprJobsClientBuilder.UseDaprApiToken(daprApiToken);
});

var app = builder.Build();

Understanding payload serialization on DaprJobsClient

While there are many methods on the DaprClient that automatically serialize and deserialize data using the System.Text.Json serializer, this SDK takes a different philosophy. Instead, the relevant methods accept an optional payload of ReadOnlyMemory<byte> meaning that serialization is an exercise left to the developer and is not generally handled by the SDK.

That said, there are some helper extension methods available for each of the scheduling methods. If you know that you want to use a type that’s JSON-serializable, you can use the Schedule*WithPayloadAsync method for each scheduling type that accepts an object as a payload and an optional JsonSerializerOptions to use when serializing the value. This will convert the value to UTF-8 encoded bytes for you as a convenience. Here’s an example of what this might look like when scheduling a Cron expression:

public sealed record Doodad (string Name, int Value);

//...
var doodad = new Doodad("Thing", 100);
await daprJobsClient.ScheduleCronJobWithPayloadAsync("myJob", "5 * * * *", doodad);

In the same vein, if you have a plain string value, you can use an overload of the same method to serialize a string-typed payload and the JSON serialization step will be skipped and it’ll only be encoded to an array of UTF-8 encoded bytes. Here’s an example of what this might look like when scheduling a one-time job:

var now = DateTime.UtcNow;
var oneWeekFromNow = now.AddDays(7);
await daprJobsClient.ScheduleOneTimeJobWithPayloadAsync("myOtherJob", oneWeekFromNow, "This is a test!");

The delegate handling the job invocation expects at least two arguments to be present:

  • A string that is populated with the jobName, providing the name of the invoked job
  • A ReadOnlyMemory<byte> that is populated with the bytes originally provided during the job registration.

Because the payload is stored as a ReadOnlyMemory<byte>, the developer has the freedom to serialize and deserialize as they wish, but there are again two helper extensions included that can deserialize this to either a JSON-compatible type or a string. Both methods assume that the developer encoded the originally scheduled job (perhaps using the helper serialization methods) as these methods will not force the bytes to represent something they’re not.

To deserialize the bytes to a string, the following helper method can be used:

var payloadAsString = Encoding.UTF8.GetString(jobPayload.Span); //If successful, returns a string with the value

Error handling

Methods on DaprJobsClient will throw a DaprJobsServiceException if an issue is encountered between the SDK and the Jobs API service running on the Dapr sidecar. If a failure is encountered because of a poorly formatted request made to the Jobs API service through this SDK, a DaprMalformedJobException will be thrown. In case of illegal argument values, the appropriate standard exception will be thrown (e.g. ArgumentOutOfRangeException or ArgumentNullException) with the name of the offending argument. And for anything else, a DaprException will be thrown.

The most common cases of failure will be related to:

  • Incorrect argument formatting while engaging with the Jobs API
  • Transient failures such as a networking problem
  • Invalid data, such as a failure to deserialize a value into a type it wasn’t originally serialized from

In any of these cases, you can examine more exception details through the .InnerException property.

7 - Dapr Cryptography .NET SDK

Get up and running with the Dapr Cryptography .NET SDK

With the Dapr Cryptography package, you can perform high-performance encryption and decryption operations with Dapr.

To get started with this functionality, walk through the [Dapr Cryptography(https://docs.dapr.io/developing-applications/sdks/dotnet/dotnet-cryptography/dotnet-cryptography-howto/) how-to guide.

7.1 - Dapr Cryptography Client

Learn how to create Dapr Crytography clients

The Dapr Cryptography package allows you to perform encryption and decryption operations provided by the Dapr sidecar.

Lifetime management

A DaprEncryptionClient is a version of the Dapr client that is dedicated to interacting with the Dapr Cryptography API. It can be registered alongside a DaprClient and other Dapr clients without issue.

It maintains access to networking resources in the form of TCP sockets used to communicate with the Dapr sidecar.

For best performance, create a single long-lived instance of DaprEncryptionClient and provide access to that shared instance throughout your application. DaprEncryptionClient instances are thread-safe and intended to be shared.

This can be aided by utilizing the dependency injection functionality. The registration method supports registration as a singleton, a scoped instance, or as a transient (meaning it’s recreated every time it’s injected), but also enables registration to utilize values from an IConfiguration or other injected service in a way that’s impractical when creating the client from scratch in each of your classes.

Avoid creating a DaprEncryptionClient for each operation.

Configuring DaprEncryptionClient via DaprEncryptionClientBuilder

A DaprCryptographyClient can be configured by invoking methods on the DaprEncryptionClientBuilder class before calling .Build() to create the client itself. The settings for each DaprEncryptionClientBuilder are separate can cannot be changed after calling .Build().

var daprEncryptionClient = new DaprEncryptionClientBuilder()
    .UseDaprApiToken("abc123") //Specify the API token used to authenticate to the Dapr sidecar
    .Build();

The DaprEncryptionClientBuilder contains settings for:

  • The HTTP endpoint of the Dapr sidecar
  • The gRPC endpoint of the Dapr sidecar
  • The JsonSerializerOptions object used to configure JSON serialization
  • The GrpcChannelOptions object used to configure gRPC
  • The API token used to authenticate requests to the sidecar
  • The factory method used to create the HttpClient instance used by the SDK
  • The timeout used for the HttpClient instance when making requests to the sidecar

The SDK will read the following environment variables to configure the default values:

  • DAPR_HTTP_ENDPOINT: used to find the HTTP endpoint of the Dapr sidecar, example: https://dapr-api.mycompany.com
  • DAPR_GRPC_ENDPOINT: used to find the gRPC endpoint of the Dapr sidecar, example: https://dapr-grpc-api.mycompany.com
  • DAPR_HTTP_PORT: if DAPR_HTTP_ENDPOINT is not set, this is used to find the HTTP local endpoint of the Dapr sidecar
  • DAPR_GRPC_PORT: if DAPR_GRPC_ENDPOINT is not set, this is used to find the gRPC local endpoint of the Dapr sidecar
  • DAPR_API_TOKEN: used to set the API token

Configuring gRPC channel options

Dapr’s use of CancellationToken for cancellation relies on the configuration of the gRPC channel options. If you need to configure these options yourself, make sure to enable the ThrowOperationCanceledOnCancellation setting.

var daprEncryptionClient = new DaprEncryptionClientBuilder()
    .UseGrpcChannelOptions(new GrpcChannelOptions { .. ThrowOperationCanceledOnCancellation = true })
    .Build();

Using cancellation with DaprEncryptionClient

The APIs on DaprEncryptionClient perform asynchronous operations and accept an optional CancellationToken parameter. This follows a standard .NET practice for cancellable operations. Note that when cancellation occurs, there is no guarantee that the remote endpoint stops processing the request, only that the client has stopped waiting for completion.

When an operation is cancelled, it will throw an OperationCancelledException.

Configuring DaprEncryptionClient via dependency injection

Using the built-in extension methods for registering the DaprEncryptionClient in a dependency injection container can provide the benefit of registering the long-lived service a single time, centralize complex configuration and improve performance by ensuring similarly long-lived resources are re-purposed when possible (e.g. HttpClient instances).

There are three overloads available to give the developer the greatest flexibility in configuring the client for their scenario. Each of these will register the IHttpClientFactory on your behalf if not already registered, and configure the DaprEncryptionClientBuilder to use it when creating the HttpClient instance in order to re-use the same instance as much as possible and avoid socket exhaustion and other issues.

In the first approach, there’s no configuration done by the developer and the DaprEncryptionClient is configured with the default settings.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDaprEncryptionClent(); //Registers the `DaprEncryptionClient` to be injected as needed
var app = builder.Build();

Sometimes the developer will need to configure the created client using the various configuration options detailed above. This is done through an overload that passes in the DaprEncryptionClientBuiler and exposes methods for configuring the necessary options.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDaprEncryptionClient((_, daprEncrpyptionClientBuilder) => {
   //Set the API token
   daprEncryptionClientBuilder.UseDaprApiToken("abc123");
   //Specify a non-standard HTTP endpoint
   daprEncryptionClientBuilder.UseHttpEndpoint("http://dapr.my-company.com");
});

var app = builder.Build();

Finally, it’s possible that the developer may need to retrieve information from another service in order to populate these configuration values. That value may be provided from a DaprClient instance, a vendor-specific SDK or some local service, but as long as it’s also registered in DI, it can be injected into this configuration operation via the last overload:

var builder = WebApplication.CreateBuilder(args);

//Register a fictional service that retrieves secrets from somewhere
builder.Services.AddSingleton<SecretService>();

builder.Services.AddDaprEncryptionClient((serviceProvider, daprEncryptionClientBuilder) => {
    //Retrieve an instance of the `SecretService` from the service provider
    var secretService = serviceProvider.GetRequiredService<SecretService>();
    var daprApiToken = secretService.GetSecret("DaprApiToken").Value;

    //Configure the `DaprEncryptionClientBuilder`
    daprEncryptionClientBuilder.UseDaprApiToken(daprApiToken);
});

var app = builder.Build();

7.2 - How to: Create and use Dapr Cryptography in the .NET SDK

Learn how to create and use the Dapr Cryptography client using the .NET SDK

Prerequisites

Installation

To get started with the Dapr Cryptography client, install the Dapr.Cryptography package from NuGet:

dotnet add package Dapr.Cryptography

A DaprEncryptionClient maintains access to networking resources in the form of TCP sockets used to communicate with the Dapr sidecar.

Dependency Injection

The AddDaprEncryptionClient() method will register the Dapr client with dependency injection and is the recommended approach for using this package. This method accepts an optional options delegate for configuring the DaprEncryptionClient and a ServiceLifetime argument, allowing you to specify a different lifetime for the registered services instead of the default Singleton value.

The following example assumes all default values are acceptable and is sufficient to register the DaprEncryptionClient:

services.AddDaprEncryptionClient();

The optional configuration delegate is used to configure the DaprEncryptionClient by specifying options on the DaprEncryptionClientBuilder as in the following example:

services.AddSingleton<DefaultOptionsProvider>();
services.AddDaprEncryptionClient((serviceProvider, clientBuilder) => {
     //Inject a service to source a value from
     var optionsProvider = serviceProvider.GetRequiredService<DefaultOptionsProvider>();
     var standardTimeout = optionsProvider.GetStandardTimeout();
     
     //Configure the value on the client builder
     clientBuilder.UseTimeout(standardTimeout);
});

Manual Instantiation

Rather than using dependency injection, a DaprEncryptionClient can also be built using the static client builder.

For best performance, create a single long-lived instance of DaprEncryptionClient and provide access to that shared instance throughout your application. DaprEncryptionClient instances are thread-safe and intended to be shared.

Avoid creating a DaprEncryptionClient per-operation.

A DaprEncryptionClient can be configured by invoking methods on the DaprEncryptionClientBuilder class before calling .Build() to create the client. The settings for each DaprEncryptionClient are separate and cannot be changed after calling .Build().

var daprEncryptionClient = new DaprEncryptionClientBuilder()
    .UseJsonSerializerSettings( ... ) //Configure JSON serializer
    .Build();

See the .NET documentation here for more information about the options available when configuring the Dapr client via the builder.

Try it out

Put the Dapr AI .NET SDK to the test. Walk through the samples to see Dapr in action:

SDK SamplesDescription
SDK samplesClone the SDK repo to try out some examples and get started.

8 - Dapr Secrets Management .NET SDK

Get up and running with Dapr Secrets Management .NET SDK

With the Dapr Secrets Management package, you can interact with the Dapr Secrets API from a .NET application to retrieve individual or bulk secrets from configured secret store components. The package also includes a source generator that provides strongly-typed access to your secrets via dependency injection.

To get started, walk through the Dapr Secrets Management how-to guide and refer to best practices documentation for additional guidance.

8.1 - How to: Manage secrets with the Dapr Secrets Management .NET SDK

Learn how to retrieve and manage secrets using the Dapr Secrets Management .NET SDK

Let’s walk through how to retrieve secrets from a Dapr secret store using the Dapr.SecretsManagement package. We’ll use the sample project provided here for the following demonstration, covering direct secret retrieval, bulk secret retrieval, and strongly-typed access via the included source generator. In this guide, you will:

  • Deploy a .NET Web API application (SecretManagementSample)
  • Utilize the Dapr .NET Secrets Management SDK to retrieve individual and bulk secrets
  • Use the source generator to create a strongly-typed interface for your secret store

In the .NET example project:

Prerequisites

Set up the environment

Clone the .NET SDK repo.

git clone https://github.com/dapr/dotnet-sdk.git

From the .NET SDK root directory, navigate to the Dapr Secrets Management example.

cd examples/SecretManagement

Run the application locally

To run the Dapr application, you need to start the .NET program and a Dapr sidecar. Navigate to the SecretManagementSample directory.

cd SecretManagementSample

We’ll run a command that starts both the Dapr sidecar and the .NET program at the same time.

dapr run --app-id secretsapp --app-port 6543 --dapr-grpc-port 4001 --dapr-http-port 3500 -- dotnet run

Dapr listens for HTTP requests at http://localhost:3500 and internal gRPC requests at http://localhost:4001.

Register the Dapr Secrets Management client with dependency injection

The Dapr Secrets Management SDK provides an extension method to simplify the registration of the client. Before completing the dependency injection registration in Program.cs, add the following line:

var builder = WebApplication.CreateBuilder(args);

//Add anywhere between these two lines
builder.Services.AddDaprSecretsManagementClient();

var app = builder.Build();

It’s possible that you may want to provide some configuration options to the client that should be present with each call to the sidecar, such as a Dapr API token or a non-standard HTTP or gRPC endpoint. This is possible through use of an overload of the registration method that allows configuration of a DaprSecretsManagementClientBuilder instance:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDaprSecretsManagementClient((_, daprSecretsClientBuilder) =>
{
    daprSecretsClientBuilder.UseDaprApiToken("abc123");
    daprSecretsClientBuilder.UseHttpEndpoint("http://localhost:8512"); //Non-standard sidecar HTTP endpoint
});

var app = builder.Build();

Still, it’s possible that whatever values you wish to inject need to be retrieved from some other source, itself registered as a dependency. There’s one more overload you can use to inject an IServiceProvider into the configuration action method. In the following example, we register a fictional singleton that can retrieve configuration values from somewhere and pass it into the configuration method for AddDaprSecretsManagementClient so we can retrieve our Dapr API token from somewhere else for registration here:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<SecretRetriever>();
builder.Services.AddDaprSecretsManagementClient((serviceProvider, daprSecretsClientBuilder) =>
{
    var secretRetriever = serviceProvider.GetRequiredService<SecretRetriever>();
    var daprApiToken = secretRetriever.GetSecret("DaprApiToken").Value;
    daprSecretsClientBuilder.UseDaprApiToken(daprApiToken);

    daprSecretsClientBuilder.UseHttpEndpoint("http://localhost:8512");
});

var app = builder.Build();

Use the Dapr Secrets Management client using IConfiguration

It’s possible to configure the Dapr Secrets Management client using the values in your registered IConfiguration as well without explicitly specifying each of the value overrides using the DaprSecretsManagementClientBuilder as demonstrated in the previous section. Rather, by populating an IConfiguration made available through dependency injection the AddDaprSecretsManagementClient() registration will automatically use these values over their respective defaults.

Start by populating the values in your configuration. This can be done in several different ways as demonstrated below.

Configuration via ConfigurationBuilder

Application settings can be configured without using a configuration source and by instead populating the value in-memory using a ConfigurationBuilder instance:

var builder = WebApplication.CreateBuilder();

//Create the configuration
var configuration = new ConfigurationBuilder()
    .AddInMemoryCollection(new Dictionary<string, string> {
            { "DAPR_HTTP_ENDPOINT", "http://localhost:54321" },
            { "DAPR_API_TOKEN", "abc123" }
        })
    .Build();

builder.Configuration.AddConfiguration(configuration);
builder.Services.AddDaprSecretsManagementClient(); //This will automatically populate the HTTP and gRPC endpoints and API token values from the IConfiguration

Configuration via Environment Variables

Application settings can be accessed from environment variables available to your application.

The following environment variables will be used to populate both the HTTP endpoint and API token used to register the Dapr Secrets Management client.

KeyValue
DAPR_HTTP_ENDPOINThttp://localhost:54321
DAPR_API_TOKENabc123
var builder = WebApplication.CreateBuilder();

builder.Configuration.AddEnvironmentVariables();
builder.Services.AddDaprSecretsManagementClient();

The Dapr Secrets Management client will be configured to use both the HTTP endpoint http://localhost:54321 and populate all outbound requests with the API token header abc123.

Retrieve a single secret

To retrieve a single secret from a Dapr secret store, use the GetSecretAsync method on the DaprSecretsManagementClient. The method accepts the name of the secret store component, the secret key, and an optional metadata dictionary.

app.MapGet("/secrets/{storeName}/{key}", async (
    string storeName,
    string key,
    DaprSecretsManagementClient secretsClient,
    CancellationToken cancellationToken) =>
{
    var secret = await secretsClient.GetSecretAsync(storeName, key, cancellationToken: cancellationToken);
    return Results.Ok(secret);
});

The result is a IReadOnlyDictionary<string, string>. Some secret stores (such as Kubernetes) can store multiple values per key — each entry in the dictionary represents one such value.

Retrieve bulk secrets

To retrieve all secrets that the application is allowed to access from a secret store, use the GetBulkSecretAsync method:

app.MapGet("/secrets/{storeName}", async (
    string storeName,
    DaprSecretsManagementClient secretsClient,
    CancellationToken cancellationToken) =>
{
    var secrets = await secretsClient.GetBulkSecretAsync(storeName, cancellationToken: cancellationToken);
    return Results.Ok(secrets);
});

The result is a nested IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>>. The outer key is the secret name; the inner dictionary contains one or more key-value pairs representing the secret data.

Passing metadata

Both GetSecretAsync and GetBulkSecretAsync accept an optional metadata parameter. This allows you to pass additional context to the secret store component. The valid metadata keys and values are determined by the type of secret store in use.

var metadata = new Dictionary<string, string>
{
    { "version", "2" }
};

var secret = await secretsClient.GetSecretAsync("my-vault", "db-password", metadata: metadata);

Use the source generator for strongly-typed secrets

The Dapr.SecretsManagement package includes an incremental source generator that produces strongly-typed access to your secret store via a simple interface definition. This eliminates the need to pass store names and key strings throughout your codebase.

Define a typed secret store interface

Create a partial interface decorated with the [SecretStore] attribute, specifying the Dapr secret store component name. Each property maps to a single secret key.

using Dapr.SecretsManagement.Abstractions;

[SecretStore("my-vault")]
public partial interface IMyVaultSecrets
{
    [Secret("db-connection-string")]
    string DatabaseConnection { get; }

    string ApiKey { get; } // Uses the property name "ApiKey" as the secret key
}

The [Secret] attribute is optional. When applied, it overrides the secret key to the specified value. When omitted, the property name is used as the secret key.

Register the typed secret store

The source generator produces a DI registration extension method named after your interface (e.g., AddMyVaultSecrets()). Chain it after AddDaprSecretsManagementClient():

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDaprSecretsManagementClient()
    .AddMyVaultSecrets();

var app = builder.Build();

How it works

The source generator emits:

  1. A concrete implementation of your interface that is backed by DaprSecretsManagementClient.GetBulkSecretAsync.
  2. An IHostedService that pre-loads all mapped secrets at application startup.
  3. A typed DI registration extension method on IDaprSecretsManagementBuilder.

Because secrets are loaded in bulk at startup, properties are synchronous and available immediately after host startup without requiring callers to manage async flows.

Consume the typed secrets

Once registered, inject your interface anywhere via standard dependency injection:

app.MapGet("/do-something", (IMyVaultSecrets secrets, IOtherSvc svc) =>
{
    var svcInstance = svc.Build(secrets.ApiKey);
    return svcInstnce.DoSomething();
});

Testing your application

Unit testing with the direct API

DaprSecretsManagementClient is an abstract class that provides a natural seam for mocking. You can mock it directly in your unit tests:

var mockClient = new Mock<DaprSecretsManagementClient>();
mockClient
    .Setup(c => c.GetSecretAsync("my-vault", "db-password", null, It.IsAny<CancellationToken>()))
    .ReturnsAsync(new Dictionary<string, string> { { "db-password", "s3cr3t" } });

Unit testing with the source generator

The generated implementation wraps a plain interface with get-only properties, so you can mock it directly:

var mockSecrets = new Mock<IMyVaultSecrets>();
mockSecrets.Setup(s => s.DatabaseConnection).Returns("Server=localhost;...");
mockSecrets.Setup(s => s.ApiKey).Returns("my-api-key");

Integration testing

For integration tests, register via AddDaprSecretsManagementClient() and configure the builder to point at a real or test sidecar. The generated IHostedService will pre-load secrets at startup using the real client, testing the full end-to-end flow.

8.2 - DaprSecretsManagementClient usage

Essential tips and advice for using DaprSecretsManagementClient

Lifetime management

A DaprSecretsManagementClient is a subset of the Dapr client that is dedicated to interacting with the Dapr Secrets API. It can be registered alongside a DaprClient and other Dapr clients without issue.

It maintains access to networking resources in the form of TCP sockets used to communicate with the Dapr sidecar and implements IDisposable to support the eager cleanup of resources.

For best performance, create a single long-lived instance of DaprSecretsManagementClient and provide access to that shared instance throughout your application. DaprSecretsManagementClient instances are thread-safe and intended to be shared.

This can be aided by utilizing the dependency injection functionality. The registration method supports registration as a singleton, a scoped instance or as transient (meaning it’s recreated every time it’s injected), but also enables registration to utilize values from an IConfiguration or other injected service in a way that’s impractical when creating the client from scratch in each of your classes.

Avoid creating a DaprSecretsManagementClient for each operation and disposing it when the operation is complete.

Configuring DaprSecretsManagementClient via the DaprSecretsManagementClientBuilder

A DaprSecretsManagementClient can be configured by invoking methods on the DaprSecretsManagementClientBuilder class before calling .Build() to create the client itself. The settings for each DaprSecretsManagementClient are separate and cannot be changed after calling .Build().

var daprSecretsClient = new DaprSecretsManagementClientBuilder()
    .UseDaprApiToken("abc123") // Specify the API token used to authenticate to other Dapr sidecars
    .Build();

The DaprSecretsManagementClientBuilder contains settings for:

  • The HTTP endpoint of the Dapr sidecar
  • The gRPC endpoint of the Dapr sidecar
  • The JsonSerializerOptions object used to configure JSON serialization
  • The GrpcChannelOptions object used to configure gRPC
  • The API token used to authenticate requests to the sidecar
  • The factory method used to create the HttpClient instance used by the SDK
  • The timeout used for the HttpClient instance when making requests to the sidecar

The SDK will read the following environment variables to configure the default values:

  • DAPR_HTTP_ENDPOINT: used to find the HTTP endpoint of the Dapr sidecar, example: https://dapr-api.mycompany.com
  • DAPR_GRPC_ENDPOINT: used to find the gRPC endpoint of the Dapr sidecar, example: https://dapr-grpc-api.mycompany.com
  • DAPR_HTTP_PORT: if DAPR_HTTP_ENDPOINT is not set, this is used to find the HTTP local endpoint of the Dapr sidecar
  • DAPR_GRPC_PORT: if DAPR_GRPC_ENDPOINT is not set, this is used to find the gRPC local endpoint of the Dapr sidecar
  • DAPR_API_TOKEN: used to set the API token

Configuring gRPC channel options

Dapr’s use of CancellationToken for cancellation relies on the configuration of the gRPC channel options. If you need to configure these options yourself, make sure to enable the ThrowOperationCanceledOnCancellation setting.

var daprSecretsClient = new DaprSecretsManagementClientBuilder()
    .UseGrpcChannelOptions(new GrpcChannelOptions { ... ThrowOperationCanceledOnCancellation = true })
    .Build();

Using cancellation with DaprSecretsManagementClient

The APIs on DaprSecretsManagementClient perform asynchronous operations and accept an optional CancellationToken parameter. This follows a standard .NET practice for cancellable operations. Note that when cancellation occurs, there is no guarantee that the remote endpoint stops processing the request, only that the client has stopped waiting for completion.

When an operation is cancelled, it will throw an OperationCancelledException.

Configuring DaprSecretsManagementClient via dependency injection

Using the built-in extension methods for registering the DaprSecretsManagementClient in a dependency injection container can provide the benefit of registering the long-lived service a single time, centralize complex configuration and improve performance by ensuring similarly long-lived resources are re-purposed when possible (e.g. HttpClient instances).

There are three overloads available to give the developer the greatest flexibility in configuring the client for their scenario. Each of these will register the IHttpClientFactory on your behalf if not already registered, and configure the DaprSecretsManagementClientBuilder to use it when creating the HttpClient instance in order to re-use the same instance as much as possible and avoid socket exhaustion and other issues.

In the first approach, there’s no configuration done by the developer and the DaprSecretsManagementClient is configured with the default settings.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDaprSecretsManagementClient(); //Registers the `DaprSecretsManagementClient` to be injected as needed
var app = builder.Build();

Sometimes you need to configure the created client using the various configuration options detailed above. This is done through an overload that passes in the DaprSecretsManagementClientBuilder and exposes methods for configuring the necessary options.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDaprSecretsManagementClient((_, daprSecretsClientBuilder) => {
   //Set the API token
   daprSecretsClientBuilder.UseDaprApiToken("abc123");
   //Specify a non-standard HTTP endpoint
   daprSecretsClientBuilder.UseHttpEndpoint("http://dapr.my-company.com");
});

var app = builder.Build();

Finally, it’s possible that the developer may need to retrieve information from another service in order to populate these configuration values. That value may be provided from a DaprClient instance, a vendor-specific SDK or some local service, but as long as it’s also registered in DI, it can be injected into this configuration operation via the last overload:

var builder = WebApplication.CreateBuilder(args);

//Register a fictional service that retrieves secrets from somewhere
builder.Services.AddSingleton<SecretService>();

builder.Services.AddDaprSecretsManagementClient((serviceProvider, daprSecretsClientBuilder) => {
    //Retrieve an instance of the `SecretService` from the service provider
    var secretService = serviceProvider.GetRequiredService<SecretService>();
    var daprApiToken = secretService.GetSecret("DaprApiToken").Value;

    //Configure the `DaprSecretsManagementClientBuilder`
    daprSecretsClientBuilder.UseDaprApiToken(daprApiToken);
});

var app = builder.Build();

Chaining the source generator registration

The AddDaprSecretsManagementClient() method returns an IDaprSecretsManagementBuilder that enables chaining of source-generator-produced registration methods. First, you need to create an appropriately decorated interface:

[SecretStore("my-vault")]
public partial interface IMyVaultSecrets
{
  /// <summary>
  /// The database connection string, retrieved from the "db-connection-string" secret key.
  /// </summary>
  [Secret("db-connection-string")]
  string DatabaseConnection { get; }

  /// <summary>
  /// The API key. Uses the property name "ApiKey" as the secret name.
  /// </summary>
  string ApiKey { get; }
}

Because this interface is decorated with the [SecretStore] attribute specifying the name of the secret store component it should use, the source generator emits an extension method you can chain directly (replacing the conventional “I” prefix with “Add” from your interface type name):

builder.Services.AddDaprSecretsManagementClient()
    .AddMyVaultSecrets()       // Generated from [SecretStore("my-vault")] on IMyVaultSecrets
    .AddPaymentSecrets();      // Generated from another typed interface

Each call registers the typed interface, its concrete implementation, and an IHostedService that pre-loads the secrets at startup.

From there, simply inject your IMyVaultSecrets into a type and the properties will be populated with the values from your secret store via Dapr.

Understanding the API response shapes

GetSecretAsync

GetSecretAsync returns an IReadOnlyDictionary<string, string>. Most secret stores return a single key-value pair, but some (like Kubernetes) can store multiple values per secret key. Each entry in the dictionary represents one such value.

GetBulkSecretAsync

GetBulkSecretAsync returns an IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>>. The outer key is the secret name; the inner dictionary contains one or more key-value pairs representing the secret data for that key.

Error handling

Methods on DaprSecretsManagementClient will throw a DaprException if an issue is encountered when communicating with the Dapr sidecar. In case of illegal argument values, the appropriate standard exception will be thrown (e.g. ArgumentNullException or ArgumentException) with the name of the offending argument. When an operation is canceled via a CancellationToken, an OperationCanceledException will be thrown.

The most common cases of failure will be related to:

  • Incorrect argument formatting (e.g. an empty store name or key)
  • Transient failures such as a networking problem
  • The specified secret store component not being configured or available

In any of these cases, you can examine more exception details through the .InnerException property.

9 - Dapr State Management .NET SDK

Get up and running with Dapr State Management .NET SDK

With the Dapr State Management package, you can interact with the Dapr State Management API from a .NET application to save, retrieve, delete, and query key/value state entries in configured state store components. The package supports single-key and bulk operations, optimistic concurrency control via ETags, state transactions, and state queries. It also includes a source generator that provides strongly-typed, store-scoped access via dependency injection.

To get started, walk through the Dapr State Management how-to guide and refer to best practices documentation for additional guidance.

9.1 - How to: Manage state with the Dapr State Management .NET SDK

Learn how to save, retrieve, delete, and query state using the Dapr State Management .NET SDK

Let’s walk through how to manage state using the Dapr.StateManagement package. We’ll use the sample project provided here for the following demonstration, covering typed state store clients via the source generator, direct client usage for bulk operations and transactions, and optimistic concurrency control with ETags. In this guide, you will:

  • Deploy a .NET application (StateManagementExample)
  • Utilize the Dapr .NET State Management SDK to save, retrieve, and delete state entries
  • Use ETags for optimistic concurrency control
  • Perform bulk operations and state transactions
  • Use the source generator to create a strongly-typed, store-scoped interface

In the .NET example project:

  • The main Program.cs contains both usage patterns (typed store and direct client).
  • The IWidgetStore.cs file demonstrates the typed state store interface.

Prerequisites

Set up the environment

Clone the .NET SDK repo.

git clone https://github.com/dapr/dotnet-sdk.git

From the .NET SDK root directory, navigate to the Dapr State Management example.

cd examples/StateManagement

Run the application locally

To run the Dapr application, you need to start the .NET program and a Dapr sidecar. Navigate to the StateManagementExample directory.

cd StateManagementExample

We’ll run a command that starts both the Dapr sidecar and the .NET program at the same time.

dapr run --app-id statemanagement-example --dapr-grpc-port 50001 -- dotnet run

Dapr listens for gRPC requests at http://localhost:50001.

Register the client

The DaprStateManagementClient is registered through the AddDaprStateManagementClient() extension method on IServiceCollection. This returns an IDaprStateManagementBuilder that can be used to further register source-generated typed state store clients.

Default registration

Register the client with default settings. The Dapr endpoint and API token will be read from environment variables or IConfiguration automatically.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDaprStateManagementClient();

Registration with custom configuration

If you need to configure the client (for example, to override the gRPC endpoint or JSON serializer options), pass a callback:

builder.Services.AddDaprStateManagementClient((sp, clientBuilder) =>
{
    clientBuilder.UseGrpcEndpoint("http://localhost:50001");
});

Registration with IServiceProvider

The configuration callback receives the IServiceProvider, allowing you to resolve other services:

builder.Services.AddDaprStateManagementClient((sp, clientBuilder) =>
{
    var config = sp.GetRequiredService<IConfiguration>();
    var endpoint = config["DaprGrpcEndpoint"];
    if (!string.IsNullOrEmpty(endpoint))
    {
        clientBuilder.UseGrpcEndpoint(endpoint);
    }
});

Configure the client from IConfiguration

The client builder inherits from DaprGenericClientBuilder<T>, which supports reading configuration values from IConfiguration. The following configuration keys are recognized:

KeyDescription
DAPR_GRPC_ENDPOINTThe gRPC endpoint for the Dapr sidecar
DAPR_HTTP_ENDPOINTThe HTTP endpoint for the Dapr sidecar
DAPR_API_TOKENThe API token for authenticating with the Dapr sidecar

These values can be provided via environment variables, appsettings.json, or any other IConfiguration source.

Using in-memory configuration:

builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
    ["DAPR_GRPC_ENDPOINT"] = "http://localhost:50001",
});

builder.Services.AddDaprStateManagementClient();

Using prefixed environment variables:

builder.Configuration.AddEnvironmentVariables(prefix: "MYAPP_");

// MYAPP_DAPR_GRPC_ENDPOINT will be read automatically
builder.Services.AddDaprStateManagementClient();

Save and retrieve state

Use GetStateAsync<TValue> to retrieve a single value and SaveStateAsync<TValue> to save one:

var client = app.Services.GetRequiredService<DaprStateManagementClient>();

// Save a widget
var widget = new Widget("medium", "blue");
await client.SaveStateAsync("statestore", "my-widget", widget);

// Retrieve it
var loaded = await client.GetStateAsync<Widget>("statestore", "my-widget");
Console.WriteLine($"Widget: {loaded?.Size} / {loaded?.Color}");

Delete state

await client.DeleteStateAsync("statestore", "my-widget");

Optimistic concurrency with ETags

Use GetStateAndETagAsync to retrieve a value along with its ETag, then use TrySaveStateAsync or TryDeleteStateAsync to conditionally write only if the value hasn’t changed:

// Read the value and its ETag
var (existingValue, etag) = await client.GetStateAndETagAsync<Widget>("statestore", "my-widget");

if (etag is not null)
{
    // Modify the value
    var updated = existingValue! with { Color = "green" };

    // Save only if nobody else has modified it since our read
    bool saved = await client.TrySaveStateAsync("statestore", "my-widget", updated, etag);
    Console.WriteLine(saved ? "ETag save succeeded." : "ETag mismatch — concurrent modification.");
}

And similar for deletes:

var (_, etag) = await client.GetStateAndETagAsync<Widget>("statestore", "my-widget");

if (etag is not null)
{
    bool deleted = await client.TryDeleteStateAsync("statestore", "my-widget", etag);
    Console.WriteLine(deleted ? "Deleted." : "ETag mismatch.");
}

Bulk operations

Save multiple entries

await client.SaveBulkStateAsync("statestore", new List<SaveStateItem<Widget>>
{
    new("widget-a", new Widget("small", "red")),
    new("widget-b", new Widget("large", "white")),
});

Retrieve multiple entries

var bulk = await client.GetBulkStateAsync<Widget>("statestore", new[] { "widget-a", "widget-b" });

foreach (var item in bulk)
{
    Console.WriteLine($"Key={item.Key}, Size={item.Value?.Size}, Color={item.Value?.Color}");
}

Delete multiple entries

await client.DeleteBulkStateAsync("statestore", new List<BulkDeleteStateItem>
{
    new("widget-a"),
    new("widget-b"),
});

State transactions

Execute multiple upsert and delete operations atomically within a single transaction. Not all state stores support transactions — check the Dapr documentation for your specific store.

await client.ExecuteStateTransactionAsync("statestore", new List<StateTransactionRequest>
{
    new("widget-a", null, StateOperationType.Delete),
    new("widget-b", null, StateOperationType.Delete),
});

Query state

If the underlying state store supports queries, you can use QueryStateAsync with a JSON query expression:

var query = """
{
    "filter": {
        "EQ": { "value.Color": "blue" }
    },
    "sort": [
        { "key": "value.Size", "order": "ASC" }
    ]
}
""";

var response = await client.QueryStateAsync<Widget>("statestore", query);

foreach (var item in response.Results)
{
    Console.WriteLine($"Key={item.Key}, Size={item.Data?.Size}, Color={item.Data?.Color}");
}

Query support depends on the state store component. Refer to the Dapr state store query API documentation for details. Do note that this functionality is not expected to receive any additional implementation support and is thus effectively deprecated.

Pass metadata to requests

Most state management operations accept an optional metadata parameter. Metadata is a dictionary of string key-value pairs whose valid keys and values depend on the state store component being used:

var metadata = new Dictionary<string, string>
{
    ["partitionKey"] = "widgets",
};

await client.SaveStateAsync("statestore", "my-widget", widget, metadata: metadata);
var loaded = await client.GetStateAsync<Widget>("statestore", "my-widget", metadata: metadata);

Use the source generator for typed state store access

For a cleaner, more maintainable, and testable approach, use the source generator to create a strongly-typed interface bound to a specific state store. This eliminates repeated store name strings throughout your code.

Step 1: Define the interface

Create a partial interface that extends IDaprStateStoreClient and annotate it with [StateStore("storeName")]:

using Dapr.StateManagement;

[StateStore("statestore")]
public partial interface IWidgetStore : IDaprStateStoreClient;

At compile time, a source generator produces:

  1. A sealed internal implementation class that forwards all IDaprStateStoreClient calls to DaprStateManagementClient with the store name "statestore" pre-filled.
  2. A DI registration extension method on IDaprStateManagementBuilder named WithWidgetStore() (the leading I is stripped from the interface name).

Step 2: Register the typed store

Chain the generated extension method onto AddDaprStateManagementClient():

builder.Services
    .AddDaprStateManagementClient()
    .WithWidgetStore();

Step 3: Inject and use

Inject IWidgetStore into your service. All methods mirror IDaprStateStoreClient but omit the storeName parameter:

public class WidgetService(IWidgetStore store)
{
    public async Task<Widget?> GetWidgetAsync(string id)
    {
        return await store.GetStateAsync<Widget>(id);
    }

    public async Task SaveWidgetAsync(string id, Widget widget)
    {
        await store.SaveStateAsync(id, widget);
    }

    public async Task<bool> UpdateWidgetAsync(string id, Widget widget)
    {
        var (_, etag) = await store.GetStateAndETagAsync<Widget>(id);
        if (etag is null)
            return false;

        return await store.TrySaveStateAsync(id, widget, etag);
    }
}

Registering multiple typed stores

You can define multiple interfaces for different Dapr state store components and register them all:

[StateStore("statestore")]
public partial interface IWidgetStore : IDaprStateStoreClient;

[StateStore("cachestore")]
public partial interface ICacheStore : IDaprStateStoreClient;
builder.Services
    .AddDaprStateManagementClient()
    .WithWidgetStore()
    .WithCacheStore();

Testing

Testing with the direct client

Because DaprStateManagementClient is an abstract class, you can mock it using any mocking framework:

var mockClient = new Mock<DaprStateManagementClient>();
mockClient
    .Setup(c => c.GetStateAsync<Widget>(
        "statestore", "my-widget", null, null, default))
    .ReturnsAsync(new Widget("medium", "blue"));

var service = new WidgetService(mockClient.Object);

Testing with typed store clients

Since typed stores implement IDaprStateStoreClient, mock the interface directly:

var mockStore = new Mock<IWidgetStore>();
mockStore
    .Setup(s => s.GetStateAsync<Widget>("my-widget", null, null, default))
    .ReturnsAsync(new Widget("medium", "blue"));

var service = new WidgetService(mockStore.Object);

9.2 - DaprStateManagementClient usage

Essential tips and advice for using DaprStateManagementClient

Lifetime management

A DaprStateManagementClient holds long-lived network resources (gRPC channels, HTTP connections). For best performance:

  • Register a single shared instance and reuse it for the lifetime of the application.
  • Avoid creating and disposing a client per operation — this can lead to socket exhaustion.
  • The DI registration via AddDaprStateManagementClient() defaults to ServiceLifetime.Singleton, which is the recommended approach for most applications.

The client is thread-safe and can be shared across concurrent requests.

Client builder configuration

DaprStateManagementClientBuilder inherits from DaprGenericClientBuilder<DaprStateManagementClient> and supports the same configuration surface as the other Dapr .NET client builders.

Builder methods

MethodDescription
UseGrpcEndpoint(string)Sets the gRPC endpoint for the Dapr sidecar.
UseHttpEndpoint(string)Sets the HTTP endpoint for the Dapr sidecar.
UseJsonSerializationOptions(JsonSerializerOptions)Configures custom JSON serializer options.
UseDaprApiToken(string)Sets the API token for authenticating with the Dapr sidecar.
UseGrpcChannelOptions(GrpcChannelOptions)Provides custom gRPC channel options.
UseTimeout(TimeSpan)Configures an HTTP request timeout.

Environment variables

The builder reads the following environment variables automatically:

VariableDescription
DAPR_GRPC_ENDPOINTgRPC endpoint address for the Dapr sidecar
DAPR_HTTP_ENDPOINTHTTP endpoint address for the Dapr sidecar
DAPR_API_TOKENAPI token for authenticating with the Dapr sidecar
DAPR_GRPC_PORTgRPC port (used as fallback if DAPR_GRPC_ENDPOINT is not set)
DAPR_HTTP_PORTHTTP port (used as fallback if DAPR_HTTP_ENDPOINT is not set)

Values explicitly set on the builder take precedence over environment variables.

gRPC channel options

For fine-grained control over gRPC behavior:

builder.Services.AddDaprStateManagementClient((sp, clientBuilder) =>
{
    clientBuilder.UseGrpcChannelOptions(new GrpcChannelOptions
    {
        MaxReceiveMessageSize = 16 * 1024 * 1024, // 16 MB
    });
});

Cancellation tokens

All asynchronous methods on DaprStateManagementClient and IDaprStateStoreClient accept a CancellationToken parameter. Passing a token allows you to cancel long-running operations in response to timeouts or user cancellation:

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));

var widget = await client.GetStateAsync<Widget>("statestore", "my-widget",
    cancellationToken: cts.Token);

When canceled, an OperationCanceledException is thrown.

Dependency injection registration

Registration overloads

The AddDaprStateManagementClient method has the following signature:

public static IDaprStateManagementBuilder AddDaprStateManagementClient(
    this IServiceCollection services,
    Action<IServiceProvider, DaprStateManagementClientBuilder>? configure = null,
    ServiceLifetime lifetime = ServiceLifetime.Singleton)

The returned IDaprStateManagementBuilder enables chaining typed state store registrations:

builder.Services
    .AddDaprStateManagementClient()
    .WithWidgetStore()
    .WithCacheStore();

Chaining source generator registrations

Each [StateStore]-annotated interface generates an extension method on IDaprStateManagementBuilder. The methods return IDaprStateManagementBuilder, so they chain naturally:

builder.Services
    .AddDaprStateManagementClient((sp, clientBuilder) =>
    {
        clientBuilder.UseGrpcEndpoint("http://localhost:50001");
    })
    .WithWidgetStore()
    .WithCacheStore()
    .WithUserPreferencesStore();

JSON serialization

By default, the client uses System.Text.Json with JsonSerializerDefaults.Web, which provides:

  • camelCase property naming (PropertyNamingPolicy = JsonNamingPolicy.CamelCase)
  • Case-insensitive property name matching (PropertyNameCaseInsensitive = true)
  • Strings read as numbers allowed (NumberHandling = JsonNumberHandling.AllowReadingFromString)

These defaults match the DaprClient package, ensuring serialization compatibility — data written by one client can be read correctly by the other.

To customize:

builder.Services.AddDaprStateManagementClient((sp, clientBuilder) =>
{
    clientBuilder.UseJsonSerializationOptions(new JsonSerializerOptions
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    });
});

Note: Changing serializer settings can break compatibility with data previously written using default settings. Test thoroughly when customizing.

API response shapes

GetStateAsync

GetStateAsync<TValue> returns TValue?. If the key does not exist in the store, default(T) is returned (i.e., null for reference types).

GetStateAndETagAsync

GetStateAndETagAsync<TValue> returns (TValue? Value, string? ETag). The ETag can be passed to TrySaveStateAsync or TryDeleteStateAsync for optimistic concurrency control.

GetBulkStateAsync

GetBulkStateAsync<TValue> returns IReadOnlyList<BulkStateItem<TValue>>. Each BulkStateItem<TValue> contains:

PropertyTypeDescription
KeystringThe state key
ValueTValue?The deserialized value, or null if the key was not found
ETagstringThe ETag for optimistic concurrency control

TrySaveStateAsync / TryDeleteStateAsync

Both return booltrue if the operation succeeded, false if the ETag did not match (indicating a concurrent modification).

QueryStateAsync

QueryStateAsync<TValue> returns StateQueryResponse<TValue>, which contains:

PropertyTypeDescription
ResultsIReadOnlyList<StateQueryItem<TValue>>The list of matching items
Tokenstring?Pagination token for continuing the query, or null if no more results
MetadataIReadOnlyDictionary<string, string>Additional metadata returned by the state store

Each StateQueryItem<TValue> contains:

PropertyTypeDescription
KeystringThe state key
DataTValue?The deserialized value
ETagstring?The ETag for the item
Errorstring?An error message if the item could not be retrieved, or null on success

StateOptions

The StateOptions class controls consistency and concurrency behavior for individual operations:

var options = new StateOptions
{
    Consistency = ConsistencyMode.Strong,
    Concurrency = ConcurrencyMode.FirstWrite,
};

await client.SaveStateAsync("statestore", "my-widget", widget, stateOptions: options);
PropertyValuesDescription
ConsistencyEventual, StrongControls whether reads reflect the latest write. null uses the store default.
ConcurrencyFirstWrite, LastWriteControls conflict resolution. FirstWrite fails on ETag mismatch; LastWrite always overwrites. null uses the store default.

Error handling

Methods on DaprStateManagementClient will throw a DaprException if an issue is encountered when communicating with the Dapr sidecar. In case of illegal argument values, the appropriate standard exception will be thrown (e.g. ArgumentNullException or ArgumentException) with the name of the offending argument. When an operation is canceled via a CancellationToken, an OperationCanceledException will be thrown.

The most common cases of failure will be related to:

  • Incorrect argument formatting (e.g. an empty store name or key)
  • Transient failures such as a networking problem
  • The specified state store component not being configured or available
  • ETag mismatches when using optimistic concurrency (for TrySaveStateAsync and TryDeleteStateAsync, these return false rather than throwing)

In any of these cases, you can examine more exception details through the .InnerException property.

Migration from DaprClient

If you are migrating from the DaprClient state management methods to the new Dapr.StateManagement package, note the following:

  • The API surface is very similar. Methods like GetStateAsync, SaveStateAsync, DeleteStateAsync, GetBulkStateAsync, and ExecuteStateTransactionAsync are all present with the same semantics.
  • The default JSON serialization settings (JsonSerializerDefaults.Web) are identical, so data written by DaprClient is fully compatible with DaprStateManagementClient and vice versa.
  • The new package additionally provides IDaprStateStoreClient and the [StateStore] source generator for strongly-typed, store-scoped access. This reduces boilerplate and eliminating store name strings at call sites.
  • DaprStateManagementClient is registered independently from DaprClient. Both can coexist in the same application during a gradual migration.

10 - Dapr Metadata .NET SDK

Get up and running with the Dapr Metadata .NET SDK

With the Dapr Metadata package, you can retrieve typed metadata from the Dapr runtime in a .NET application. The package integrates with the .NET options pattern so metadata can be injected through IOptions<DaprMetadata>, IOptionsSnapshot<DaprMetadata>, or IOptionsMonitor<DaprMetadata>.

To get started, walk through the Dapr Metadata how-to guide.

10.1 - How to: Retrieve Dapr runtime metadata with the .NET SDK

Learn how to retrieve Dapr runtime metadata using the Dapr Metadata .NET SDK

Let’s walk through how to retrieve Dapr runtime metadata using the Dapr.Metadata package. The package registers a hosted service that retrieves metadata from the Dapr sidecar during application startup, then exposes the typed DaprMetadata value through the .NET options pattern. In this guide, you will:

  • Install and register the Dapr Metadata .NET SDK
  • Inject metadata through IOptions<DaprMetadata>, IOptionsSnapshot<DaprMetadata>, or IOptionsMonitor<DaprMetadata>
  • Read runtime metadata, component metadata, subscriptions, actors, workflow, scheduler, and app connection details
  • Configure the Dapr HTTP endpoint and API token used to retrieve metadata

Prerequisites

Install the package

Install the Dapr Metadata package:

dotnet add package Dapr.Metadata

Register Dapr Metadata with dependency injection

Register the metadata service during application startup with AddDaprMetadata():

using Dapr.Metadata.Extensions;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDaprMetadata();

var app = builder.Build();

The registration adds the metadata options type and an internal hosted service that retrieves metadata from the Dapr runtime when the application starts.

Inject metadata with the options pattern

The Dapr.Metadata package exposes runtime metadata as DaprMetadata through the .NET options pattern. Choose the options interface that matches the lifetime of the service consuming the metadata.

Use IOptions<DaprMetadata>

Use IOptions<DaprMetadata> when a singleton-style view of the metadata is sufficient:

using Dapr.Metadata.Abstractions;
using Microsoft.Extensions.Options;

public sealed class MetadataReporter(IOptions<DaprMetadata> metadata)
{
    public void PrintRuntimeDetails()
    {
        var value = metadata.Value;

        Console.WriteLine($"App ID: {value.AppId}");
        Console.WriteLine($"Runtime version: {value.RuntimeVersion}");
    }
}

Use IOptionsSnapshot<DaprMetadata>

Use IOptionsSnapshot<DaprMetadata> in scoped services, such as request handlers or MVC controllers:

using Dapr.Metadata.Abstractions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;

[ApiController]
[Route("metadata")]
public sealed class MetadataController(IOptionsSnapshot<DaprMetadata> metadata) : ControllerBase
{
    [HttpGet("components")]
    public IActionResult GetComponents()
    {
        var components = metadata.Value.Components.Select(component => new
        {
            component.Name,
            component.Type,
            component.Version,
            component.Capabilities
        });

        return Ok(components);
    }
}

Use IOptionsMonitor<DaprMetadata>

Use IOptionsMonitor<DaprMetadata> when your application uses configuration hot reload, or when you want to ensure the consumer reads the latest metadata values in case they have changed since application startup. This is the preferred approach when freshness matters, with the tradeoff that retrieving the current value can introduce a brief delay while the metadata value is loaded:

using Dapr.Metadata.Abstractions;
using Microsoft.Extensions.Options;

public sealed class ComponentHealthReporter(IOptionsMonitor<DaprMetadata> metadata)
{
    public IReadOnlyCollection<string> GetLoadedComponentNames()
    {
        return metadata.CurrentValue.Components
            .Select(component => component.Name)
            .OfType<string>()
            .Where(name => !string.IsNullOrWhiteSpace(name))
            .ToArray();
    }
}

Read metadata values

The DaprMetadata type represents the response from the Dapr runtime metadata endpoint.

PropertyDescription
AppIdThe application ID registered with Dapr.
RuntimeVersionThe Dapr runtime version.
EnabledFeaturesNamed features enabled by Dapr configuration.
ActorsRegistered actor types and counts.
CustomAttributesCustom metadata attributes exposed by the runtime.
ComponentsLoaded component names, types, versions, and capabilities.
HttpEndpointsRegistered HTTP endpoint metadata.
SubscriptionsPub/sub subscription metadata, including rules and dead letter topics.
AppConnectionPropertiesApp port, protocol, channel address, max concurrency, and health probe settings.
SchedulerMetadataConnected scheduler host addresses.
WorkflowsWorkflow runtime metadata, including connected worker count.

For example, you can inspect subscriptions and app connection properties:

using Dapr.Metadata.Abstractions;
using Microsoft.Extensions.Options;

public sealed class RuntimeMetadataService(IOptions<DaprMetadata> metadata)
{
    public void PrintSubscriptions()
    {
        foreach (var subscription in metadata.Value.Subscriptions)
        {
            Console.WriteLine($"{subscription.PubSubName}: {subscription.Topic} ({subscription.Type})");

            foreach (var rule in subscription.Rules)
            {
                Console.WriteLine($"  {rule.Match} -> {rule.Path}");
            }
        }
    }

    public void PrintAppConnection()
    {
        var appConnection = metadata.Value.AppConnectionProperties;

        Console.WriteLine($"Protocol: {appConnection.Protocol}");
        Console.WriteLine($"Address: {appConnection.ChannelAddress}:{appConnection.Port}");
        Console.WriteLine($"Max concurrency: {appConnection.MaxConcurrency}");
        Console.WriteLine($"Health path: {appConnection.Health?.HealthCheckPath}");
    }
}

Configure the Dapr endpoint

The metadata service retrieves metadata from the Dapr HTTP endpoint. By default, it uses the same Dapr configuration values used by other .NET SDK clients:

KeyDescription
DAPR_HTTP_ENDPOINTThe HTTP endpoint for the Dapr sidecar.
DAPR_HTTP_PORTThe HTTP port for the local Dapr sidecar when DAPR_HTTP_ENDPOINT is not set.
DAPR_API_TOKENThe API token for authenticating with the Dapr sidecar.

These values can come from environment variables or any registered IConfiguration source.

Configuration via ConfigurationBuilder

using Dapr.Metadata.Extensions;

var builder = WebApplication.CreateBuilder(args);

builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
    ["DAPR_HTTP_ENDPOINT"] = "http://localhost:3500",
    ["DAPR_API_TOKEN"] = "abc123"
});

builder.Services.AddDaprMetadata();

Configuration via environment variables

Application settings can be accessed from environment variables available to your application.

KeyValue
DAPR_HTTP_ENDPOINThttp://localhost:3500
DAPR_API_TOKENabc123
using Dapr.Metadata.Extensions;

var builder = WebApplication.CreateBuilder(args);

builder.Configuration.AddEnvironmentVariables();
builder.Services.AddDaprMetadata();

Configuration via prefixed environment variables

In shared-host scenarios, you can source prefixed environment variables into IConfiguration and register metadata normally:

KeyValue
myapp_DAPR_HTTP_ENDPOINThttp://localhost:3500
myapp_DAPR_API_TOKENabc123
using Dapr.Metadata.Extensions;

var builder = WebApplication.CreateBuilder(args);

builder.Configuration.AddEnvironmentVariables(prefix: "myapp_");
builder.Services.AddDaprMetadata();

Run locally

Start the application with a Dapr sidecar. The sidecar HTTP port must match your configured endpoint or port:

dapr run --app-id metadata-example --dapr-http-port 3500 -- dotnet run

Dapr listens for HTTP requests at http://localhost:3500.

When the application starts, the metadata service retrieves metadata from the sidecar and makes the typed value available through the registered options interfaces.

11 - Dapr Messaging .NET SDK

Get up and running with the Dapr Messaging .NET SDK

With the Dapr Messaging package, you can interact with the Dapr messaging APIs from a .NET application. In the v1.15 release, this package only contains the functionality corresponding to the streaming PubSub capability

Future Dapr .NET SDK releases will migrate existing messaging capabilities out from Dapr.Client to this Dapr.Messaging package. This will be documented in the release notes, documentation and obsolete attributes in advance.

To get started, walk through the Dapr Messaging how-to guide and refer to best practices documentation for additional guidance.

11.1 - How to: Author and manage Dapr streaming subscriptions in the .NET SDK

Learn how to author and manage Dapr streaming subscriptions using the .NET SDK

Let’s create a subscription to a pub/sub topic or queue at using the streaming capability. We’ll use the simple example provided here, for the following demonstration and walk through it as an explainer of how you can configure message handlers at runtime and which do not require an endpoint to be pre-configured. In this guide, you will:

  • Deploy a .NET Web API application (StreamingSubscriptionExample)
  • Utilize the Dapr .NET Messaging SDK to subscribe dynamically to a pub/sub topic.

Prerequisites

Set up the environment

Clone the .NET SDK repo.

git clone https://github.com/dapr/dotnet-sdk.git

From the .NET SDK root directory, navigate to the Dapr streaming PubSub example.

cd examples/Client/PublishSubscribe

Run the application locally

To run the Dapr application, you need to start the .NET program and a Dapr sidecar. Navigate to the StreamingSubscriptionExample directory.

cd StreamingSubscriptionExample

We’ll run a command that starts both the Dapr sidecar and the .NET program at the same time.

dapr run --app-id pubsubapp --dapr-grpc-port 4001 --dapr-http-port 3500 -- dotnet run

Dapr listens for HTTP requests at http://localhost:3500 and internal Jobs gRPC requests at http://localhost:4001.

Register the Dapr PubSub client with dependency injection

The Dapr Messaging SDK provides an extension method to simplify the registration of the Dapr PubSub client. Before completing the dependency injection registration in Program.cs, add the following line:

var builder = WebApplication.CreateBuilder(args);

//Add anywhere between these two
builder.Services.AddDaprPubSubClient(); //That's it

var app = builder.Build();

It’s possible that you may want to provide some configuration options to the Dapr PubSub client that should be present with each call to the sidecar such as a Dapr API token, or you want to use a non-standard HTTP or gRPC endpoint. This be possible through use of an overload of the registration method that allows configuration of a DaprPublishSubscribeClientBuilder instance:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDaprPubSubClient((_, daprPubSubClientBuilder) => {
    daprPubSubClientBuilder.UseDaprApiToken("abc123");
    daprPubSubClientBuilder.UseHttpEndpoint("http://localhost:8512"); //Non-standard sidecar HTTP endpoint
});

var app = builder.Build();

Still, it’s possible that whatever values you wish to inject need to be retrieved from some other source, itself registered as a dependency. There’s one more overload you can use to inject an IServiceProvider into the configuration action method. In the following example, we register a fictional singleton that can retrieve secrets from somewhere and pass it into the configuration method for AddDaprJobClient so we can retrieve our Dapr API token from somewhere else for registration here:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<SecretRetriever>();
builder.Services.AddDaprPubSubClient((serviceProvider, daprPubSubClientBuilder) => {
    var secretRetriever = serviceProvider.GetRequiredService<SecretRetriever>();
    var daprApiToken = secretRetriever.GetSecret("DaprApiToken").Value;
    daprPubSubClientBuilder.UseDaprApiToken(daprApiToken);
    
    daprPubSubClientBuilder.UseHttpEndpoint("http://localhost:8512");
});

var app = builder.Build();

Use the Dapr PubSub client using IConfiguration

It’s possible to configure the Dapr PubSub client using the values in your registered IConfiguration as well without explicitly specifying each of the value overrides using the DaprPublishSubscribeClientBuilder as demonstrated in the previous section. Rather, by populating an IConfiguration made available through dependency injection the AddDaprPubSubClient() registration will automatically use these values over their respective defaults.

Start by populating the values in your configuration. This can be done in several different ways as demonstrated below.

Configuration via ConfigurationBuilder

Application settings can be configured without using a configuration source and by instead populating the value in-memory using a ConfigurationBuilder instance:

var builder = WebApplication.CreateBuilder();

//Create the configuration
var configuration = new ConfigurationBuilder()
    .AddInMemoryCollection(new Dictionary<string, string> {
            { "DAPR_HTTP_ENDPOINT", "http://localhost:54321" },
            { "DAPR_API_TOKEN", "abc123" }
        })
    .Build();

builder.Configuration.AddConfiguration(configuration);
builder.Services.AddDaprPubSubClient(); //This will automatically populate the HTTP endpoint and API token values from the IConfiguration

Configuration via Environment Variables

Application settings can be accessed from environment variables available to your application.

The following environment variables will be used to populate both the HTTP endpoint and API token used to register the Dapr PubSub client.

KeyValue
DAPR_HTTP_ENDPOINThttp://localhost:54321
DAPR_API_TOKENabc123
var builder = WebApplication.CreateBuilder();

builder.Configuration.AddEnvironmentVariables();
builder.Services.AddDaprPubSubClient();

The Dapr PubSub client will be configured to use both the HTTP endpoint http://localhost:54321 and populate all outbound requests with the API token header abc123.

Configuration via prefixed Environment Variables

However, in shared-host scenarios where there are multiple applications all running on the same machine without using containers or in development environments, it’s not uncommon to prefix environment variables. The following example assumes that both the HTTP endpoint and the API token will be pulled from environment variables prefixed with the value “myapp_”. The two environment variables used in this scenario are as follows:

KeyValue
myapp_DAPR_HTTP_ENDPOINThttp://localhost:54321
myapp_DAPR_API_TOKENabc123

These environment variables will be loaded into the registered configuration in the following example and made available without the prefix attached.

var builder = WebApplication.CreateBuilder();

builder.Configuration.AddEnvironmentVariables(prefix: "myapp_");
builder.Services.AddDaprPubSubClient();

The Dapr PubSub client will be configured to use both the HTTP endpoint http://localhost:54321 and populate all outbound requests with the API token header abc123.

Use the Dapr PubSub client without relying on dependency injection

While the use of dependency injection simplifies the use of complex types in .NET and makes it easier to deal with complicated configurations, you’re not required to register the DaprPublishSubscribeClient in this way. Rather, you can also elect to create an instance of it from a DaprPublishSubscribeClientBuilder instance as demonstrated below:


public class MySampleClass
{
    public void DoSomething()
    {
        var daprPubSubClientBuilder = new DaprPublishSubscribeClientBuilder();
        var daprPubSubClient = daprPubSubClientBuilder.Build();

        //Do something with the `daprPubSubClient`
    }
}

Set up message handler

The streaming subscription implementation in Dapr gives you greater control over handling backpressure from events by leaving the messages in the Dapr runtime until your application is ready to accept them. The .NET SDK supports a high-performance queue for maintaining a local cache of these messages in your application while processing is pending. These messages will persist in the queue until processing either times out for each one or a response action is taken for each (typically after processing succeeds or fails). Until this response action is received by the Dapr runtime, the messages will be persisted by Dapr and made available in case of a service failure.

The various response actions available are as follows:

Response ActionDescription
RetryThe event should be delivered again in the future.
DropThe event should be deleted (or forwarded to a dead letter queue, if configured) and not attempted again.
SuccessThe event should be deleted as it was successfully processed.

The handler will receive only one message at a time and if a cancellation token is provided to the subscription, this token will be provided during the handler invocation.

The handler must be configured to return a Task<TopicResponseAction> indicating one of these operations, even if from a try/catch block. If an exception is not caught by your handler, the subscription will use the response action configured in the options during subscription registration.

The following demonstrates the sample message handler provided in the example:

Task<TopicResponseAction> HandleMessageAsync(TopicMessage message, CancellationToken cancellationToken = default)
{
    try
    {
        //Do something with the message
        Console.WriteLine(Encoding.UTF8.GetString(message.Data.Span));
        return Task.FromResult(TopicResponseAction.Success);
    }
    catch
    {
        return Task.FromResult(TopicResponseAction.Retry);
    }
}

Configure and subscribe to the PubSub topic

Configuration of the streaming subscription requires the name of the PubSub component registered with Dapr, the name of the topic or queue being subscribed to, the DaprSubscriptionOptions providing the configuration for the subscription, the message handler and an optional cancellation token. The only required argument to the DaprSubscriptionOptions is the default MessageHandlingPolicy which consists of a per-event timeout and the TopicResponseAction to take when that timeout occurs.

Other options are as follows:

Property NameDescription
MetadataAdditional subscription metadata
DeadLetterTopicThe optional name of the dead-letter topic to send dropped messages to.
MaximumQueuedMessagesBy default, there is no maximum boundary enforced for the internal queue, but setting this
property would impose an upper limit.
MaximumCleanupTimeoutWhen the subscription is disposed of or the token flags a cancellation request, this specifies
the maximum amount of time available to process the remaining messages in the internal queue.

Subscription is then configured as in the following example:

var messagingClient = app.Services.GetRequiredService<DaprPublishSubscribeClient>();

var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(60)); //Override the default of 30 seconds
var options = new DaprSubscriptionOptions(new MessageHandlingPolicy(TimeSpan.FromSeconds(10), TopicResponseAction.Retry));
var subscription = await messagingClient.SubscribeAsync("pubsub", "mytopic", options, HandleMessageAsync, cancellationTokenSource.Token);

Terminate and clean up subscription

When you’ve finished with your subscription and wish to stop receiving new events, simply await a call to DisposeAsync() on your subscription instance. This will cause the client to unregister from additional events and proceed to finish processing all the events still leftover in the backpressure queue, if any, before disposing of any internal resources. This cleanup will be limited to the timeout interval provided in the DaprSubscriptionOptions when the subscription was registered and by default, this is set to 30 seconds.

11.2 - DaprPublishSubscribeClient usage

Essential tips and advice for using DaprPublishSubscribeClient

Lifetime management

A DaprPublishSubscribeClient is a version of the Dapr client that is dedicated to interacting with the Dapr Messaging API. It can be registered alongside a DaprClient and other Dapr clients without issue.

It maintains access to networking resources in the form of TCP sockets used to communicate with the Dapr sidecar and implements IAsyncDisposable to support the eager cleanup of resources.

For best performance, create a single long-lived instance of DaprPublishSubscribeClient and provide access to that shared instance throughout your application. DaprPublishSubscribeClient instances are thread-safe and intended to be shared.

This can be aided by utilizing the dependency injection functionality. The registration method supports registration using as a singleton, a scoped instance or as transient (meaning it’s recreated every time it’s injected), but also enables registration to utilize values from an IConfiguration or other injected service in a way that’s impractical when creating the client from scratch in each of your classes.

Avoid creating a DaprPublishSubscribeClient for each operation and disposing it when the operation is complete. It’s intended that the DaprPublishSubscribeClient should only be disposed when you no longer wish to receive events on the subscription as disposing it will cancel the ongoing receipt of new events.

Configuring DaprPublishSubscribeClient via the DaprPublishSubscribeClientBuilder

A DaprPublishSubscribeClient can be configured by invoking methods on the DaprPublishSubscribeClientBuilder class before calling .Build() to create the client itself. The settings for each DaprPublishSubscribeClient are separate and cannot be changed after calling .Build().

var daprPubsubClient = new DaprPublishSubscribeClientBuilder()
    .UseDaprApiToken("abc123") // Specify the API token used to authenticate to other Dapr sidecars
    .Build();

The DaprPublishSubscribeClientBuilder contains settings for:

  • The HTTP endpoint of the Dapr sidecar
  • The gRPC endpoint of the Dapr sidecar
  • The JsonSerializerOptions object used to configure JSON serialization
  • The GrpcChannelOptions object used to configure gRPC
  • The API token used to authenticate requests to the sidecar
  • The factory method used to create the HttpClient instance used by the SDK
  • The timeout used for the HttpClient instance when making requests to the sidecar

The SDK will read the following environment variables to configure the default values:

  • DAPR_HTTP_ENDPOINT: used to find the HTTP endpoint of the Dapr sidecar, example: https://dapr-api.mycompany.com
  • DAPR_GRPC_ENDPOINT: used to find the gRPC endpoint of the Dapr sidecar, example: https://dapr-grpc-api.mycompany.com
  • DAPR_HTTP_PORT: if DAPR_HTTP_ENDPOINT is not set, this is used to find the HTTP local endpoint of the Dapr sidecar
  • DAPR_GRPC_PORT: if DAPR_GRPC_ENDPOINT is not set, this is used to find the gRPC local endpoint of the Dapr sidecar
  • DAPR_API_TOKEN: used to set the API token

Configuring gRPC channel options

Dapr’s use of CancellationToken for cancellation relies on the configuration of the gRPC channel options. If you need to configure these options yourself, make sure to enable the ThrowOperationCanceledOnCancellation setting.

var daprPubsubClient = new DaprPublishSubscribeClientBuilder()
    .UseGrpcChannelOptions(new GrpcChannelOptions { ... ThrowOperationCanceledOnCancellation = true })
    .Build();

Using cancellation with DaprPublishSubscribeClient

The APIs on DaprPublishSubscribeClient perform asynchronous operations and accept an optional CancellationToken parameter. This follows a standard .NET practice for cancellable operations. Note that when cancellation occurs, there is no guarantee that the remote endpoint stops processing the request, only that the client has stopped waiting for completion.

When an operation is cancelled, it will throw an OperationCancelledException.

Configuring DaprPublishSubscribeClient via dependency injection

Using the built-in extension methods for registering the DaprPublishSubscribeClient in a dependency injection container can provide the benefit of registering the long-lived service a single time, centralize complex configuration and improve performance by ensuring similarly long-lived resources are re-purposed when possible (e.g. HttpClient instances).

There are three overloads available to give the developer the greatest flexibility in configuring the client for their scenario. Each of these will register the IHttpClientFactory on your behalf if not already registered, and configure the DaprPublishSubscribeClientBuilder to use it when creating the HttpClient instance in order to re-use the same instance as much as possible and avoid socket exhaustion and other issues.

In the first approach, there’s no configuration done by the developer and the DaprPublishSubscribeClient is configured with the default settings.

var builder = WebApplication.CreateBuilder(args);

builder.Services.DaprPublishSubscribeClient(); //Registers the `DaprPublishSubscribeClient` to be injected as needed
var app = builder.Build();

Sometimes the developer will need to configure the created client using the various configuration options detailed above. This is done through an overload that passes in the DaprJobsClientBuiler and exposes methods for configuring the necessary options.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDaprJobsClient((_, daprPubSubClientBuilder) => {
   //Set the API token
   daprPubSubClientBuilder.UseDaprApiToken("abc123");
   //Specify a non-standard HTTP endpoint
   daprPubSubClientBuilder.UseHttpEndpoint("http://dapr.my-company.com");
});

var app = builder.Build();

Finally, it’s possible that the developer may need to retrieve information from another service in order to populate these configuration values. That value may be provided from a DaprClient instance, a vendor-specific SDK or some local service, but as long as it’s also registered in DI, it can be injected into this configuration operation via the last overload:

var builder = WebApplication.CreateBuilder(args);

//Register a fictional service that retrieves secrets from somewhere
builder.Services.AddSingleton<SecretService>();

builder.Services.AddDaprPublishSubscribeClient((serviceProvider, daprPubSubClientBuilder) => {
    //Retrieve an instance of the `SecretService` from the service provider
    var secretService = serviceProvider.GetRequiredService<SecretService>();
    var daprApiToken = secretService.GetSecret("DaprApiToken").Value;

    //Configure the `DaprPublishSubscribeClientBuilder`
    daprPubSubClientBuilder.UseDaprApiToken(daprApiToken);
});

var app = builder.Build();

12 - Dapr Distributed Lock .NET SDK

Get up and running with the Dapr Distributed .NET SDK

With the Dapr Distributed Lock package, you can create and remove locks on resources to manage exclusivity across your distributed applications.

While this capability is implemented in both the Dapr.Client and Dapr.DistributedLock packages, the approach differs slightly between them and a future release will see the Dapr.Client package be deprecated. It’s recommended that new implementations use the Dapr.DistributedLock package. This document will reflect the implementation in the Dapr.DistributedLock package.

Lifetime management

A DaprDistributedLockClient is a version of the Dapr client that is dedicated to interacting with Dapr’s distributed lock API. It can be registered alongside a DaprClient and other Dapr clients without issue.

It maintains access to networking resources in the form of TCP sockets used to communicate with the Dapr sidecar runtime.

For best performance, it is recommended that you utilize the dependency injection container mechanisms provided with the Dapr.DistributedLock package to provide easy access to an injected instance throughout your application. These injected instances are thread-safe and intended to be used across different types within your application. Registration via dependency injection can utilize values from an IConfiguration or other injected services in a way that’s impractical when creating the client from scratch in each of your classes.

If you do opt to manually create a DaprDistributedLockClient instance, it is recommended that you use the DaprClientBuilder to create the client. This will ensure that the client is properly configured to communicate with the Dapr sidecar runtime.`

Avoid creawting a DaprDistributedLockClient for each operation.

Configuring a DaprDistributedLockClient via DaprDistributedLockBuilder

A DaprDistributedLockClient can be configured by invoking methods on the DaprDistributedLockBuilder class before calling .Build() to create the client itself. The settings for each DaprDistributedLockClient are separate and cannot be changed after calling .Build().

var daprDistributedLockClient = new DaprDistributedLockBuilder()
    .UseDaprApiToken("abc123") // Optionally specify the API token used to authenticate to other Dapr sidecars
    .Build();

The DaprDistributedLockBuilder contains settings for:

  • The HTTP endpoint of the Dapr sidecar
  • The gRPC endpoint of the Dapr sidecar
  • The JsonSerializerOptions object used to configure JSON serialization
  • The GrpcChannelOptions object used to configure gRPC
  • The API token used to authenticate requests to the sidecar
  • The factory method used to create the HttpClient instance used by the SDK
  • The timeout used for the HttpClient instance when making requests to the sidecar

The SDK will read the following environment variables to configure the default values:

  • DAPR_HTTP_ENDPOINT: used to find the HTTP endpoint of the Dapr sidecar, example: https://dapr-api.mycompany.com
  • DAPR_GRPC_ENDPOINT: used to find the gRPC endpoint of the Dapr sidecar, example: https://dapr-grpc-api.mycompany.com
  • DAPR_HTTP_PORT: if DAPR_HTTP_ENDPOINT is not set, this is used to find the HTTP local endpoint of the Dapr sidecar
  • DAPR_GRPC_PORT: if DAPR_GRPC_ENDPOINT is not set, this is used to find the gRPC local endpoint of the Dapr sidecar
  • DAPR_API_TOKEN: used to set the API token

Configuring gRPC channel options

Dapr’s use of CancellationToken for cancellation relies on the configuration of the gRPC channel options. If you need to configure these options yourself, make sure to enable the ThrowOperationCanceledOnCancellation setting.

var daprDistributedLockClient = new DaprDistributedLockBuilder()
    .UseGrpcChannelOptions(new GrpcChannelOptions { ... ThrowOperationCanceledOnCancellation = true })
    .Build();

Using cancellation with DaprDistributedLockClient

The APIs on DaprDistributedLockClient perform asynchronous operations and accept an optional CancellationToken parameter. This follows a standard .NET practice for cancellable operations. Note that when cancellation occurs, there is no guarantee that the remote endpoint stops processing the request, only that the client has stopped waiting for completion.

When an operation is cancelled, it will throw an OperationCancelledException.

Configuring DaprDistributedLockClient via dependency injection

Using the built-in extension methods for registering the DaprDistributedLockClient in a dependency injection container can provide the benefit of registering the long-lived service a single time, centralize complex configuration and improve performance by ensuring similarly long-lived resources are re-purposed when possible (e.g. HttpClient instances).

There are three overloads available to give the developer the greatest flexibility in configuring the client for their scenario. Each of these will register the IHttpClientFactory on your behalf if not already registered, and configure the DaprDistributedLockBuilder to use it when creating the HttpClient instance in order to re-use the same instance as much as possible and avoid socket exhaustion and other issues.

In the first approach, there’s no configuration done by the developer and the DaprDistributedLockClient is configured with the default settings.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDaprDistributedLock(); //Registers the `DaprDistributedLockClient` to be injected as needed
var app = builder.Build();

Sometimes the developer will need to configure the created client using the various configuration options detailed above. This is done through an overload that passes in the DaprDistributedLockBuilder and exposes methods for configuring the necessary options.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDaprDistributedLock((_, daprDistributedLockBuilder) => {
   //Set the API token
   daprDistributedLockBuilder.UseDaprApiToken("abc123");
   //Specify a non-standard HTTP endpoint
   daprDistributedLockBuilder.UseHttpEndpoint("http://dapr.my-company.com");
});

var app = builder.Build();

Finally, it’s possible that the developer may need to retrieve information from another service in order to populate these configuration values. That value may be provided from a DaprClient instance, a vendor-specific SDK or some local service, but as long as it’s also registered in DI, it can be injected into this configuration operation via the last overload:

var builder = WebApplication.CreateBuilder(args);

//Register a fictional service that retrieves secrets from somewhere
builder.Services.AddSingleton<SecretService>();

builder.Services.AddDaprDistributedLock((serviceProvider, daprDistributedLockBuilder) => {
    //Retrieve an instance of the `SecretService` from the service provider
    var secretService = serviceProvider.GetRequiredService<SecretService>();
    var daprApiToken = secretService.GetSecret("DaprApiToken").Value;

    //Configure the `DaprDistributedLockBuilder`
    daprDistributedLockBuilder.UseDaprApiToken(daprApiToken);
});

var app = builder.Build();

12.1 - How to: Create and use Dapr Distributed Lock in the .NET SDK

Learn how to create and use the Dapr Distributed Lock client using the .NET SDK

Prerequisites

Installation

To get started with the Dapr Distributed lock .NET SDK client, install the Dapr.Distributed Lock package from NuGet:

dotnet add package Dapr.DistributedLock

A DaprDistributedLockClient maintains access to networking resources in the form of TCP sockets used to communicate with the Dapr sidecar.

Dependency Injection

The AddDaprDistributedLock() method will register the Dapr client ASP.NET Core dependency injection and is the recommended approach for using this package. This method accepts an optional options delegate for configuring the DaprDistributedLockClient and a ServiceLifetime argument, allowing you to specify a different lifetime for the registered services instead of the default Singleton value.

The following example assumes all default values are acceptable and is sufficient to register the DaprDistributedLockClient:

services.AddDaprDistributedLock();

The optional configuration delegate is used to configure the DaprDistributedLockClient by specifying options on the DaprDistributedLockBuilder as in the following example:

services.AddSingleton<DefaultOptionsProvider>();
services.AddDaprDistributedLock((serviceProvider, clientBuilder) => {
     //Inject a service to source a value from
     var optionsProvider = serviceProvider.GetRequiredService<DefaultOptionsProvider>();
     var standardTimeout = optionsProvider.GetStandardTimeout();
     
     //Configure the value on the client builder
     clientBuilder.UseTimeout(standardTimeout);
});

Manual Instantiation

Rather than using dependency injection, a DaprDistributedLockClient can also be built using the static client builder.

For best performance, create a single long-lived instance of DaprDistributedLockClient and provide access to that shared instance throughout your application. DaprDistributedLockClient instances are thread-safe and intended to be shared.

Avoid creating a DaprDistributedLockClient per-operation.

A DaprDistributedLockClient can be configured by invoking methods on the DaprDistributedLockBuilder class before calling .Build() to create the client. The settings for each DaprDistributedLockClient are separate and cannot be changed after calling .Build().

var daprDistributedLockClient = new DaprDistributedLockBuilder()
    .UseJsonSerializerSettings( ... ) //Configure JSON serializer
    .Build();

See the .NET documentation here for more information about the options available when configuring the Dapr Distributed Lock client via the builder.

Try it out

Put the Dapr Distributed Lock .NET SDK to the test. Walk through the samples to see Dapr in action:

SDK SamplesDescription
SDK samplesClone the SDK repo to try out some examples and get started.

Building Blocks

This part of the .NET SDK allows you to interface with the Distributed Lock API to place and remove locks for managing resource exclusivity across your distributed applications.

13 - Developing applications with the Dapr .NET SDK

Deployment integrations with the Dapr .NET SDK

Thinking more than one at a time

Using your favorite IDE or editor to launch an application typically assumes that you only need to run one thing: the application you’re debugging. However, developing microservices challenges you to think about your local development process for more than one at a time. A microservices application has multiple services that you might need running simultaneously, and dependencies (like state stores) to manage.

Adding Dapr to your development process means you need to manage the following concerns:

  • Each service you want to run
  • A Dapr sidecar for each service
  • Dapr component and configuration manifests
  • Additional dependencies such as state stores
  • optional: the Dapr placement service for actors

This document assumes that you’re building a production application and want to create a repeatable and robust set of development practices. The guidance here is generalized, and applies to any .NET server application using Dapr (including actors).

Managing components

You have two primary methods of storing component definitions for local development with Dapr:

  • Use the default location (~/.dapr/components)
  • Use your own location

Creating a folder within your source code repository to store components and configuration will give you a way to version and share these definitions. The guidance provided here will assume you created a folder next to the application source code to store these files.

Development options

Choose one of these links to learn about tools you can use in local development scenarios. It’s suggested that you familiarize yourself with each of them to get a sense of the options provided by the .NET SDK.

13.1 - Dapr .NET SDK Development with Dapr CLI

Learn about local development with the Dapr CLI

Dapr CLI

Consider this to be a .NET companion to the Dapr Self-Hosted with Docker Guide.

The Dapr CLI provides you with a good base to work from by initializing a local redis container, zipkin container, the placement service, and component manifests for redis. This will enable you to work with the following building blocks on a fresh install with no additional setup:

You can run .NET services with dapr run as your strategy for developing locally. Plan on running one of these commands per-service in order to launch your application.

  • Pro: this is easy to set up since its part of the default Dapr installation
  • Con: this uses long-running docker containers on your machine, which might not be desirable
  • Con: the scalability of this approach is poor since it requires running a separate command per-service

Using the Dapr CLI

For each service you need to choose:

  • A unique app-id for addressing (app-id)
  • A unique listening port for HTTP (port)

You also should have decided on where you are storing components (components-path).

The following command can be run from multiple terminals to launch each service, with the respective values substituted.

dapr run --app-id <app-id> --app-port <port> --components-path <components-path> -- dotnet run -p <project> --urls http://localhost:<port>

Explanation: this command will use dapr run to launch each service and its sidecar. The first half of the command (before --) passes required configuration to the Dapr CLI. The second half of the command (after --) passes required configuration to the dotnet run command.

If any of your services do not accept HTTP traffic, then modify the command above by removing the --app-port and --urls arguments.

Next steps

If you need to debug, then use the attach feature of your debugger to attach to one of the running processes.

If you want to scale up this approach, then consider building a script which automates this process for your whole application.

13.2 - Dapr .NET SDK Development with Docker-Compose

Learn about local development with Docker-Compose

Docker-Compose

Consider this to be a .NET companion to the Dapr Self-Hosted with Docker Guide.

docker-compose is a CLI tool included with Docker Desktop that you can use to run multiple containers at a time. It is a way to automate the lifecycle of multiple containers together, and offers a development experience similar to a production environment for applications targeting Kubernetes.

  • Pro: Since docker-compose manages containers for you, you can make dependencies part of the application definition and stop the long-running containers on your machine.
  • Con: most investment required, services need to be containerized to get started.
  • Con: can be difficult to debug and troubleshoot if you are unfamilar with Docker.

Using docker-compose

From the .NET perspective, there is no specialized guidance needed for docker-compose with Dapr. docker-compose runs containers, and once your service is in a container, configuring it similar to any other programming technology.

To summarize the approach:

  • Create a Dockerfile for each service
  • Create a docker-compose.yaml and place check it in to the source code repository

To understand the authoring the docker-compose.yaml you should start with the Hello, docker-compose sample.

Similar to running locally with dapr run for each service you need to choose a unique app-id. Choosing the container name as the app-id will make this simple to remember.

The compose file will contain at a minimum:

  • A network that the containers use to communicate
  • Each service’s container
  • A <service>-daprd sidecar container with the service’s port and app-id specified
  • Additional dependencies that run in containers (redis for example)
  • optional: Dapr placement container (for actors)

You can also view a larger example from the eShopOnContainers sample application.

13.3 - Dapr .NET SDK Development with .NET Aspire

Learn about local development with .NET Aspire

.NET Aspire

.NET Aspire is a development tool designed to make it easier to include external software into .NET applications by providing a framework that allows third-party services to be readily integrated, observed and provisioned alongside your own software.

Aspire simplifies local development by providing rich integration with popular IDEs including Microsoft Visual Studio, Visual Studio Code, JetBrains Rider and others to launch your application with the debugger while automatically launching and provisioning access to other integrations as well, including Dapr.

While Aspire also assists with deployment of your application to various cloud hosts like Microsoft Azure and Amazon AWS, deployment is currently outside the scope of this guide. More information can be found in Aspire’s documentation here.

An end-to-end demonstration featuring the following and demonstrating service invocation between multiple Dapr-enabled services can be found here.

Prerequisites

  • The Dapr .NET SDK supports .NET 8, .NET 9, and .NET 10. Use a .NET Aspire version that supports the runtime you choose.
  • An OCI compliant container runtime such as Docker Desktop or Podman
  • Install and initialize Dapr v1.16 or later

Using .NET Aspire via CLI

We’ll start by creating a brand new .NET application. Open your preferred CLI and navigate to the directory you wish to create your new .NET solution within. Start by using the following command to install a template that will create an empty Aspire application:

dotnet new install Aspire.ProjectTemplates

Once that’s installed, proceed to create an empty .NET Aspire application in your current directory. The -n argument allows you to specify the name of the output solution. If it’s excluded, the .NET CLI will instead use the name of the output directory, e.g. C:\source\aspiredemo will result in the solution being named aspiredemo. The rest of this tutorial will assume a solution named aspiredemo.

dotnet new aspire -n aspiredemo

This will create two Aspire-specific directories and one file in your directory:

  • aspiredemo.AppHost/ contains the Aspire orchestration project that is used to configure each of the integrations used in your application(s).
  • aspiredemo.ServiceDefaults/ contains a collection of extensions meant to be shared across your solution to aid in resilience, service discovery and telemetry capabilities offered by Aspire (these are distinct from the capabilities offered in Dapr itself).
  • aspiredemo.sln is the file that maintains the layout of your current solution

We’ll next create twp projects that’ll serve as our Dapr application and demonstrate Dapr functionality. From the same directory, use the following to create an empty ASP.NET Core project called FrontEndApp and another called ‘BackEndApp’. Either one will be created relative to your current directory in FrontEndApp\FrontEndApp.csproj and BackEndApp\BackEndApp.csproj, respectively.

dotnet new web --name FrontEndApp

Next we’ll configure the AppHost project to add the necessary package to support local Dapr development. Navigate into the AppHost directory with the following and install the CommunityToolkit.Aspire.Hosting.Dapr package from NuGet into the project.

We’ll also add a reference to our FrontEndApp project so we can reference it during the registration process.

cd aspiredemo.AppHost
dotnet add package CommunityToolkit.Aspire.Hosting.Dapr
dotnet add reference ../FrontEndApp/
dotnet add reference ../BackEndApp/

Next, we need to configure Dapr as a resource to be loaded alongside your project. Open the Program.cs file in that project within your preferred IDE. It should look similar to the following:

var builder = DistributedApplication.CreateBuilder(args);

builder.Build().Run();

If you’re familiar with the dependency injection approach used in ASP.NET Core projects or others utilizing the Microsoft.Extensions.DependencyInjection functionality, you’ll find that this will be a familiar experience.

Because we’ve already added a project reference to MyApp, we need to start by adding a reference in this configuration as well. Add the following before the builder.Build().Run() line:

var backEndApp = builder
    .AddProject<Projects.BackEndApp>("be")
    .WithDaprSidecar();

var frontEndApp = builder
    .AddProject<Projects.FrontEndApp>("fe")
    .WithDaprSidecar();

Because the project reference has been added to this solution, your project shows up as a type within the Projects. namespace for our purposes here. The name of the variable you assign the project to doesn’t much matter in this tutorial but would be used if you wanted to create a reference between this project and another using Aspire’s service discovery functionality.

Adding .WithDaprSidecar() configures Dapr as a .NET Aspire resource so that when the project runs, the sidecar will be deployed alongside your application. This accepts a number of different options and could optionally be configured as in the following example:

DaprSidecarOptions sidecarOptions = new()
{
    AppId = "how-dapr-identifies-your-app",
    AppPort = 8080, //Note that this argument is required if you intend to configure pubsub, actors or workflows as of Aspire v9.0 
    DaprGrpcPort = 50001,
    DaprHttpPort = 3500,
    MetricsPort = 9090
};

builder
    .AddProject<Projects.BackEndApp>("be")
    .WithReference(myApp)
    .WithDaprSidecar(sidecarOptions);

Finally, let’s add an endpoint to the back-end app that we can invoke using Dapr’s service invocation to display to a page to demonstrate that Dapr is working as expected.

When you open the solution in your IDE, ensure that the aspiredemo.AppHost is configured as your startup project, but when you launch it in a debug configuration, you’ll note that your integrated console should reflect your expected Dapr logs and it will be available to your application.

14 - Best Practices for the Dapr .NET SDK

Using Dapr .NET SDK effectively

Building with confidence

The Dapr .NET SDK offers a rich set of capabilities for building distributed applications. This section provides practical guidance for using the SDK effectively in production scenarios—focusing on reliability, maintainability, and developer experience.

Topics covered include:

  • Error handling strategies across Dapr building blocks
  • Managing experimental features and suppressing related warnings
  • Leveraging source analyzers and generators to reduce boilerplate and catch issues early
  • Running integration tests with Dapr.Testcontainers
  • General .NET development practices in Dapr-based applications

Error model guidance

Dapr operations can fail for many reasons—network issues, misconfigured components, or transient faults. The SDK provides structured error types to help you distinguish between retryable and fatal errors.

Learn how to use DaprException and its derived types effectively here.

Experimental attributes

Some SDK features are marked as experimental and may change in future releases. These are annotated with [Experimental] and generate build-time warnings by default. You can:

  • Suppress warnings selectively using #pragma warning disable
  • Use SuppressMessage attributes for finer control
  • Track experimental usage across your codebase

Learn more about our use of the [Experimenta] attribute here.

Source tooling

The SDK includes Roslyn-based analyzers and source generators to help you write better code with less effort. These tools:

  • Warn about common misuses of the SDK
  • Generate boilerplate for actor registration and invocation
  • Support IDE integration for faster feedback

Read more about how to install and use these analyzers here.

Additional guidance

This section is designed to support a wide range of development scenarios. As your applications grow in complexity, you’ll find increasingly relevant practices and patterns for working with Dapr in .NET—from actor lifecycle management to configuration strategies and performance tuning.

Learn how to run integration tests with Dapr.Testcontainers here.

14.1 - Error Model in the Dapr .NET SDK

Learn how to use the richer error model in the .NET SDK.

The Dapr .NET SDK supports the richer error model, implemented by the Dapr runtime. This model provides a way for applications to enrich their errors with added context, allowing consumers of the application to better understand the issue and resolve it faster. You can read more about the richer error model here, and you can find the Dapr proto file implementing these errors here.

The Dapr .NET SDK implements all details supported by the Dapr runtime, implemented in the Dapr.Common.Exceptions namespace, and is accessible through the DaprException extension method TryGetExtendedErrorInfo. Currently, this detail extraction is only supported for RpcExceptions where the details are present.

// Example usage of ExtendedErrorInfo

try
{
    // Perform some action with the Dapr client that throws a DaprException.
}
catch (DaprException daprEx)
{
    if (daprEx.TryGetExtendedErrorInfo(out DaprExtendedErrorInfo errorInfo)
    {
        Console.WriteLine(errorInfo.Code);
        Console.WriteLine(errorInfo.Message);

        foreach (DaprExtendedErrorDetail detail in errorInfo.Details)
        {
            Console.WriteLine(detail.ErrorType);
            switch (detail.ErrorType)
                case ExtendedErrorType.ErrorInfo:
                    Console.WriteLine(detail.Reason);
                    Console.WriteLine(detail.Domain);
                default:
                    Console.WriteLine(detail.TypeUrl);
        }
    }
}

DaprExtendedErrorInfo

Contains Code (the status code) and Message (the error message) associated with the error, parsed from an inner RpcException. Also contains a collection of DaprExtendedErrorDetails parsed from the details in the exception.

DaprExtendedErrorDetail

All details implement the abstract DaprExtendedErrorDetail and have an associated DaprExtendedErrorType.

  1. RetryInfo

  2. DebugInfo

  3. QuotaFailure

  4. PreconditionFailure

  5. RequestInfo

  6. LocalizedMessage

  7. BadRequest

  8. ErrorInfo

  9. Help

  10. ResourceInfo

  11. Unknown

RetryInfo

Information notifying the client how long to wait before they should retry. Provides a DaprRetryDelay with the properties Second (offset in seconds) and Nano (offset in nanoseconds).

DebugInfo

Debugging information offered by the server. Contains StackEntries (a collection of strings containing the stack trace), and Detail (further debugging information).

QuotaFailure

Information relating to some quota that may have been reached, such as a daily usage limit on an API. It has one property Violations, a collection of DaprQuotaFailureViolation, which each contain a Subject (the subject of the request) and Description (further information regarding the failure).

PreconditionFailure

Information informing the client that some required precondition was not met. Has one property Violations, a collection of DaprPreconditionFailureViolation, which each has Subject (subject where the precondition failure occured, e.g. “Azure”), Type (representation of the precondition type, e.g. “TermsOfService”), and Description (further description e.g. “ToS must be accepted.”).

RequestInfo

Information returned by the server that can be used by the server to identify the client’s request. Contains RequestId and ServingData properties, RequestId being some string (such as a UID) the server can interpret, and ServingData being some arbitrary data that made up part of the request.

LocalizedMessage

Contains a localized message, along with the locale of the message. Contains Locale (the locale e.g. “en-US”) and Message (the localized message).

BadRequest

Describes a bad request field. Contains collection of DaprBadRequestDetailFieldViolation, which each has Field (the offending field in request, e.g. ‘first_name’) and Description (further information detailing the reason, e.g. “first_name cannot contain special characters”).

ErrorInfo

Details the cause of an error. Contains three properties, Reason (the reason for the error, which should take the form of UPPER_SNAKE_CASE, e.g. DAPR_INVALID_KEY), Domain (domain the error belongs to, e.g. ‘dapr.io’), and Metadata, a key/value-based collection with further information.

Help

Provides resources for the client to perform further research into the issue. Contains a collection of DaprHelpDetailLink, which provides Url (a url to help or documentation), and Description (a description of what the link provides).

ResourceInfo

Provides information relating to an accessed resource. Provides three properties ResourceType (type of the resource being access e.g. “Azure service bus”), ResourceName (the name of the resource e.g. “my-configured-service-bus”), Owner (the owner of the resource e.g. “subscriptionowner@dapr.io”), and Description (further information on the resource relating to the error, e.g. “missing permissions to use this resource”).

Unknown

Returned when the detail type url cannot be mapped to the correct DaprExtendedErrorDetail implementation. Provides one property TypeUrl (the type url that could not be parsed, e.g. “type.googleapis.com/Google.rpc.UnrecognizedType”).

14.2 - Experimental Attributes

Learn about why we mark some methods with the [Experimental] attribute

Experimental Attributes

Introduction to Experimental Attributes

With the release of .NET 8, C# 12 introduced the [Experimental] attribute, which provides a standardized way to mark APIs that are still in development or experimental. This attribute is defined in the System.Diagnostics.CodeAnalysis namespace and requires a diagnostic ID parameter used to generate compiler warnings when the experimental API is used.

In the Dapr .NET SDK, we now use the [Experimental] attribute instead of [Obsolete] to mark building blocks and components that have not yet passed the stable lifecycle certification. This approach provides a clearer distinction between:

  1. Experimental APIs - Features that are available but still evolving and have not yet been certified as stable according to the Dapr Component Certification Lifecycle.

  2. Obsolete APIs - Features that are truly deprecated and will be removed in a future release.

Usage in the Dapr .NET SDK

In the Dapr .NET SDK, we apply the [Experimental] attribute at the class level for building blocks that are still in the Alpha or Beta stages of the Component Certification Lifecycle. The attribute includes:

  • A diagnostic ID that identifies the experimental building block
  • A URL that points to the relevant documentation for that block

For example:

using System.Diagnostics.CodeAnalysis;
namespace Dapr.Cryptography.Encryption 
{ 
    [Experimental("DAPR_CRYPTOGRAPHY", UrlFormat = "https://docs.dapr.io/developing-applications/building-blocks/cryptography/cryptography-overview/")] 
    public class DaprEncryptionClient 
    { 
        // Implementation 
    } 
}

The diagnostic IDs follow a naming convention of DAPR_[BUILDING_BLOCK_NAME], such as:

  • DAPR_CONVERSATION - For the Conversation building block
  • DAPR_CRYPTOGRAPHY - For the Cryptography building block
  • DAPR_JOBS - For the Jobs building block
  • DAPR_DISTRIBUTEDLOCK - For the Distributed Lock building block

Suppressing Experimental Warnings

When you use APIs marked with the [Experimental] attribute, the compiler will generate errors. To build your solution without marking your own code as experimental, you will need to suppress these errors. Here are several approaches to do this:

Option 1: Using #pragma directive

You can use the #pragma warning directive to suppress the warning for specific sections of code:

// Disable experimental warning 
#pragma warning disable DAPR_CRYPTOGRAPHY 
// Your code using the experimental API 
var client = new DaprEncryptionClient(); 
// Re-enable the warning 
#pragma warning restore DAPR_CRYPTOGRAPHY

This approach is useful when you want to suppress warnings only for specific sections of your code.

Option 2: Project-level suppression

To suppress warnings for an entire project, add the following to your .csproj file. file.

<PropertyGroup>
    <NoWarn>$(NoWarn);DAPR_CRYPTOGRAPHY</NoWarn>
</PropertyGroup>

You can include multiple diagnostic IDs separated by semicolons:

<PropertyGroup>
    <NoWarn>$(NoWarn);DAPR_CONVERSATION;DAPR_JOBS;DAPR_DISTRIBUTEDLOCK;DAPR_CRYPTOGRAPHY</NoWarn>
</PropertyGroup>

This approach is particularly useful for test projects that need to use experimental APIs.

Option 3: Directory-level suppression

For suppressing warnings across multiple projects in a directory, add a Directory.Build.props file:

<PropertyGroup>
    <NoWarn>$(NoWarn);DAPR_CONVERSATION;DAPR_JOBS;DAPR_DISTRIBUTEDLOCK;DAPR_CRYPTOGRAPHY</NoWarn>
</PropertyGroup>

This file should be placed in the root directory of your test projects. You can learn more about using Directory.Build.props files in the MSBuild documentation.

Lifecycle of Experimental APIs

As building blocks move through the certification lifecycle and reach the “Stable” stage, the [Experimental] attribute will be removed. No migration or code changes will be required from users when this happens, except for the removal of any warning suppressions if they were added.

Conversely, the [Obsolete] attribute will now be reserved exclusively for APIs that are truly deprecated and scheduled for removal. When you see a method or class marked with [Obsolete], you should plan to migrate away from it according to the migration guidance provided in the attribute message.

Best Practices

  1. In application code:

    • Be cautious when using experimental APIs, as they may change in future releases
    • Consider isolating usage of experimental APIs to make future updates easier
    • Document your use of experimental APIs for team awareness
  2. In test code:

    • Use project-level suppression to avoid cluttering test code with warning suppressions
    • Regularly review which experimental APIs you’re using and check if they’ve been stabilized
  3. When contributing to the SDK:

    • Use [Experimental] for new building blocks that haven’t completed certification
    • Use [Obsolete] only for truly deprecated APIs
    • Provide clear documentation links in the UrlFormat parameter

Additional Resources

14.3 - Integration testing with Dapr.Testcontainers

Use Dapr.Testcontainers to run Dapr integration tests with real infrastructure

Overview

Dapr.Testcontainers is a helper package for writing integration tests against real Dapr runtime components using containers. It wraps the Testcontainers library to spin up Dapr sidecars, control plane services (placement and scheduler), and the infrastructure needed by specific Dapr building blocks.

Packages

  • Dapr.Testcontainers (core harnesses and infrastructure)
  • Dapr.Testcontainers.Xunit (optional xUnit helpers)

Prerequisites

  • A container runtime (Docker Desktop, Podman, or equivalent).
  • Network access to pull Dapr and dependency images.

Core concepts

Dapr.Testcontainers models tests around environments and harnesses:

  • DaprTestEnvironment: Shared infrastructure (network, placement, scheduler, optional Redis). Use this when you need multiple apps to share a Dapr control plane or when running multi-app tests.
  • DaprHarnessBuilder: Creates a harness for a specific building block (workflow, jobs, distributed locks, or conversation).
  • DaprTestApplicationBuilder: Starts your test app with the harness and wires Dapr endpoints into configuration.

Basic workflow test example

The example below mirrors the workflow integration tests in the .NET SDK test suite and shows a typical setup:

var componentsDir = TestDirectoryManager.CreateTestDirectory("workflow-components");

await using var environment = await DaprTestEnvironment.CreateWithPooledNetworkAsync(needsActorState: true);
await environment.StartAsync();

var harness = new DaprHarnessBuilder(componentsDir)
    .WithEnvironment(environment)
    .BuildWorkflow();

await using var testApp = await DaprHarnessBuilder.ForHarness(harness)
    .ConfigureServices(builder =>
    {
        builder.Services.AddDaprWorkflowBuilder(opt =>
        {
            opt.RegisterWorkflow<TestWorkflow>();
        });
    })
    .BuildAndStartAsync();

using var scope = testApp.CreateScope();
var workflowClient = scope.ServiceProvider.GetRequiredService<DaprWorkflowClient>();

await workflowClient.ScheduleNewWorkflowAsync(nameof(TestWorkflow), input: 42);

Configuring the Dapr runtime

DaprRuntimeOptions lets you control the Dapr image version, App ID, log level, and container logs:

var options = new DaprRuntimeOptions("1.17.0")
    .WithAppId("test-app")
    .WithLogLevel(DaprLogLevel.Debug)
    .WithContainerLogs();

var harness = new DaprHarnessBuilder(componentsDir)
    .WithOptions(options)
    .BuildWorkflow();

You can also set the runtime version globally using the DAPR_RUNTIME_VERSION environment variable.

xUnit helpers

If you use xUnit, the Dapr.Testcontainers.Xunit package includes a helper attribute to skip tests when the runtime version is too low:

[MinimumDaprRuntimeFact("1.17.0")]
public async Task RequiresLatestRuntime()
{
    // ...
}

Next steps

  • Review the integration test projects in the Dapr .NET SDK repo (for example, test/Dapr.IntegrationTest.Workflow).
  • Use the harness that matches the building block you are testing (workflow, jobs, distributed locks, conversation).

14.4 - Dapr source code analyzers and generators

Code analyzers and fixes for common Dapr issues

Dapr supports a growing collection of optional Roslyn analyzers and code fix providers that inspect your code for code quality issues. Starting with the release of v1.16, developers have the opportunity to install additional projects from NuGet alongside each of the standard capability packages to enable these analyzers in their solutions.

Rule violations will typically be marked as Info or Warning so that if the analyzer identifies an issue, it won’t necessarily break builds. All code analysis violations appear with the prefix “DAPR” and are uniquely distinguished by a number following this prefix.

Install and configure analyzers

The following packages will be available via NuGet following the v1.16 Dapr release:

  • Dapr.Actors.Analyzers
  • Dapr.Jobs.Analyzers
  • Dapr.Workflow.Analyzers

Install each NuGet package on every project where you want the analyzers to run. The package will be installed as a project dependency and analyzers will run as you write your code or as part of a CI/CD build. The analyzers will flag issues in your existing code and warn you about new issues as you build your project.

Many of our analyzers have associated code fixes that can be applied to automatically correct the problem. If your IDE supports this capability, any available code fixes will show up as an inline menu option in your code.

Further, most of our analyzers should also report a specific line and column number in your code of the syntax that’s been identified as a key aspect of the rule. If your IDE supports it, double clicking any of the analyzer warnings should jump directly to the part of your code responsible for the violating the analyzer’s rule.

Suppress specific analyzers

If you wish to keep an analyzer from firing against some particular piece of your project, their outputs can be individually targeted for suppression through a number of ways. Read more about suppressing analyzers in projects or files in the associated .NET documentation.

Disable all analyzers

If you wish to disable all analyzers in your project without removing any packages providing them, set the EnableNETAnalyzers property to false in your csproj file.

Available Analyzers

Diagnostic IDDapr PackageCategorySeverityVersion AddedDescriptionCode Fix Available
DAPR1001Dapr.CommonCompatibilityWarning1.18API requires a newer Dapr runtime than the configured target.No
DAPR1002Dapr.CommonCompatibilityWarning1.18Unable to parse the Configured Dapr runtime target versionNo
DAPR1003Dapr.CommonCompatibilityWarning1.18Unable to parse the minimum Dapr runtime version declared on an APINo
DAPR1301Dapr.WorkflowUsageWarning1.16The workflow type is not registered with the dependency injection provider. Note: Not applied when Dapr.Workflow.Versioning installed.Yes
DAPR1302Dapr.WorkflowUsageWarning1.16The workflow activity type is not registered with the dependency injection providerYes
DAPR1401Dapr.ActorsUsageWarning1.16Actor timer method invocations require the named callback method to exist on typeNo
DAPR1402Dapr.ActorsUsageWarning1.16The actor type is not registered with dependency injectionYes
DAPR1403Dapr.ActorsInteroperabilityInfo1.16Set options.UseJsonSerialization to true to support interoperability with non-.NET actorsYes
DAPR1404Dapr.ActorsUsageWarning1.16Call app.MapActorsHandlers to map endpoints for Dapr actorsYes
DAPR1410Dapr.Actors.NextCompatibilityWarning1.18Actor state shape change breaks shipped serializationYes
DAPR1411Dapr.Actors.NextConcurrencyWarning1.18Actor turn must not escape the schedulerYes
DAPR1412Dapr.Actors.NextConcurrencyWarning1.18Actor turn must not blockYes
DAPR1413Dapr.Actors.NextDeterminismWarning1.18Actor turn must use TimeProviderYes
DAPR1414Dapr.Actors.NextDeterminismWarning1.18Actor turn must use a scheduler-aware seeded sourceYes
DAPR1415Dapr.Actors.NextCompatibilityWarning1.18Actor state migration target is unreachableYes
DAPR1416Dapr.Actors.NextDesignInfo1.18Actor turn filter should stay cross-cuttingYes
DAPR1417Dapr.Actors.NextUsageWarning1.18Actor interface method must return an asynchronous typeNo
DAPR1418Dapr.Actors.NextCompatibilityWarning1.18Actor interface change breaks shipped wire contractYes
DAPR1419Dapr.Actors.NextConcurrencyWarning1.18Actor field should not hold mutable shared stateNo
DAPR1420Dapr.Actors.NextUsageWarning1.18Actor type name must disambiguate shared actor contractsNo
DAPR1421Dapr.Actors.NextUsageWarning1.18Actor implementation must expose a generated client contractYes
DAPR1423Dapr.Actors.NextCompatibilityWarning1.18Actor state type is not connected to its migration familyYes
DAPR1424Dapr.Actors.NextCompatibilityWarning1.18Actor state migration chain has a gapNo
DAPR1425Dapr.Actors.NextCompatibilityWarning1.18Actor state migration step requires an upcasterYes
DAPR1426Dapr.Actors.NextCompatibilityWarning1.18Actor state migration fold path is ambiguousNo
DAPR1427Dapr.Actors.NextUsageWarning1.18Actor state name maps to multiple migration familiesNo
DAPR1428Dapr.Actors.NextUsageInfo1.18Actor state usage should target the latest state versionNo
DAPR1429Dapr.Actors.NextUsageError1.18Scheduled actor reminder/timer callback does not match a dispatchable actor methodYes
DAPR1430Dapr.Actors.NextUsageWarning1.18Scheduled actor reminder/timer targets an actor type not found in this applicationNo
DAPR1431Dapr.Actors.NextUsageError1.18Scheduled actor reminder/timer callback method is not exposed through a generated actor clientNo
DAPR1501Dapr.JobsUsageWarning1.16Job invocations require the MapDaprScheduledJobHandler to be set and configured for each anticipated job on IEndpointRouteBuilderNo

Analyzer Categories

The following are each of the eligible categories that an analyzer can be assigned to and are modeled after the standard categories used by the .NET analyzers:

  • Design
  • Documentation
  • Globalization
  • Interoperability
  • Maintainability
  • Naming
  • Performance
  • Reliability
  • Security
  • Usage
  • Compatibility

15 - How to troubleshoot and debug with the Dapr .NET SDK

Tips, tricks, and guides for troubleshooting and debugging with the Dapr .NET SDKs

15.1 - Troubleshoot Pub/Sub with the .NET SDK

Troubleshoot Pub/Sub with the .NET SDK

Troubleshooting Pub/Sub

The most common problem with pub/sub is that the pub/sub endpoint in your application is not being called.

There are a few layers to this problem with different solutions:

  • The application is not receiving any traffic from Dapr
  • The application is not registering pub/sub endpoints with Dapr
  • The pub/sub endpoints are registered with Dapr, but the request is not reaching the desired endpoint

Step 1: Turn up the logs

This is important. Future steps will depend on your ability to see logging output. ASP.NET Core logs almost nothing with the default log settings, so you will need to change it.

Adjust the logging verbosity to include Information logging for ASP.NET Core as described here. Set the Microsoft key to Information.

Step 2: Verify you can receive traffic from Dapr

  1. Start the application as you would normally (dapr run ...). Make sure that you’re including an --app-port argument in the commandline. Dapr needs to know that your application is listening for traffic. By default an ASP.NET Core application will listen for HTTP on port 5000 in local development.

  2. Wait for Dapr to finish starting

  3. Examine the logs

You should see a log entry like:

info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
      Request starting HTTP/1.1 GET http://localhost:5000/.....

During initialization Dapr will make some requests to your application for configuration. If you can’t find these then it means that something has gone wrong. Please ask for help either via an issue or in Discord (include the logs). If you see requests made to your application, then continue to step 3.

Step 3: Verify endpoint registration

  1. Start the application as you would normally (dapr run ...).

  2. Use curl at the command line (or another HTTP testing tool) to access the /dapr/subscribe endpoint.

Here’s an example command assuming your application’s listening port is 5000:

curl http://localhost:5000/dapr/subscribe -v

For a correctly configured application the output should look like the following:

*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 5000 (#0)
> GET /dapr/subscribe HTTP/1.1
> Host: localhost:5000
> User-Agent: curl/7.64.1
> Accept: */*
>
< HTTP/1.1 200 OK
< Date: Fri, 15 Jan 2021 22:31:40 GMT
< Content-Type: application/json
< Server: Kestrel
< Transfer-Encoding: chunked
<
* Connection #0 to host localhost left intact
[{"topic":"deposit","route":"deposit","pubsubName":"pubsub"},{"topic":"withdraw","route":"withdraw","pubsubName":"pubsub"}]* Closing connection 0

Pay particular attention to the HTTP status code, and the JSON output.

< HTTP/1.1 200 OK

A 200 status code indicates success.

The JSON blob that’s included near the end is the output of /dapr/subscribe that’s processed by the Dapr runtime. In this case it’s using the ControllerSample in this repo - so this is an example of correct output.

[
    {"topic":"deposit","route":"deposit","pubsubName":"pubsub"},
    {"topic":"withdraw","route":"withdraw","pubsubName":"pubsub"}
]

With the output of this command in hand, you are ready to diagnose a problem or move on to the next step.

Option 0: The response was a 200 included some pub/sub entries

If you have entries in the JSON output from this test then the problem lies elsewhere, move on to step 2.

Option 1: The response was not a 200, or didn’t contain JSON

If the response was not a 200 or did not contain JSON, then the MapSubscribeHandler() endpoint was not reached.

Make sure you have some code like the following in Startup.cs and repeat the test.

app.UseRouting();

app.UseCloudEvents();

app.UseEndpoints(endpoints =>
{
    endpoints.MapSubscribeHandler(); // This is the Dapr subscribe handler
    endpoints.MapControllers();
});

If adding the subscribe handler did not resolve the problem, please open an issue on this repo and include the contents of your Startup.cs file.

Option 2: The response contained JSON but it was empty (like [])

If the JSON output was an empty array (like []) then the subscribe handler is registered, but no topic endpoints were registered.


If you’re using a controller for pub/sub you should have a method like:

[Topic("pubsub", "deposit")]
[HttpPost("deposit")]
public async Task<ActionResult> Deposit(...)

// Using Pub/Sub routing
[Topic("pubsub", "transactions", "event.type == \"withdraw.v2\"", 1)]
[HttpPost("withdraw")]
public async Task<ActionResult> Withdraw(...)

In this example the Topic and HttpPost attributes are required, but other details might be different.


If you’re using routing for pub/sub you should have an endpoint like:

endpoints.MapPost("deposit", ...).WithTopic("pubsub", "deposit");

In this example the call to WithTopic(...) is required but other details might be different.


After correcting this code and re-testing if the JSON output is still the empty array (like []) then please open an issue on this repository and include the contents of Startup.cs and your pub/sub endpoint.

Step 4: Verify endpoint reachability

In this step we’ll verify that the entries registered with pub/sub are reachable. The last step should have left you with some JSON output like the following:

[
  {
    "pubsubName": "pubsub",
    "topic": "deposit",
    "route": "deposit"
  },
  {
    "pubsubName": "pubsub",
    "topic": "deposit",
    "routes": {
      "rules": [
        {
          "match": "event.type == \"withdraw.v2\"",
          "path": "withdraw"
        }
      ]
    }
  }
]

Keep this output, as we’ll use the route information to test the application.

  1. Start the application as you would normally (dapr run ...).

  2. Use curl at the command line (or another HTTP testing tool) to access one of the routes registered with a pub/sub endpoint.

Here’s an example command assuming your application’s listening port is 5000, and one of your pub/sub routes is withdraw:

curl http://localhost:5000/withdraw -H 'Content-Type: application/json' -d '{}' -v

Here’s the output from running the above command against the sample:

*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 5000 (#0)
> POST /withdraw HTTP/1.1
> Host: localhost:5000
> User-Agent: curl/7.64.1
> Accept: */*
> Content-Type: application/json
> Content-Length: 2
>
* upload completely sent off: 2 out of 2 bytes
< HTTP/1.1 400 Bad Request
< Date: Fri, 15 Jan 2021 22:53:27 GMT
< Content-Type: application/problem+json; charset=utf-8
< Server: Kestrel
< Transfer-Encoding: chunked
<
* Connection #0 to host localhost left intact
{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.1","title":"One or more validation errors occurred.","status":400,"traceId":"|5e9d7eee-4ea66b1e144ce9bb.","errors":{"Id":["The Id field is required."]}}* Closing connection 0

Based on the HTTP 400 and JSON payload, this response indicates that the endpoint was reached but the request was rejected due to a validation error.

You should also look at the console output of the running application. This is example output with the Dapr logging headers stripped away for clarity.

info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
      Request starting HTTP/1.1 POST http://localhost:5000/withdraw application/json 2
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
      Executing endpoint 'ControllerSample.Controllers.SampleController.Withdraw (ControllerSample)'
info: Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker[3]
      Route matched with {action = "Withdraw", controller = "Sample"}. Executing controller action with signature System.Threading.Tasks.Task`1[Microsoft.AspNetCore.Mvc.ActionResult`1[ControllerSample.Account]] Withdraw(ControllerSample.Transaction, Dapr.Client.DaprClient) on controller ControllerSample.Controllers.SampleController (ControllerSample).
info: Microsoft.AspNetCore.Mvc.Infrastructure.ObjectResultExecutor[1]
      Executing ObjectResult, writing value of type 'Microsoft.AspNetCore.Mvc.ValidationProblemDetails'.
info: Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker[2]
      Executed action ControllerSample.Controllers.SampleController.Withdraw (ControllerSample) in 52.1211ms
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1]
      Executed endpoint 'ControllerSample.Controllers.SampleController.Withdraw (ControllerSample)'
info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
      Request finished in 157.056ms 400 application/problem+json; charset=utf-8

The log entry of primary interest is the one coming from routing:

info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
      Executing endpoint 'ControllerSample.Controllers.SampleController.Withdraw (ControllerSample)'

This entry shows that:

  • Routing executed
  • Routing chose the ControllerSample.Controllers.SampleController.Withdraw (ControllerSample)' endpoint

Now you have the information needed to troubleshoot this step.

Option 0: Routing chose the correct endpoint

If the information in the routing log entry is correct, then it means that in isolation your application is behaving correctly.

Example:

info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
      Executing endpoint 'ControllerSample.Controllers.SampleController.Withdraw (ControllerSample)'

You might want to try using the Dapr cli to execute send a pub/sub message directly and compare the logging output.

Example command:

dapr publish --pubsub pubsub --topic withdraw --data '{}'

If after doing this you still don’t understand the problem please open an issue on this repo and include the contents of your Startup.cs.

Option 1: Routing did not execute

If you don’t see an entry for Microsoft.AspNetCore.Routing.EndpointMiddleware in the logs, then it means that the request was handled by something other than routing. Usually the problem in this case is a misbehaving middleware. Other logs from the request might give you a clue to what’s happening.

If you need help understanding the problem please open an issue on this repo and include the contents of your Startup.cs.

Option 2: Routing chose the wrong endpoint

If you see an entry for Microsoft.AspNetCore.Routing.EndpointMiddleware in the logs, but it contains the wrong endpoint then it means that you’ve got a routing conflict. The endpoint that was chosen will appear in the logs so that should give you an idea of what’s causing the conflict.

If you need help understanding the problem please open an issue on this repo and include the contents of your Startup.cs.