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.
The Dapr .NET SDK supports .NET 8, .NET 9, .NET 10, and .NET 11. Be advised that .NET 8 and .NET 9
reach end of support in November 2026. Support for both will be dropped in Dapr v1.20; only .NET 10 and
.NET 11 will be supported through the end of 2027.
Installation
For the package/API coverage in SDK 1.19.0, see
.NET building block support. Use that release or a later compatible
release for the 1.19 features in these guides.
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:
Choose .NET SDK packages and APIs for Dapr building blocks in SDK 1.19
This guide describes the .NET SDK surface in
v1.19.0.
Use that release or a later compatible release when following these guides. Runtime and component support are
separate from SDK support: installing a package does not enable a runtime API or add capabilities to a component.
Use a Dapr 1.19 runtime for the complete 1.19 API surface, including binary store and newer management operations.
Install the package for the building block you need, for example:
dotnet add package Dapr.Client
Keep Dapr package versions aligned. Experimental APIs can produce compiler diagnostics such as
DAPR_CONVERSATION, DAPR_CRYPTOGRAPHY, and DAPR_DISTRIBUTEDLOCK; see
experimental attributes before opting in.
HTTP verbs, JSON or arbitrary content, headers and query parameters, response/status inspection, generated gRPC clients through the Dapr proxy, and legacy InvokeMethodGrpcAsync protobuf calls. Prefer native HTTP/gRPC clients for new applications.
Single and bulk reads/writes/deletes, ETags, consistency/concurrency options, transactions, metadata/TTL, and component-dependent queries. Dapr.Client additionally exposes byte-state methods and StateEntry<T>.
Stream or memory uploads, overwrite control, chunked or stream downloads, buffered byte downloads, and deletion. No public list, range-read, or file-metadata API is exposed by this SDK release.
Dapr.Client: InvokeBindingAsync; ASP.NET Core or gRPC callbacks for input
Output operations with typed JSON requests/responses or raw bytes and metadata; input events through app callbacks. Operations and payload formats are component-specific.
Dapr.Actors.Next and the original Dapr.Actors package family
Generated proxies/dispatch, dynamic invocation, state/cache management, lifecycle and turn hooks, reentrancy, timers/reminders and their management APIs, pub/sub streams, state migration, state machines, and in-memory testing. The original SDK retains its distinct remoting/JSON proxy and HTTP hosting APIs; it is not API-compatible with Actors Next.
Single and bulk reads with metadata, generated startup-loaded secret properties, and secret-backed IConfiguration. There are no create, update, delete, or secret-watch methods.
Fetch selected/all keys, subscribe to updates, unsubscribe by ID, and snapshot or streaming IConfiguration providers with optional non-blocking startup. These APIs read/watch configuration; they do not write it.
Attempt acquisition with an owner and expiry, automatic unlock through IAsyncDisposable, and explicit unlock with status reporting. No wait-until-acquired or lease-renewal API. Legacy DaprClient.Lock/Unlock have different response shapes.
Buffered and streaming encryption/decryption, key-wrap and data-cipher selection, decryption-key override, and configurable streaming block size. The SDK does not expose the runtime’s subtle key/sign/verify APIs.
Role-based text messages, tools/tool choices and returned tool calls, context IDs, metadata/parameters, PII scrubbing, temperature, response schema, prompt cache retention, and model/token usage results. IChatClient maps a subset of these features. Neither client provides token streaming in this release.
Durable orchestration, activities/children/detached workflows, timers/events, retries, bounded parallelism, continuation, custom status, multi-app routing, patch/name versioning, custom serialization, history propagation/caching, alpha binary payload references, and instance management including waits, listing, history, and rerun.
Shared client behavior
Reuse long-lived clients, preferably through their dependency injection registration extensions. Most dedicated
clients default to singleton registration and inherit common endpoint, API token, JSON and gRPC channel settings.
Pass cancellation tokens to network operations and asynchronous stream enumeration; an HTTP timeout setting is
not a deadline for every gRPC operation.
Do not assume all clients have identical failure behavior. In particular, see the
state client’s release-specific cancellation behavior,
inspect bulk publishing failures, observe dynamic subscription completion, and check explicit unlock status.
The SDK source at the version linked above is the reference for public signatures. The task-focused guides linked
in the table distinguish SDK functionality from runtime/component requirements and identify APIs that remain
available only on the older DaprClient surface.
2 - 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.
Note
If you haven’t already, try out one of the quickstarts for a quick walk-through on how to use the Dapr .NET SDK with an API building block.
We will only include the dependency injection registration for the DaprClient in the first example
(service invocation). In nearly all other examples, it’s assumed you’ve already registered the DaprClient in your
application in the latter examples and have injected an instance of DaprClient into your code as an instance named
client.
Invoke a service
HTTP
You can either use the DaprClient or System.Net.Http.HttpClient to invoke your services.
varbuilder=WebApplication.CreateBuilder(args);builder.Services.AddDaprClient();varapp=builder.Build();usingvarscope=app.Services.CreateScope();varclient=scope.ServiceProvider.GetRequiredService<DaprClient>();// Invokes a POST method named "deposit" that takes input of type "Transaction"vardata=new{id="17",amount=99m};varaccount=awaitclient.InvokeMethodAsync<Account>("routing","deposit",data,cancellationToken);Console.WriteLine("Returned: id:{0} | Balance:{1}",account.Id,account.Balance);
usingMicrosoft.Extensins.Hosting;usingMicrosoft.Extensions.DependencyInjection;varbuilder=Host.CreateApplicationBuilder(args);builder.Services.AddDaprClient();varapp=builder.Build();usingvarscope=app.Services.CreateScope();varclient=scope.ServiceProvider.GetRequiredService<DaprClient>();// Invokes a POST method named "deposit" that takes input of type "Transaction"vardata=new{id="17",amount=99m};varaccount=awaitclient.InvokeMethodAsync<Account>("routing","deposit",data,cancellationToken);Console.WriteLine("Returned: id:{0} | Balance:{1}",account.Id,account.Balance);
varclient=DaprClient.CreateInvokeHttpClient(appId:"routing");// To set a timeout on the HTTP client:client.Timeout=TimeSpan.FromSeconds(2);vardeposit=newTransaction{Id="17",Amount=99m};varresponse=awaitclient.PostAsJsonAsync("/deposit",deposit,cancellationToken);varaccount=awaitresponse.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.
Visit .NET SDK examples for code samples and instructions to try out pub/sub
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.
Important
Bindings differ in the shape of data they expect, take special note and ensure that the data you
are sending is handled accordingly. If you are authoring both an output and an input, make sure
that they both follow the same conventions for serialization.
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.
usingvarclient=newDaprClientBuilder().Build();varrequest=newBindingRequest("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"},},}awaitclient.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.
usingvarclient=newDaprClientBuilder().Build();varemail=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",},};awaitclient.InvokeBindingAsync("send-email","create",email);
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.
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 DIvarclient=scope.GetRequiredService<DaprClient>();// Wait for the outbound channel to be established - only use for this scenario and not generallyawaitclient.WaitForOutboundHealthAsync();// Retrieve a key-value-pair-based secret - returns a Dictionary<string, string>varsecrets=awaitclient.GetSecretAsync("mysecretstore","key-value-pair-secret");Console.WriteLine($"Got secret keys: {string.Join(",", secrets.Keys)}");
// Get an instance of the DaprClient from DIvarclient=scope.GetRequiredService<DaprClient>();// Wait for the outbound channel to be established - only use for this scenario and not generallyawaitclient.WaitForOutboundHealthAsync();// Retrieve a key-value-pair-based secret - returns a Dictionary<string, string>varsecrets=awaitclient.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 keyvardata=awaitclient.GetSecretAsync("mysecretstore","single-value-secret");varvalue=data["single-value-secret"]Console.WriteLine("Got a secret value, I'm not going to be print it, it's a secret!");
// Retrieve a specific set of keys.varspecificItems=awaitclient.GetConfiguration("configstore",newList<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.varspecificItems=awaitclient.GetConfiguration("configstore",newList<string>());Console.WriteLine($"I got {configItems.Count} entires!");foreach(variteminconfigItems){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.varsubscribeConfigurationResponse=awaitdaprClient.SubscribeConfiguration(store,keys,metadata,cts.Token);awaitforeach(varitemsinsubscribeConfigurationResponse.Source.WithCancellation(cts.Token)){foreach(variteminitems){Console.WriteLine($"{item.Key} -> {item.Value}")}}
Distributed lock (Alpha)
Acquire a lock
usingSystem;usingDapr.Client;usingMicrosoft.Extensions.Hosting;usingMicrosoft.Extensions.DependencyInjection;namespaceLockService{classProgram{ [Obsolete("Distributed Lock API is in Alpha, this can be removed once it is stable.")]staticasyncTaskMain(string[]args){conststringdaprLockName="lockstore";conststringfileName="my_file_name";varbuilder=Host.CreateDefaultBuilder();builder.ConfigureServices(services=>{services.AddDaprClient();});varapp=builder.Build();usingvarscope=app.Services.CreateScope();varclient=scope.ServiceProvider.GetRequiredService<DaprClient>();// Locking with this approach will also unlock it automatically, as this is a disposable objectawaitusing(varfileLock=awaitclient.Lock(DAPR_LOCK_NAME,fileName,"random_id_abc123",60)){if(fileLock.Success){Console.WriteLine("Success");}else{Console.WriteLine($"Failed to lock {fileName}.");}}}}}
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.
WaitForSidecarAsync continuously polls CheckOutboundHealthAsync until it returns a successful status code.
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.
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:
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.
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().
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_;
varbuilder=WebApplication.CreateBuilder(args);builder.Configuration.AddEnvironmentVariables("test_");//Retrieves all environment variables that start with "test_" and removes the prefix when sourced from IConfigurationbuilder.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
Note
If both DAPR_HTTP_ENDPOINT and DAPR_HTTP_PORT are specified, the port value from DAPR_HTTP_PORT will be ignored in favor of the port
implicitly or explicitly defined on DAPR_HTTP_ENDPOINT. The same is true of both DAPR_GRPC_ENDPOINT and DAPR_GRPC_PORT.
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.
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 canceled, it can throw an OperationCanceledException. A canceled request does not imply
that a remote write or external side effect was rolled back.
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 does not have built-in support for all F# language features. 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.
publicclassWidget{publicstringColor{get;set;}}...// Storing a Widget value as JSON in the state storewidgetwidget=newWidget(){Color="Green",};awaitclient.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:
publicclassWidget{publicstringColor{get;set;}}publicclassSuperWidget:Widget{publicboolHasSelfCleaningFeature{get;set;}}...// Storing a SuperWidget value as JSON in the state storeWidgetwidget=newSuperWidget(){Color="Green",HasSelfCleaningFeature=true,};awaitclient.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.
Methods on DaprClient will throw DaprException or a subclass when a failure is encountered.
try{varwidget=newWidget(){Color="Green",};awaitclient.SaveStateAsync("mystatestore","mykey",widget);}catch(DaprExceptionex){// 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.
3 - Dapr Workflow .NET SDK
Get up and running with Dapr Workflow and the Dapr .NET SDK
The Dapr.Workflow package provides typed workflows and activities, a hosted worker, and DaprWorkflowClient
for managing durable instances. These guides describe SDK 1.19.0 or later; use Dapr runtime 1.19 for the full
feature set. Installing the package does not configure a workflow state store or start the required runtime services.
dotnet add package Dapr.Workflow
Task
Guide
Register the worker/client, discover types, and configure transport or history caching
Workflow code must remain deterministic during replay. Keep I/O and other nondeterministic work in activities,
make external side effects idempotent, and preserve compatible code and payloads for retained workflow histories.
3.1 - DaprWorkflowClient lifetime management and registration
Configure workflow hosting, discovery, client lifetime, transport limits, and history caching
Install and register
Use Dapr.Workflow 1.19.0 or a later compatible release:
dotnet add package Dapr.Workflow
AddDaprWorkflow registers the workflow worker as a hosted service, its dependencies, and a
DaprWorkflowClient. It works with ASP.NET Core and the .NET Generic Host. Start the host to process work items;
building a service provider alone does not start the worker.
Configure a workflow-compatible state store and the Dapr runtime services described in
Author workflows. Use Dapr runtime 1.19 for the full SDK 1.19 surface.
Automatic discovery and explicit registration
The included source generator discovers accessible, concrete classes deriving from Workflow<TInput, TOutput>
and WorkflowActivity<TInput, TOutput> in the project being compiled. An options delegate is not required.
Abstract classes and open generic types are not registered.
Explicit registration is supported for custom names, delegate implementations, or types not discovered by the
generator. Explicit registrations take precedence over generated registrations:
To scan accessible workflow and activity types in referenced assemblies, add both the property value and
compiler-visible property to the host project’s .csproj. Reference scanning is disabled by default to limit
build overhead:
Discovery does not require opting into name-based workflow versioning.
Avoid colliding simple type names, or register distinct explicit names.
Client lifetime and dependency injection
The client defaults to a singleton. Reuse it instead of constructing a client for every operation, and let the
container manage disposal of injected instances. DaprWorkflowClient implements both IDisposable and
IAsyncDisposable; the DI-registered gRPC transport is managed by the client factory.
The overload with an options delegate accepts lifetime to change the client lifetime:
Use ServiceLifetime.Transient for a new client on each resolution. This does not change the singleton runtime
options/factory or turn the hosted worker into a scoped service.
Inject the concrete DaprWorkflowClient into an ASP.NET Core endpoint:
The public DaprWorkflowClientBuilder in Dapr.Workflow.Registration can also construct a management client
without hosting a worker. Dispose clients you construct yourself. AddDaprWorkflowClient is not a public
registration extension in this release; use AddDaprWorkflow when hosting workflows.
Transport configuration
The hosted worker and DI client resolve their sidecar gRPC address through Dapr configuration, including
DAPR_GRPC_ENDPOINT / DAPR_GRPC_PORT. Configure DAPR_API_TOKEN when the sidecar requires authentication.
Workflow operations use gRPC; changing an HTTP timeout is not a workflow execution deadline.
The DI transport defaults to no SDK send/receive size limit. Explicit limits must be positive byte counts.
Sidecar, scheduler, and state-store limits still apply independently. Consider
data references rather than increasing limits for large payloads.
WorkflowRuntimeOptions.UseGrpcChannelOptions also supports setting the send/receive limits; the registration
does not copy every property of that GrpcChannelOptions object.
Worker history caching
The worker enables stateful history by default: the sidecar can send new events rather than the complete history
on every turn. A cache miss is recovered through the history RPC; the cache is not durable application state.
false; set true to always receive full history and ignore cache settings.
HistoryCacheTtl
null uses a one-hour sliding TTL. Explicit values must be positive.
HistoryCacheMaxInstances
0 uses the built-in limit of 100,000 instances per work-item stream.
HistoryCacheMaxBytes
0 means no byte budget; instance count and TTL still apply.
Capacity values cannot be negative. Entries are evicted using least-recently-used ordering when a capacity limit
is exceeded. This cache does not eliminate workflow replay or automatically cache resolved binary payloads.
MaxConcurrentWorkflows and MaxConcurrentActivities on WorkflowRuntimeOptions are obsolete and have no
effect. Configure dispatch concurrency in the Dapr runtime instead. For concurrency within one workflow, see
bounded parallel processing.
Inject services into activities
Activities are activated through dependency injection in a scope for each activity invocation. Register their
dependencies normally:
WorkflowActivityContext exposes Identifier, InstanceId, TaskExecutionKey (not TaskExecutionId), and
ScheduledEventId, the ID of the TaskScheduled history event for the activity attempt.
The worker uses the runtime’s task execution ID when available, falling back to the numeric task ID otherwise.
SDK activity retries preserve the task execution ID. Combine the key with the workflow instance/app identity when
designing an idempotency key, and persist deduplication atomically with the side effect. The identifier alone does
not make an activity exactly-once. ScheduledEventId can be recorded alongside the instance ID and later passed
to RerunWorkflowFromEventAsync to rerun the workflow from that activity (see
Rerun from a history event).
Replay-safe logging
Use context.CreateReplaySafeLogger<T>() inside workflows. It suppresses log writes during replay. Although
the current factory can activate workflow constructors through DI, the included DAPR1305 analyzer warns against
workflow constructor injection. Keep injected I/O dependencies in activities; they do not make nondeterministic
operations safe during workflow replay.
Use deterministic workflow APIs, activities, retries, timers, events, parallelism, and continuation
This guide covers the authoring APIs in Dapr.Workflow 1.19.0. For runtime prerequisites and the underlying
execution model, see Author workflows. Register the host as described in
client registration.
Define a workflow and activity
Derive from Workflow<TInput, TOutput> and WorkflowActivity<TInput, TOutput>. Workflow input, activity input and
output, custom status, and workflow output are serialized and retained in durable state.
The source generator discovers these classes. You can also explicitly register class or delegate implementations.
Changing a name or payload contract can break replay: keep compatible implementations available or use
workflow versioning.
Keep orchestration deterministic
The worker replays workflow code against recorded history after durable operations complete. Use only durable
workflow tasks inside orchestration:
Need
Workflow API
Current time
context.CurrentUtcDateTime, not DateTime.UtcNow or DateTime.Now.
Unique identifier
context.NewGuid(), not Guid.NewGuid() or random values.
Delay
context.CreateTimer(...), not Task.Delay or thread sleeps.
I/O, remote APIs, mutable configuration, or nondeterministic computation
Put it in an activity and call CallActivityAsync.
Logging
context.CreateReplaySafeLogger<T>() to suppress replay duplicates.
Progress visible to clients
context.SetCustomStatus(value) and state.ReadCustomStatusAs<T>().
Do not use Task.Run, perform network/file access directly, or escape the workflow synchronization context with
ConfigureAwait(false). Task.WhenAll and Task.WhenAny are appropriate for tasks created by workflow APIs.
Use context.IsReplaying for diagnostic suppression, not to change the sequence of durable operations.
Activities may perform I/O and use injected services. They have at-least-once execution semantics, so implement
idempotent side effects even when no explicit retry policy is configured.
Retry activities and child workflows
Attach WorkflowRetryPolicy through WorkflowTaskOptions or ChildWorkflowTaskOptions:
The attempt count includes the first invocation. Retry delays use durable timers. A retry timeout limits further
retry attempts; it is not a hard cancellation deadline for a running activity. With no policy, no SDK retry chain
is configured, but runtime redelivery can still repeat execution.
After retries are exhausted, activity/child failures surface as WorkflowTaskFailedException. Its FailureDetails
contains ErrorType, ErrorMessage, and optional StackTrace; IsCausedBy<TException>() can match resolvable
exception types. Catch this exception to implement explicit compensation, or let the workflow fail. Compensation
is application code, not an automatic rollback of external effects.
Durable timers and external events
Use CreateTimer(TimeSpan, CancellationToken) or CreateTimer(DateTime, CancellationToken) for durable delays.
Derive absolute deadlines from CurrentUtcDateTime. Cancel timers that lose a race with another durable task.
The timeout overload uses a durable timer and cancels the losing wait. Events arriving before a matching wait are
buffered; each event completes one waiter. The .NET worker matches event names case-insensitively, but use consistent
casing across senders and SDKs. A cancellation token on a wait cancels that wait, not the workflow or event producer.
External applications send events using client.RaiseEventAsync. A workflow can send an event to another instance
in its own app without calling a client or performing direct I/O:
SendEvent records a durable action, not a confirmation that the recipient processed the event, and has no
TargetAppId option. Do not assume that signaling a missing or completed instance succeeded at the application level.
Fan-out and bounded parallel processing
For a small, bounded batch, create activity or child-workflow tasks and await Task.WhenAll. For a sliding window,
use the SDK’s ProcessInParallelAsync:
The default concurrency is five and must be positive. Results preserve input order. Failures are collected into
an AggregateException after processing; this is not fail-fast cancellation of other activities.
This bounds outstanding tasks in one workflow, not total worker/runtime concurrency. The helper materializes
the input collection and allocates a result array, so it is not constant-memory processing of an unbounded stream.
Use bounded batches, child workflows, or continuation to limit history growth.
Child and detached workflows
CallChildWorkflowAsync<T> waits for the child’s output and propagates failures. Use ChildWorkflowTaskOptions
to set InstanceId, RetryPolicy, or TargetAppId. Child execution has its own history; recursively terminating a
parent also terminates its children.
Use ScheduleNewDetachedWorkflowAsync for independent work instead:
The returned ID is available when the action is recorded, not when the new workflow completes. The generated ID is
replay-safe if omitted. Detached failures do not fail the originating workflow, and terminating that workflow does
not recursively terminate detached instances. The options overload supports app routing and payload-reference
permission, but rejects retry policies and history propagation.
Return immediately after requesting continuation; the next execution uses the new input rather than completing
the instance with the returned value. Do not schedule more work after the call. Incomplete task results are
discarded; buffered unprocessed external events are preserved by default. Set preserveUnprocessedEvents: false
to discard those events too.
Testing boundaries
The package includes compiler analyzers: DAPR1303 and DAPR1304 warn about input/output mismatches when an
activity or workflow type can be resolved from nameof(...); DAPR1305 warns about workflow constructor
injection. Prefer nameof for local concrete types, but use the registered/canonical string when routing requires
it. Analyzers cannot validate every dynamic name or remote contract, and a warning-free build does not prove
determinism.
Unit-test activity logic and its injected dependencies normally. Workflow unit tests can supply a test double for
the abstract WorkflowContext to check decisions, scheduled calls, and outputs, but do not prove replay or runtime
durability. Exercise replay, failures/redelivery, durable event timeouts, cross-app routing, version upgrades, and
history/payload limits with a running sidecar and representative durable stores.
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.
For large payloads, workflow data references (alpha) can store
serialized data in a binary store instead of inline in history. Offloading is separate from serializer selection
and happens after serialization. Unlike serializer configuration alone, it requires a runtime with binary-store
support. Registration alone preserves ordinary inline values; select PayloadWritePreference.PreferReference to
enable automatic offloading. Cross-app call inputs remain inline unless the call explicitly allows references.
Forwarding references requires compatible, payload-aware destinations with access to the same referenced
objects. The JavaScript SDK also supports the version-1
reference envelope from @dapr/dapr 3.19.0, provided serialization and storage access are compatible
and payload stages do not require reversal. Do not assume other language SDKs support data references.
Note
This feature requires Dapr .NET SDK v1.17.0 or later.
Compatibility and breaking changes
Warning
Changing serialization can be a breaking change for existing workflows. There is no supported migration path between serialization implementations.
All Dapr SDKs use a standard JSON convention by default. If you change the serialization settings or switch to a custom serializer in your .NET workflows and activities, cross-SDK workflows may fail because other SDKs might not support your custom serialization format.
“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 optional.options.RegisterWorkflow<MyWorkflow>();options.RegisterActivity<MyActivity>();}).WithJsonSerializer(newJsonSerializerOptions{PropertyNamingPolicy=null});
The DI-registered client and worker use these settings for payloads. Independently constructed clients must use
compatible settings too. Serializer instances are shared, so custom implementations must be safe for concurrent use.
Custom serialization providers
In SDK 1.19.0, serializers implement IDaprSerializer from Dapr.Common.Serialization.
IWorkflowSerializer from Dapr.Workflow.Serialization extends that shared interface and remains supported,
including by the factory-based registration overload. Registering an IWorkflowSerializer directly in DI is
marked [Obsolete] and will be removed in the SDK release coinciding with Dapr v1.20; prefer implementing
IDaprSerializer for new code. Implement all four methods, including Serialize<T>.
The following example shows a MessagePack-based implementation that encodes data as Base64 strings for transport.
It also requires the MessagePack NuGet package; choose serialization options compatible with your DTO contracts:
usingDapr.Workflow.Serialization;usingMessagePack;publicsealedclassMessagePackWorkflowSerializer:IWorkflowSerializer{privatereadonlyMessagePackSerializerOptions_options;publicMessagePackWorkflowSerializer(MessagePackSerializerOptionsoptions){_options=options;}publicstringSerialize<T>(Tvalue)=>Serialize(value,typeof(T));/// <inheritdoc/>publicstringSerialize(object?value,Type?inputType=null){if(value==null){returnstring.Empty;}vartargetType=inputType??value.GetType();varbytes=MessagePackSerializer.Serialize(targetType,value,_options);returnConvert.ToBase64String(bytes);}/// <inheritdoc/>publicT?Deserialize<T>(string?data){return(T?)Deserialize(data,typeof(T));}/// <inheritdoc/>publicobject?Deserialize(string?data,TypereturnType){if(returnType==null){thrownewArgumentNullException(nameof(returnType));}if(string.IsNullOrEmpty(data)){returndefault;}try{varbytes=Convert.FromBase64String(data);returnMessagePackSerializer.Deserialize(returnType,bytes,_options);}catch(FormatExceptionex){thrownewInvalidOperationException("Failed to decode Base64 data. The input may not be valid MessagePack-serialized data.",ex);}catch(MessagePackSerializationExceptionex){thrownewInvalidOperationException($"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 optional.options.RegisterWorkflow<MyWorkflow>();options.RegisterActivity<MyActivity>();}).WithSerializer(newMessagePackWorkflowSerializer(MessagePackSerializerOptions.Standard));
If you need DI-provided configuration, use the overload that receives an IServiceProvider:
builder.Services.AddDaprWorkflowBuilder(options=>{// Explicit registration is optional.options.RegisterWorkflow<MyWorkflow>();options.RegisterActivity<MyActivity>();}).WithSerializer(serviceProvider=>{varoptions=serviceProvider.GetRequiredService<IOptions<MessagePackSerializerOptions>>().Value;returnnewMessagePackWorkflowSerializer(options);});
Offload large workflow payloads to a binary store and defer reading them with explicit data references
Workflow data references let you keep large payloads out of workflow history. The .NET SDK stores the serialized
payload in a Dapr binary store and records a small reference instead. You can either keep your existing workflow
and activity types and let the SDK retrieve the data automatically, or pass PayloadReference<T> values and read
the data only where it is needed.
Alpha feature
The binary store is an alpha building block and is subject to change. The .NET SDK’s workflow data-reference
implementation is also alpha; its APIs and behavior are subject to change.
These APIs are available from Dapr.Workflow 1.19.0. Earlier packages do not include them.
Cross-app SDK compatibility
Cross-app activity and workflow call inputs are inline by default. When using cross-app workflows with other .NET
applications, register WithBinaryStorePayloads(...) on every participating .NET application (with access to the
same binary store) to preserve compatibility across return values, propagated history, and forwarded references—and
never unregister it once enabled. The JavaScript SDK also
supports the version-1 reference envelope from @dapr/dapr 3.19.0, with compatible serialization and
storage access, but does not implement payload-stage reversal. Do not assume other SDKs support this scheme. See
Cross-app input handling for requirements and limits.
Use this guide in the order that fits your application:
Full serialized values stay in history. No binary-store registration or access.
Small payloads that fit comfortably in workflow history.
Automatic offloading (PreferReference)
DocumentPayload
Eligible large values are stored externally. The SDK downloads and deserializes them wherever an ordinary value is required, including each workflow execution that receives the document. Cross-app inputs remain inline unless explicitly allowed.
Stepping-stone migrations toward explicit references, or ensuring compatibility with other .NET apps that already use data references without immediately changing local signatures.
Explicit references
PayloadReference<DocumentPayload>
The workflow can forward a reference without downloading the document. An activity or client explicitly resolves it to an object or opens a stream.
Workflows that coordinate steps without inspecting the payload themselves (deferring data access to the specific activities that need it), especially in single-app workflows or multi-app topologies where all participating applications use .NET and can be updated to support binary-store payloads.
Performance and memory tradeoffs
Both offloading modes shrink workflow history traffic: in the SDK benchmark (a 2 MB
document across nine activities), both modes reduced workflow protobuf traffic by ~99.9%.
Automatic offloading does not eliminate repeated materialization: because the workflow signature still takes
DocumentPayload, the SDK downloads and deserializes the payload on every replay. Automatic offloading
allocated 1.39x as many managed bytes as inline processing in this
benchmark. Enable automatic offloading primarily when you are migrating toward explicit data
references or when you need cross-app compatibility with another .NET application that already uses data
references, even if this application only uses automatic offloading for now.
Explicit references defer materialization: passing PayloadReference<T> avoids downloading or deserializing the
payload until an activity explicitly resolves or streams it, reducing both execution time and managed allocations.
This is the best fit when you do not use multi-app workflows, or when all participating applications use .NET and
can be updated to support binary-store payloads.
How references work
Offloading happens after serialization, based on the UTF-8 byte count of the serialized value. The reference
envelope contains the store name, key, size, SHA-256 hash, and format version. Stored keys are content-addressed and
scoped to a workflow instance. Existing references pass through under Preserve and PreferReference, except
when a cross-app input requires inlining. PreferInline resolves outgoing references instead.
Set up payload support
Prerequisites
Dapr.Workflow 1.19.0 or later. Payload support is included in Dapr.Workflow.
Dapr runtime 1.19.0 or later.
A configured binary-store component, in addition to the workflow’s usual actor state store, placement, and scheduler.
Payload support registered on every client and worker that writes or reads these workflows, with access to the
referenced component and compatible serialization settings.
The SDK manages the references; the workflow runtime protocol does not need native data-reference support.
Adding a component alone does not enable offloading.
Configure a binary-store component
For a disposable local experiment, add this component to the sidecar’s resources directory:
This component loses all payloads when the sidecar stops, even if workflow history remains in a durable state store, and
is strictly intended for ephemeral testing. Those histories can no longer resolve their references. Use durable
binary storage for workflows that must survive restarts, and retain payloads for as long as any history or
consumer needs them.
Register payload support
Chain WithBinaryStorePayloads from AddDaprWorkflow or AddDaprWorkflowBuilder. For example, in an application’s
startup code, register the workflow and activity types used below:
StoreName and endpoints:StoreName must match the component name. If you use a non-default sidecar gRPC
endpoint, configure DAPR_GRPC_ENDPOINT consistently for the workflow and binary-store clients.
Builder chaining:WithBinaryStorePayloads extends the workflow builder, not IServiceCollection. For
subsequent service registrations, use builder.Services or the returned workflow builder’s Services property.
Host-wide scope: Offloading is configured for the host, not per workflow. Do not mix payload-aware and
payload-unaware workers for the same app ID while offloaded workflows are running.
Keep payload support registered indefinitely
Once you opt into WithBinaryStorePayloads(...) in any .NET application—including any application that participates
in multi-app workflows with it—never unregister it, even if you later opt out of creating references. Retained
workflow histories, results, and events can still contain data references that require the registered services to
read. Instead, opt out of future offloading by setting WritePreference = PayloadWritePreference.PreferInline while
keeping WithBinaryStorePayloads(...) registered; see Switch back to inline payloads.
Use payloads
The examples below use the registration above, which selects PreferReference. For other write behaviors and limits,
see Configuration and policy reference.
Use automatic offloading
Keep ordinary input and output types. The SDK stores values that meet OffloadThreshold and resolves them before
passing them to your application code. These types work with or without WithBinaryStorePayloads:
With the registration above, the document exceeds 64 KiB and is offloaded at the workflow and activity input
boundaries, while the small integer result stays inline. Without payload registration—or with the default Preserve
preference and an ordinary input—the same document stays inline. You can safely change OffloadThreshold between
builds or deployments: it only affects whether newly written workflow history items are inlined or offloaded, and has
no impact on automatic resolution for existing workflows. (By contrast, changing MaxInMemoryRehydrationSize is not
safe without planning because it applies to all existing references; see
Size and change MaxInMemoryRehydrationSize safely.)
Because DocumentWorkflow accepts DocumentPayload, the SDK still downloads and deserializes the payload each time
the workflow replays; previously downloaded documents are not cached across executions.
Pass explicit references
To avoid materializing the document in the workflow itself, change the workflow and activity inputs to
PayloadReference<DocumentPayload> and inject IWorkflowPayloadResolver into the activity that consumes it.
(For existing workflows, introduce contract changes in a new version; see
Adopt data references in existing workflows.)
Creating references: Call StoreAsync in client or activity code, never in replayed workflow code.
StoreAsync writes the value regardless of WritePreference or OffloadThreshold.
Write preference: Use Preserve or PreferReference for reference-typed workflows; PreferInline would
convert the outgoing reference back into inline document content and fail the receiving contract.
Retries: Activity retries can download the payload again; explicit references do not guarantee an
exactly-once download.
In-workflow resolution: If the workflow itself must read the value, call
await context.ResolvePayloadAsync(reference) rather than calling the binary-store client directly.
context.HasPayloadResolver() reports whether support is registered. Resolved values are cached only for the
current workflow execution (up to ResolutionCacheSize), not across replays.
Stream large payloads
ResolveAsync<T> buffers the serialized payload before deserialization and is subject to
MaxInMemoryRehydrationSize. A typed reference alone does not remove that limit. For larger data, use
IWorkflowPayloadResolver.OpenAsync(reference) in an activity or client (or context.OpenPayloadAsync(reference) in
a workflow, though activity processing is preferred to avoid replay overhead) and consume the stream
incrementally with asynchronous reads and await using:
Serialized content: The stream contains the full serialized bytes, not just a document’s inner property.
Write buffering: Streaming avoids buffering the whole payload on read, but StoreAsync still serializes the
value in memory on write.
Manual integrity check: Streaming does not automatically verify the content hash. Consumers must check the
bytes against reference.ContentHash and, where appropriate, reference.Size. See the
streaming activity example
for bounded-buffer reads and SHA-256 verification.
Configuration and policy reference
Configure offloading and read limits
Configure BinaryStorePayloadOptions in WithBinaryStorePayloads(...):
Option
Default
Purpose
StoreName
Required
Binary-store component name.
KeyPrefix
"workflow-payloads"
Prefix for instance-scoped, content-addressed keys.
WritePreference
PayloadWritePreference.Preserve
Choose Preserve, PreferReference, or PreferInline for future writes, including events. Cross-app activity and workflow call inputs require inline data unless explicitly allowed per call.
OffloadThreshold
64 * 1024 bytes
Under PreferReference, offload serialized values at or above this size. null offloads every non-empty payload at enabled boundaries; numeric values must be positive.
OffloadSites
PayloadKind.All
Select which boundaries can automatically create references under PreferReference. Does not restrict inlining or reads.
MaxInMemoryRehydrationSize
16 * 1024 * 1024 bytes
Maximum serialized payload size for buffered resolution, including ResolveAsync<T> and outbound inlining. This is not a transport limit or a limit on total process memory.
VerifyIntegrityOnRead
true
Verify SHA-256 for buffered reads. Does not verify streams returned by OpenAsync.
ResolutionCacheSize
128
Maximum resolved entries cached by the workflow context resolver per execution. 0 disables this cache.
SkipWriteIfExists
true
Treat an already-existing content-addressed object as a successful write. Does not guarantee that a duplicate upload attempt transfers no bytes.
PayloadKind flags cover workflow and activity inputs/outputs, external events, continue-as-new input, custom
status, and child workflow inputs/outputs (detached workflow inputs and rerun overrides use PayloadKind.WorkflowInput;
termination outputs use PayloadKind.WorkflowOutput). Combine flags to restrict automatic offloading:
Buffered reads throw PayloadRehydrationLimitException when exceeding MaxInMemoryRehydrationSize,
PayloadNotFoundException when the object is missing, or PayloadIntegrityException when hash verification fails.
Retain compatible payload readers and serialization settings for existing histories; changing the serializer does
not migrate stored data.
Size and change MaxInMemoryRehydrationSize safely
Unlike OffloadThreshold (which only affects future writes), MaxInMemoryRehydrationSize is not safe to change
casually. It is enforced on every buffered read and inlining operation—including references already recorded in
workflow history. Lowering it below an existing payload’s declared or actual size can cause:
Ordinary T inputs and outputs to throw PayloadRehydrationLimitException during materialization.
Cross-app boundary inlining to fail before the call is sent.
PreferInline conversion of existing references to fail.
Running workflows to fail when replay or a later turn encounters an older reference.
Sizing best practices
Keep the 16 MiB default unless measured requirements justify changing it.
Allow modest headroom: Set the limit above your expected serialized payload size with room for natural growth.
Account for concurrent memory pressure: Keep the limit below a size that would cause excessive memory usage when
multiplied by concurrent resolutions. For example, a 32 MiB limit with 20 concurrent materializations can require
roughly 640 MiB for payload buffers alone, plus additional allocations during UTF-8 conversion and deserialization.
Prefer streaming for larger documents: Use PayloadReference<T> and stream via OpenAsync rather than raising
the in-memory limit indefinitely.
Align with transport limits: If cross-app inlining is expected, ensure materialized payloads also fit the
configured Dapr runtime and gRPC transport limits, including message overhead. Raising MaxInMemoryRehydrationSize
does not raise transport limits.
Test boundary behavior: Exercise payloads near and just above the configured limit in load and integration tests.
Changing MaxInMemoryRehydrationSize later
Treat MaxInMemoryRehydrationSize as a host compatibility and memory-safety boundary: keep it consistent across all
workers that can process the same workflow versions, and change it only through a reviewed deployment rather than
runtime mutation.
Increasing the limit is generally backward-compatible, but requires reassessing memory concurrency and transport
limits.
Lowering the limit can break existing workflows whose histories contain references larger than the new value
(failing during replay, typed state reads, automatic deserialization, PreferInline conversion, or cross-app
inlining). Before lowering it:
Identify the largest referenced payloads used by active workflow instances.
Change large-payload consumers to accept PayloadReference<T> and stream content via OpenAsync.
Version workflow and activity contracts when changing ordinary T inputs to PayloadReference<T> inputs.
Keep old workflow versions and their previous limit available until existing instances complete or are retired.
Roll out the lower limit gradually and monitor for PayloadRehydrationLimitException.
Choose a write preference
BinaryStorePayloadOptions.WritePreference controls conversion of future outgoing top-level payloads.
It defaults to PayloadWritePreference.Preserve; registration alone does not enable automatic payload offloading.
Preference
Ordinary outgoing value
Outgoing reference
Preserve (default)
Serialize inline; no automatic upload.
Keep the reference.
PreferReference
Upload if the serialized value meets OffloadThreshold at an enabled OffloadSites boundary. Otherwise keep inline.
Keep the reference; do not upload it again.
PreferInline
Serialize inline; no automatic upload.
Resolve to its original serialized content and write that content inline. Fail if resolution cannot complete safely.
Important policy rules:
Future writes only: Write preferences affect future writes from both new and running workflows. They do not
rewrite retained history, persist an instance-level setting, infer policy from a workflow’s input, or automatically
version workflows or activities.
No object provenance under Preserve:Preserve means no automatic outbound conversion. If the SDK resolves a
reference to an ordinary T and your code later returns that T, the output is serialized inline as a new value.
Histories can legitimately contain both inline values and references.
Stopping automatic offloading: Set WritePreference = PayloadWritePreference.Preserve (or
OffloadSites = PayloadKind.None under PreferReference) to stop automatic reference creation while keeping
reference reads and explicit StoreAsync writes active. To convert outgoing references back to inline data, see
Switch back to inline payloads.
Inbound handling preserves the recorded representation
Inbound handling is independent of WritePreference and works the same for local, cross-app, and event inputs:
Received payload
Receiving contract
Behavior
Inline data
Ordinary T
Deserialize normally.
Reference envelope
Ordinary T
Resolve and deserialize using registered payload support.
Reference envelope
PayloadReference<T>
Pass the reference through without automatically materializing it.
Inline data
PayloadReference<T>
Report a contract mismatch; do not upload data to manufacture a reference.
Resolving a reference for application code does not rewrite workflow history or mutate retained wire metadata.
Received data also does not establish an outbound preference or prove the sender can consume reference-valued
responses. Unsupported envelope versions, missing data, integrity failures, and buffering-limit violations surface
as errors rather than falling back to ordinary application data.
Cross-app and external boundaries
Cross-app input handling
An automatic boundary switch makes binary-store payloads compatible with multi-app workflow calls by default for
top-level inputs. When an activity, child workflow, or detached workflow targets another app (TargetAppId differs
from the current app), the SDK marks the boundary as requiring inline data, overriding a host’s PreferReference
setting:
An ordinary input is kept inline.
Any top-level PayloadReference<T> is resolved by the source app (after replay filters out already-recorded calls,
so storage I/O does not run directly inside the deterministic workflow body) and its original serialized payload is
sent inline before Dapr routes the call, without another deserialize/serialize cycle.
Key scope of the cross-app boundary switch:
Top-level cross-app inputs only: Nested references inside DTO properties, return values/outputs, and propagated
history are not automatically converted. Because those paths can still carry references, every .NET application
participating in a multi-app workflow should register WithBinaryStorePayloads(...) (and never unregister it once
enabled) to preserve compatibility.
Source app read access required: The source app must have payload support registered and access to the referenced
object in the binary store.
Transport limits still apply: Normal Dapr, gRPC, scheduler, and state-store message-size limits apply after the
payload is materialized inline.
Per-call opt-out: Set AllowPayloadReferences: true when both apps support references and share access to the
underlying binary store.
Configure the source app ID correctly: if it is unavailable, any non-empty TargetAppId is treated as cross-app and
inlined by default. Explicit same-app targets follow the normal host preference.
Use the inline default
For a top-level cross-app input, the sender resolves reference and sends ordinary T data inline (the sender still
needs WithBinaryStorePayloads(...) and storage access to resolve reference; if the destination is another .NET
application, register WithBinaryStorePayloads(...) there as well to preserve compatibility across return values and
propagated history):
Explicitly allow references for a known destination
Opt out per call with AllowPayloadReferences: true only when all of the following hold:
The destination supports the version-1 envelope with compatible payload contracts and serialization settings.
The JavaScript SDK supports unstaged references from @dapr/dapr 3.19.0; it does not implement
payload-stage reversal. Do not assume other language SDKs support this scheme.
The destination registers WithBinaryStorePayloads(...) in .NET, or a
payload resolver in JavaScript
(upgrading the SDK alone is not enough).
Both apps can access the same underlying storage objects via the named binary-store component in their respective
sidecars (matching component names pointing to separate in-memory stores do not share data).
The sending host uses Preserve or PreferReference (PreferInline still forces inline content).
AllowPayloadReferences applies only to that call’s input and is retained across SDK retries. Detached workflow
options support routing and payload permission only; retry policies and history propagation are rejected.
Limits and scope
Cross-app inlining and PreferInline are inline-or-error, not best-effort fallbacks:
Strict validation: Resolution checks MaxInMemoryRehydrationSize (against both declared size and bytes read)
and verifies the SHA-256 hash when VerifyIntegrityOnRead is enabled. Missing storage, unsupported envelopes, or
limit/integrity failures prevent the call from being submitted; the SDK never silently falls back to sending a
reference.
No recursive or return-path rewriting: Because only top-level call inputs are converted, plan compatible
contracts, write preferences, and storage access independently for nested PayloadReference<T> properties, return
values, and propagated history.
Event payload handling
Event APIs do not expose TargetAppId or AllowPayloadReferences and instead follow the sender’s WritePreference:
Event path
Outgoing policy
context.SendEvent
Uses the workflow host’s WritePreference. The target instance is in the current app; local events are not forced inline.
Client RaiseEventAsync
Uses the publishing client’s configured WritePreference. The destination is determined by the sidecar endpoint configured for that client.
Under PreferReference, automatic event offloading is controlled by PayloadKind.ExternalEvent in OffloadSites
and OffloadThreshold. Inbound events follow the standard
inbound handling rules.
Because an external application can point a client at a destination’s sidecar and call RaiseEventAsync, same-app
routing does not guarantee compatible SDK support. When publishing from an external client to an app with unknown
reference support, configure WritePreference = PayloadWritePreference.PreferInline on that client (noting that
this applies to all payload writes from that client and follows the same inline-or-error limits).
Migration, rollback, and lifecycle
Adopt data references in existing workflows
Automatic offloading is a storage-format change; explicit references are an application-contract change.
Approach
Compatibility with existing workflows
Inline
Unchanged by default. AddDaprWorkflow(...) alone does not enable payload offloading.
Automatic offloading
Safe to enable for in-flight workflows without versioning. Existing T contracts stay unchanged; the SDK reads both historical inline values and new reference envelopes.
Explicit references
Changing T to PayloadReference<T> is a breaking contract change for histories containing inline T. Preserve the old contract and introduce PayloadReference<T> in a new workflow version.
Enable automatic offloading safely
Enable WritePreference = PayloadWritePreference.PreferReference in WithBinaryStorePayloads(...). Historical
inline values remain untouched, and outbound offloading applies only to newly emitted payloads after replay has
consumed recorded actions. Adjusting OffloadThreshold between builds is also safe—it only governs newly written
history items and does not affect how existing inline or referenced payloads are resolved. By contrast, do not lower
MaxInMemoryRehydrationSize below existing referenced payload sizes; see
Size and change MaxInMemoryRehydrationSize safely.
Introduce explicit references with file-based versioning
Use name-based workflow versioning by copying the
workflow to a new file and giving the class a versioned name (such as DocumentWorkflowV2; renaming the file alone
does not create a version):
Add new activities and the V2 workflow. Create distinctly named activities for any changed PayloadReference<T>
input or output contracts while keeping the original activities registered.
Route new instances to V2. Configure name-based versioning and keep both workflow versions registered (manual
registration is no longer needed for workflows as of SDK v1.17.0 or activities as of v1.18.0). V2 can keep the
original workflow input type T and obtain references from its new activities, unless V2 also changes its
entry-point input to PayloadReference<T> (in which case clients must call StoreAsync before scheduling V2).
Note that automatic offloading remains host-wide: enabling PreferReference on a shared host affects eligible
payloads emitted by both V1 and V2.
When patch-based versioning is appropriate
context.IsPatched(...) can guard a new branch that calls reference-producing activities while keeping the
workflow’s original entry-point input type T. It cannot guard a change to the workflow’s entry-point input
type because the worker deserializes the input before invoking the workflow body.
Switch back to inline payloads
Never unregister WithBinaryStorePayloads(...), either in the primary application or in any .NET application that
participates with it in multi-app workflows. Instead, keep WithBinaryStorePayloads(...) and your binary-store
configuration in place so historical workflows that used data references remain readable, and opt out of future
offloading by preferring inline payloads:
From automatic offloading (ordinary T contracts): Set WritePreference = PayloadWritePreference.PreferInline:
PreferInline keeps future ordinary values inline and resolves any outgoing references before writing, without
rewriting existing history or disabling reference reads.
From explicit references (PayloadReference<T> contracts): Introduce a new workflow version and separately
named activities that accept ordinary T contracts, and stop calling StoreAsync in the new path. While old and
new versions share a host, use Preserve (not PreferInline, which would inline references still expected by
the old version’s PayloadReference<T> receivers) and keep AllowPayloadReferences: true on any old cross-app
calls that still expect references.
Operate workflows with stored payloads
Reading workflow metadata can download payloads
Client methods that fetch inputs and outputs attempt to resolve references before returning, while ReadInputAs<T>,
ReadOutputAs<T>, and ReadCustomStatusAs<T> remain synchronous:
state.ReadOutputAs<DocumentPayload>() returns the deserialized document.
state.ReadOutputAs<PayloadReference<DocumentPayload>>() exposes the retained reference (if the recorded value was
offloaded), though requesting the reference after fetching metadata does not undo the download already performed by
the fetch.
If a payload exceeds MaxInMemoryRehydrationSize during metadata fetch, the state retains the reference so you
can still read PayloadReference<T> and stream it via OpenAsync, whereas calling ReadOutputAs<T>() throws
PayloadRehydrationLimitException. Other read errors (missing objects, integrity failures) fail the metadata fetch
immediately.
When you only need completion status, avoid downloading inputs and outputs (and keep custom status small, as it is
resolved unconditionally):
varstate=awaitclient.WaitForWorkflowCompletionAsync(instanceId,getInputsAndOutputs:false);if(state.RuntimeStatus!=WorkflowRuntimeStatus.Completed){thrownewInvalidOperationException($"Workflow {instanceId} ended with {state.RuntimeStatus}.");}
Retain and clean up payloads
Workflow completion and history purging do not automatically delete binary objects, and deleting objects still
referenced by any history, result, child workflow, or rerun will break replay or inspection:
Register a durable manifest: The default IWorkflowPayloadManifest is a no-op. To track objects for cleanup
across restarts, implement a durable IWorkflowPayloadManifest and register it afterWithBinaryStorePayloads via builder.Services.WithPayloadManifest<YourDurableManifest>().
Purge after history deletion: Only after the owning workflow history is purged and no other workflow shares
its references (forwarding a reference does not transfer ownership or reference-count it), resolve
WorkflowPayloadCleaner and call PurgeAsync(instanceId). Also account for orphaned payloads uploaded via
StoreAsync when a subsequent scheduling call fails.
Benchmark comparison
The SDK benchmark
compares three ways to process the same 2,000,000-byte serialized document through nine sequential
activities. The first eight activities coordinate work using small values. Only activity nine needs the document,
and all three modes compute the same checksum.
Case
Workflow input
Storage and materialization
Inline
BenchmarkDocument
No payload registration. Full document in workflow history; deserialize at each workflow execution.
Automatic
BenchmarkDocument
Data in the binary store, references in history. Automatically retrieve and deserialize for each execution and the processing activity.
References
PayloadReference<BenchmarkDocument>
Replay only the reference. Retrieve and deserialize once, in activity nine.
The current benchmark explicitly selects PreferReference for Automatic and Preserve for References.
The following sample results are not a latency or throughput guarantee:
Mode
Median total ms
Median execution ms
Min..max total ms
Workflow protobuf bytes
Binary download bytes
Document deserializations
Allocated bytes
Total speedup
Inline
302.9
302.9
269.0..370.2
12,010,505
0
11
74,661,448
1.00x
Automatic
224.9
202.9
175.2..769.2
12,254
22,000,000
11
103,614,880
1.35x
References
122.7
113.4
109.6..204.6
12,288
2,000,000
1
14,869,544
2.47x
Except for the min/max range, the measurements shown are medians. Total time includes preparation and execution.
Both offloaded cases explicitly store the initial document before scheduling so that preparation
(serialization/upload) can be measured separately. Execution time covers scheduling through completion, including
any subsequent offloading. Total speedup is the inline median total time divided by the mode’s median total time.
What the results justify
Smaller workflow traffic with either offloading approach: measured workflow protobuf bytes fell by about
99.9%, from 12.01 MB to about 12.3 KB. This is workflow message traffic, not the persisted history size or total
network traffic.
Automatic offloading allocates more than inline: it was 1.35x faster in this run (25.8% less
total time), but deserialized the document 11 times, downloaded 22 MB, and allocated 1.39x as many managed
bytes as inline processing.
Deferring materialization matters: explicit references were 2.47x faster than inline processing (59.5%
less total time) and allocated 80.1% fewer managed bytes. Compared with automatic offloading, document
deserializations dropped from 11 to 1 and binary downloads dropped by 90.9%, from 22 MB to 2 MB.
Median speedups do not eliminate timing variability: the total-time ranges for automatic offloading
(175.2..769.2 ms) overlap those for both inline (269.0..370.2 ms) and explicit references (109.6..204.6 ms).
Median speedups do not imply that every run is faster than another mode.
These results support using explicit references when most workflow steps do not need the document. Automatic
offloading can reduce history pressure with less application change, but moves repeated reads to the binary store
rather than eliminating them.
Measurement scope and limitations
Workflow protobuf bytes count serialized message bodies sent and received for scheduling, work items, completions,
and history fetches. They exclude status queries, health pings, transport framing, headers, TLS, and backend traffic.
Binary download bytes count file content returned to the SDK; the table does not include binary upload bytes.
Do not interpret the 99.9% reduction as an equivalent reduction in total network traffic.
“Document deserializations” is the benchmark’s Doc reads count, not a count of storage calls: inline mode also has
11 deserializations despite making no binary-store reads. Allocated bytes are process-wide managed allocations
in the measurement window, not peak memory or sidecar allocations.
Connection warmup, dataset generation, host startup/shutdown, result inspection, and cleanup are outside the timed
window. Completion waits do not request inputs or outputs, avoiding an extra metadata download. Instrumentation,
including protobuf sizing and UTF-8 byte counting, adds work and contributes to the measured timings and allocations.
The benchmark’s supplied component is an in-memory binary store, which minimizes backend latency. The summary above
does not specify hardware, runtime version, iteration count, or history-cache setting; those details should accompany
results used for capacity planning. The benchmark can run with history caching enabled or disabled. Cached history
can reduce repeated inline transfers without eliminating document deserialization; do not generalize full-history
results to cached-history deployments.
Before choosing a mode for production, repeat the benchmark with representative payloads, replay patterns, and
durable storage. Follow the benchmark README to capture both history-cache configurations and the JSON report of
individual samples. Small payloads or workflows that need the document on every execution may not benefit from
external storage.
Call activities, children, and detached workflows hosted in another Dapr application
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:
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.
Data-reference compatibility
With the workflow data-reference implementation (alpha),
cross-app activity, child workflow, and detached workflow inputs are inline by default, even when the sending
host uses PayloadWritePreference.PreferReference. The sender resolves a top-level PayloadReference<T> before
submitting the call, so the destination should accept ordinary T and need not enable payload support for that input.
The sender needs registered payload support and storage access to inline a reference; resolution errors prevent
submission rather than falling back to sending the reference.
Set AllowPayloadReferences: true on the call’s WorkflowTaskOptions or ChildWorkflowTaskOptions only for a
compatible, payload-aware destination. This permits the host’s write preference; it does not force offloading
or override PreferInline. Each reader must resolve the reference’s store name and key to the same binary object
through its own sidecar, with compatible serialization and contracts. Separate in-memory stores do not share objects.
The JavaScript SDK also supports the version-1 envelope from
@dapr/dapr 3.19.0 with compatible serialization and storage access, but does not implement payload-stage
reversal. Do not assume other language SDKs support data references. The inline default covers only top-level call
inputs, not nested references, results, or propagated history. See
Cross-app input handling for examples and limits.
Call an activity in another application
Set TargetAppId on WorkflowTaskOptions when calling an activity to execute it in another app:
When calling a workflow in another application, you are responsible for using the workflow name expected by that app.
If the target application is a .NET app that uses named workflow versioning, you can call it by its canonical (unversioned)
workflow name and the target app will route it to the latest version. Named workflow versioning requires Dapr runtime
v1.17.0 or later (multi-app workflows only require v1.16.0+).
Schedule a detached workflow in another application
SDK/runtime 1.19 also support independent, replay-safe detached workflow scheduling from the workflow context:
Unlike CallChildWorkflowAsync, this returns the scheduled ID without waiting for output. The detached instance
has independent failure and termination semantics: terminating the source workflow does not terminate it.
The options overload allows TargetAppId and AllowPayloadReferences, but rejects RetryPolicy and any
PropagationScope other than None.
Events and client targeting
WorkflowContext.SendEvent does not accept TargetAppId; it signals an instance in the current application.
For external event submission, DaprWorkflowClient.RaiseEventAsync targets the application associated with the
client’s sidecar, not an arbitrary app ID argument. Do not call a management client directly from deterministic
workflow code to bypass that restriction; put external I/O in an activity.
3.6 - 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 child workflows. Call options also expose propagation scopes for activities, but the public .NET reader is
WorkflowContext.GetPropagatedHistory() on a workflow, not a method on WorkflowActivityContext.
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
This feature requires Dapr .NET SDK v1.18.0 or later and Dapr runtime v1.18.0 or later.
Propagation scopes
Propagation is opt-in and per-call. Each call to CallActivityAsync or CallChildWorkflowAsync can independently specify a HistoryPropagationScope:
Scope
Description
None
Default. No history is propagated to the callee.
OwnHistory
Propagates the calling workflow’s own events only. Ancestor history is dropped, acting as a trust boundary.
Lineage
Propagates 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:
publicsealedclassMerchantCheckoutWorkflow:Workflow<Order,CheckoutResult>{publicoverrideasyncTask<CheckoutResult>RunAsync(WorkflowContextcontext,Orderorder){// Activity without propagation — default behavior, no opt-inawaitcontext.CallActivityAsync(nameof(ValidateMerchantActivity),order.MerchantId);// Child workflow with full lineage propagationvaroptions=newChildWorkflowTaskOptions().WithHistoryPropagation(HistoryPropagationScope.Lineage);varresult=awaitcontext.CallChildWorkflowAsync<PaymentResult>(nameof(ProcessPaymentWorkflow),order,options);returnnewCheckoutResult(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:
In SDK 1.19.0, the activity scheduling action carries this scope, but WorkflowActivityContext exposes only
Identifier, InstanceId, TaskExecutionKey, and ScheduledEventId. It has no public history reader, and the worker does not
populate an activity history property. Use a child workflow when application code needs to inspect propagated
history; do not assume the activity above can call GetPropagatedHistory().
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.
publicsealedclassProcessPaymentWorkflow:Workflow<Order,PaymentResult>{publicoverrideasyncTask<PaymentResult>RunAsync(WorkflowContextcontext,Orderorder){varhistory=context.GetPropagatedHistory();if(history!=null){foreach(varevtinhistory.Events){// evt.Name, evt.InstanceId, evt.AppId// evt.Activities — activity results for this ancestor// evt.Workflows — child workflow results for this ancestor}}returnawaitcontext.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:
Member
Type
Description
AppId
string
Dapr app ID that ran this workflow
InstanceId
string
Workflow instance ID
Name
string
The name of the workflow
Activities
IReadOnlyList<PropagatedHistoryActivityResult>
Activity results for this workflow, in execution order
Workflows
IReadOnlyList<PropagatedHistoryWorkflowResult>
Child workflow results for this workflow, in execution order
PropagatedHistoryActivityResult type
Each PropagatedHistoryActivityResult is a sealed record describing a single activity invocation:
Member
Type
Description
Name
string
The scheduled name of the activity
Status
PropagatedHistoryStatus
Lifecycle status — Pending, Completed, or Failed
Input
string?
Serialized input payload, or null when unset
Output
string?
Serialized output payload, or null when the activity has not completed
FailureDetails
WorkflowTaskFailureDetails?
Failure details when Status is Failed, otherwise null
PropagatedHistoryWorkflowResult type
Each PropagatedHistoryWorkflowResult is a sealed record describing a single child workflow invocation:
Member
Type
Description
Name
string
The scheduled name of the child workflow
Status
PropagatedHistoryStatus
Lifecycle status — Pending, Completed, or Failed
Output
string?
Serialized output payload, or null when the workflow has not completed
FailureDetails
WorkflowTaskFailureDetails?
Failure details when Status is Failed, otherwise null
Input/output strings retain the configured serialization format; they are JSON only with a JSON serializer.
With binary payload support they can also contain reference envelopes. Propagation does not automatically
materialize all ancestor payloads; apply compatible serialization and
data-reference handling.
PropagatedHistoryStatus enum
PropagatedHistoryStatus reflects how far a task progressed past scheduling:
Value
Description
Pending
The task was scheduled but has not yet completed or failed
Completed
The task completed successfully
Failed
The 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.
Method
Return type
Description
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?)
bool
Gets 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:
Method
Return type
Description
GetActivitiesByName(string)
IReadOnlyList<PropagatedHistoryActivityResult>
All activities matching the given name
TryGetLastActivityByName(string, out PropagatedHistoryActivityResult?)
bool
Gets the most recent activity matching the name
GetWorkflowsByName(string)
IReadOnlyList<PropagatedHistoryWorkflowResult>
All child workflows matching the given name
TryGetLastWorkflowByName(string, out PropagatedHistoryWorkflowResult?)
bool
Gets the most recent child workflow matching the name
Example
varhistory=context.GetPropagatedHistory();if(history!=null){// By app ID — useful in multi-app workflowsvarfromOrderApp=history.GetByAppId("order-app");// By workflow instance IDvarfromSpecificRun=history.GetByInstanceId("checkout-abc123");// By workflow name — returns all matches (e.g. recursion or ContinueAsNew)varcheckoutEvents=history.GetEventsByWorkflowName(nameof(MerchantCheckoutWorkflow));// TryGet for a single match — avoids null ambiguityif(history.TryGetLastWorkflowEventByName(nameof(MerchantCheckoutWorkflow),outvarparentEvent)){// Inspect the parent's activitiesvarfailedActivities=parentEvent.Activities.Where(a=>a.Status==PropagatedHistoryStatus.Failed).ToList();// Or look up a specific activity by nameif(parentEvent.TryGetLastActivityByName(nameof(ValidateMerchantActivity),outvarvalidation)){// 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.
3.7 - Workflow management operations with DaprWorkflowClient
Schedule, inspect, wait for, signal, rerun, and manage workflow instances
DaprWorkflowClient provides the following operations in SDK 1.19.0. Use a Dapr 1.19 runtime for the complete
surface, particularly listing, history inspection, and rerunning from an event. Register or construct a client as
described in client registration.
Schedule a new workflow instance
ScheduleNewWorkflowAsync returns an instance ID, not the workflow result. Provide a registered workflow name and
optional input. Omitting instanceId generates a GUID. A scheduled start in the future delays execution; a past
or omitted start time starts it immediately.
The cancellation-capable overload takes name, instanceId, input, DateTimeOffset? startTime, and
cancellation. The three-argument convenience overload and legacy DateTime? overload do not take a token.
Cancellation stops the client request; it does not terminate an instance that was already scheduled.
When name-based versioning is enabled, prefer the canonical name for
new instances so the target app can choose its latest registered version.
Retrieve the status of a workflow instance
GetWorkflowStateAsync returns a WorkflowState snapshot:
varstate=awaitclient.GetWorkflowStateAsync(instanceId,getInputsAndOutputs:true,cancellation:cancellationToken);if(!state.Exists){thrownewInvalidOperationException($"Workflow {instanceId} does not exist.");}varstatus=state.RuntimeStatus;
Member
Meaning
Exists, WorkflowName
Whether an instance was found and its workflow name.
RuntimeStatus
Current WorkflowRuntimeStatus, including pending, running, suspended, terminal, or unknown state.
IsWorkflowRunning
True only for Running, not for pending or suspended instances.
IsWorkflowCompleted
True for any terminal state: completed, failed, or terminated. It does not imply success.
CreatedAt, LastUpdatedAt
UTC timestamps exposed as DateTimeOffset.
FailureDetails
Failure type, message, and optional stack trace when available.
Deserialize captured values using the configured workflow serializer.
Set getInputsAndOutputs: false to avoid fetching input/output data when polling status. Keep custom status small;
the SDK processes custom status returned by the runtime even when this flag is false. Typed readers do not fetch
missing data later. With data references, metadata reads can also
download binary objects.
A missing instance, including gRPC NotFound, produces Exists == false. Other gRPC failures propagate as
RpcException; do not treat transport failures as missing workflows. Access to failure details depends on the
data included in the runtime response.
Wait for start or completion
WaitForWorkflowStartAsync waits until the instance leaves pending state. WaitForWorkflowCompletionAsync waits
for completed, failed, or terminated state. Both return WorkflowState, can return immediately when the target
state was already reached, and throw InvalidOperationException when the runtime reports no instance.
usingvartimeout=CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);timeout.CancelAfter(TimeSpan.FromMinutes(10));varfinalState=awaitclient.WaitForWorkflowCompletionAsync(instanceId,getInputsAndOutputs:true,cancellation:timeout.Token);if(finalState.RuntimeStatus!=WorkflowRuntimeStatus.Completed){thrownewInvalidOperationException($"Workflow ended with {finalState.RuntimeStatus}: {finalState.FailureDetails?.ErrorMessage}");}varresult=finalState.ReadOutputAs<string>();
Waits retry gRPC Unavailable and DeadlineExceeded with backoff until they succeed or the caller cancels.
Always set a cancellation/deadline appropriate to the application: an eternal workflow might never finish.
Canceling a wait does not cancel the workflow.
The workflow receives the value through WaitForExternalEventAsync<T>("Approval"). Events can be buffered before
the workflow starts waiting. Submission does not acknowledge application processing; do not rely on this call to
confirm that a missing or already-completed target consumed an event.
awaitclient.TerminateWorkflowAsync(instanceId,output:new{Reason="Canceled by operator"},cancellation:cancellationToken);
output is an optional serializable object, not just a string. The request recursively terminates child
workflows, but does not stop in-flight activity executions or independent detached workflows. Request completion
does not mean every affected workflow has already transitioned; query or wait for the resulting state.
Purge a workflow instance
The public method is PurgeInstanceAsync, not PurgeWorkflowAsync:
Only terminal instances can be purged. The SDK requests recursive deletion of child instances and their histories;
the Boolean result reports whether the runtime deleted any instances. Runtime errors still propagate.
Purging is irreversible and does not delete binary payload objects. Follow the
payload retention and cleanup guidance.
List instance IDs with pagination
ListInstanceIdsAsync returns a WorkflowInstancePage containing InstanceIds and an optional
ContinuationToken. Pass the token back unchanged to obtain the next page:
pageSize, when supplied, must be greater than zero. This API lists IDs, not full states; it does not expose
status/date/name filters. Fetch individual states as needed and bound request concurrency for large listings.
Inspect execution history
varevents=awaitclient.GetInstanceHistoryAsync(instanceId,cancellation:cancellationToken);foreach(varhistoryEventinevents){Console.WriteLine($"{historyEvent.EventId}: {historyEvent.EventType} at {historyEvent.Timestamp:O}");}
The returned IReadOnlyList<WorkflowHistoryEvent> contains event ID, event type, and timestamp. It does not
expose raw serialized inputs, outputs, or the full protobuf history. Event types distinguish activity scheduling,
completion/failure, child workflows, timers, events, suspension/resumption, continuation, and detached creation.
Rerun from a history event
RerunWorkflowFromEventAsync creates a new workflow instance from a source history and event ID. It does not
reset the source instance. Select a runtime-supported rerun point from history; an event ID is not a list index,
and not every event is a valid rerun boundary.
usingDapr.Workflow.Client;// Pick a rerun point: the first scheduled activity in the source history.varhistory=awaitclient.GetInstanceHistoryAsync(instanceId,cancellationToken);varscheduledEvent=history.First(e=>e.EventType==WorkflowHistoryEventType.TaskScheduled);varrerunId=awaitclient.RerunWorkflowFromEventAsync(sourceInstanceId:instanceId,eventId:(uint)scheduledEvent.EventId,options:newRerunWorkflowFromEventOptions{NewInstanceId=null,OverwriteInput=true,Input=new{Quantity=3}},cancellation:cancellationToken);
The eventId argument is uint, while WorkflowHistoryEvent.EventId is int, so cast when passing an ID from
history. An activity can also capture its own rerun point via WorkflowActivityContext.ScheduledEventId.
Omit options to retain recorded input and let the runtime allocate an ID.
NewInstanceId optionally chooses the new ID. Set OverwriteInput = true when supplying Input; otherwise a
non-null input is rejected. The override is applied at the next activity event at the rerun point, not as a
general replacement for all earlier history.
Keep the workflow/activity implementations and any referenced payload objects needed by the copied history.
Rerunning is not compensation: earlier external side effects remain, and executing work again still requires
idempotent activities.
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.
Note
Workflow versioning requires Dapr .NET SDK v1.17.0 or later and Dapr runtime v1.17.0 or later.
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:
Patch names can appear multiple times in the same workflow and can be nested.
Keep an existing patch identifier for the same change across deployments. Do not reuse that identifier for a
different change; introduce a new identifier instead. Names are case-sensitive and must not be blank.
On replay, a patch recorded in history returns true; an unrecorded patch returns false while replaying.
When execution advances beyond recorded history, a new patch can return true. Do not assume every existing
instance stays on the old branch forever.
Retain the old branch and patch checks while retained histories still need them. Removing or reordering durable
calls without a compatible versioning transition can stall replay.
IsPatched is available on WorkflowContext; no additional setup is required.
Tip
Keep patch names simple and monotonic (for example, "v2", "v3") so it is clear which deployment introduced each change.
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 NumericVersionStrategy with the case-sensitive prefix V:
MyWorkflow (treated as version 0)
MyWorkflowV2
MyWorkflowV10
Higher numeric values are newer (MyWorkflowV10 is newer than MyWorkflowV2). A raw suffix such as MyWorkflow2
requires NumericVersionStrategyOptions.SuffixPrefix = ""; it is not accepted by the default configuration.
The SDK also includes Date and SemVer strategies and supports custom strategies/selectors.
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: Parses SemVer versions, including explicit [WorkflowVersion(Version = "1.2.3")]
metadata. Use the attribute for dotted versions, prerelease identifiers, and build metadata that cannot appear
in a C# class name.
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.
The option key strings must match exactly or the options will not be applied.
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:
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:
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:
usingDapr.Workflow;usingDapr.Workflow.Versioning;[WorkflowVersion(
CanonicalName = "OrderWorkflow",
Version = "2",
StrategyType = typeof(NumericVersionStrategy))]publicsealedclassRevisedOrderWorkflow:Workflow<string,string>{publicoverrideTask<string>RunAsync(WorkflowContextcontext,stringinput)=>context.CallActivityAsync<string>("ProcessOrder",input);}
StrategyType overrides the default per type; OptionsName selects named strategy options for that override.
Different workflow families can use different strategies. All overrides in one canonical family must agree on
strategy and options, and its versions must be comparable by that strategy. Ambiguous latest versions or
conflicting configuration fail registration rather than silently picking a candidate.
The default selector is MaxVersionSelector. Advanced registrations can supply DefaultStrategy and
DefaultSelector factories in AddDaprWorkflowVersioning, or use UseDefaultWorkflowSelector<TSelector>().
Generated contributions from loaded assemblies are combined before version selection. Explicit workflow
registrations take precedence, so an explicit canonical alias can intentionally override automatic routing.
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.
Use a concrete versioned name only when intentionally pinning a caller to that version; concrete registrations
remain available for replay. Fully qualified concrete names disambiguate colliding simple names.
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
There is no automatic migration from one versioning strategy to another.
Version selection is configured by the destination application, not negotiated across apps. Multi-app callers
can use the destination’s canonical name when it enables name-based routing; otherwise use its registered name.
Name-based routing does not migrate running histories or incompatible activity contracts. Keep the concrete
versions and activity names required by retained histories.
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.
For large payloads, see Workflow data references (alpha) for
automatic offloading, explicit references, a streaming example, and a benchmark comparison. The binary store and
this SDK integration are alpha. Cross-app call inputs are inline by default; explicitly allowing references requires
compatible, payload-aware destinations with access to the same referenced objects. The
JavaScript SDK also supports the version-1 envelope from
@dapr/dapr 3.19.0 with compatible serialization and no payload-stage reversal.
Do not assume other language SDKs support data references.
Overview of the Dapr virtual actors building block for .NET
The Dapr.Actors package enables you to interact with Dapr virtual actors from a .NET application. Actors are virtual, addressed by type and id, with support for state management, reminders, timers, and both strongly-typed and weakly-typed proxy clients. The actor pattern is suited to scenarios where you need single-threaded, instance-based concurrency for stateful objects.
Status of Dapr.Actors
Active development on Dapr.Actors has stopped; this package now receives security fixes only, and all new actor development happens in Dapr.Actors.Next. There is no deprecation date for Dapr.Actors but it’s considered to be in a maintenance-only mode.
Dapr.Actors.Next is expected to be released as a stable package alongside the v1.19 Dapr runtime and SDK release.
Author & run actors: authoring actors with ActorHost, dependency injection, disposal, logging, and custom type names; hosting actors in ASP.NET Core with AddActors, JSON options, endpoint routing via MapActorsHandlers, and middleware pitfalls.
Actor serialization: the two serialization models — the weakly-typed client (System.Text.Json, JSON output) and the strongly-typed client (Data Contract Serializer, XML output) — and the rules for configuring types in each.
The IActorProxyFactory interface: creating actor clients with the strongly-typed (CreateActorProxy<>) and weakly-typed (Create) proxy styles, and reading exception details from method invocations.
Dapr actors encapsulate business logic, state transitions, and inter-actor communication. Unit testing catches bugs early before deploying to a Dapr-enabled cluster.
This guide covers practical patterns for testing actors without a running Dapr sidecar, using standard .NET test frameworks.
Prerequisites
.NET 8, .NET 9, .NET 10, or .NET 11 project with Dapr.Actors package
Be advised that .NET 8 and .NET 9 reach end of support in November 2026. Support for both will be dropped
in Dapr v1.20; only .NET 10 and .NET 11 will be supported through the end of 2027.
An actor class inherits from Actor (namespace Dapr.Actors.Runtime). Key members available for testing:
Member
Role
StateManager
Typed key-value state store (IActorStateManager)
Id
The actor unique identifier (ActorId)
Host
The ActorHost with registered type info
RegisterTimerAsync()
Register a periodic callback
RegisterReminderAsync()
Register a persistent reminder
Unit testing with ActorHost
The simplest unit test creates an actor with a lightweight ActorHost:
If your actor uses the base class StateManager property directly, test state persistence via TrySaveStateAsync() or verify behavior through public methods.
Mocking cross-actor calls
Actors call other actors through IActorProxyFactory. Inject the factory via constructor for testability:
In this guide, you will learn how to use IActorProxyFactory.
Tip
For a non-dependency-injected application, you can use the static methods on ActorProxy. Since the ActorProxy methods are error prone, try to avoid using them when configuring custom settings.
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 style
Description
Strongly-typed
Strongly-typed clients are based on .NET interfaces and provide the typical benefits of strong-typing. They don’t work with non-.NET actors.
Weakly-typed
Weakly-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 idvarproxy=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 directlyawaitproxy.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 idvarproxy=this.ProxyFactory.Create(ActorId.CreateRandom(),"OtherActor");// Invoke a method by name to invoke the actor//// proxy is an instance of ActorProxy.awaitproxy.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 idvarproxy=this.ProxyFactory.Create(ActorId.CreateRandom(),"OtherActor");// Invoke a method on the proxy to invoke the actor//// proxy is an instance of ActorProxy.varrequest=newMyRequest(){Message="Hi, it's me.",};varresponse=awaitproxy.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
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
internalclassMyActor:Actor,IMyActor,IRemindable{publicMyActor(ActorHosthost)// 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.
internalclassMyActor:Actor,IMyActor,IRemindable{publicMyActor(ActorHosthost,BankServicebank)// 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.
// In Startup.cspublicvoidConfigureServices(IServiceCollectionservices){...// 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.
internalclassMyActor:Actor,IMyActor,IRemindable{publicMyActor(ActorHosthost,IServiceProviderservices)// 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:
publicTask<MyData>GetDataAsync(){this.Logger.LogInformation("Getting state at {CurrentTime}",DateTime.UtcNow);returnthis.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.
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.cspublicvoidConfigureServices(IServiceCollectionservices){// Register actor runtime with DIservices.AddActors(options=>{// Register actor types and configure actor settingsoptions.Actors.RegisterActor<MyActor>();// Configure default settingsoptions.ActorIdleTimeout=TimeSpan.FromMinutes(10);options.ActorScanInterval=TimeSpan.FromSeconds(35);options.DrainOngoingCallTimeout=TimeSpan.FromSeconds(5);options.DrainRebalancedActors=true;});// Register additional services for use with actorsservices.AddSingleton<BankService>();}
You can configure the JsonSerializerOptions as part of ConfigureServices:
// In Startup.cspublicvoidConfigureServices(IServiceCollectionservices){services.AddActors(options=>{...// Customize JSON optionsoptions.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.cspublicvoidConfigure(IApplicationBuilderapp,IWebHostEnvironmentenv){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.cspublicvoidConfigure(IApplicationBuilderapp,IWebHostEnvironmentenv){if(env.IsDevelopment()){app.UseDeveloperExceptionPage();}// INVALID - this will block non-HTTPS requestsapp.UseHttpsRedirection();// INVALID - this will block non-HTTPS requestsapp.UseRouting();app.UseEndpoints(endpoints=>{// Register actors handlers that interface with the Dapr runtime.endpoints.MapActorsHandlers();});}
Necessary steps to serialize your types remoted and non-remoted Actors in .NET
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.Webdefault 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.
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.
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:
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:
publicenumSeason{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:
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.
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 [EnumMember] 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:
publicsealedclassEnumMemberJsonConverter<T>:JsonConverter<T>whereT: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>publicoverrideTRead(refUtf8JsonReaderreader,TypetypeToConvert,JsonSerializerOptionsoptions){// Get the string value from the JSON readervarvalue=reader.GetString();// Loop through all the enum valuesforeach(varenumValueinEnum.GetValues<T>()){// Get the value from the EnumMember attribute, if anyvarenumMemberValue=GetValueFromEnumMember(enumValue);// If the values match, return the enum valueif(value==enumMemberValue){returnenumValue;}}// If no match found, throw an exceptionthrownewJsonException($"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>publicoverridevoidWrite(Utf8JsonWriterwriter,Tvalue,JsonSerializerOptionsoptions){// Get the value from the EnumMember attribute, if anyvarenumMemberValue=GetValueFromEnumMember(value);// Write the value to the JSON writerwriter.WriteStringValue(enumMemberValue);}privatestaticstringGetValueFromEnumMember(Tvalue){MemberInfo[]member=typeof(T).GetMember(value.ToString(),BindingFlags.DeclaredOnly|BindingFlags.Static|BindingFlags.Public);if(member.Length==0)returnvalue.ToString();object[]customAttributes=member.GetCustomAttributes(typeof(EnumMemberAttribute),false);if(customAttributes.Length!=0){EnumMemberAttributeenumMemberAttribute=(EnumMemberAttribute)customAttributes;if(enumMemberAttribute!=null&&enumMemberAttribute.Value!=null)returnenumMemberAttribute.Value;}returnvalue.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.
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.
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.
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!publicclassDoodad{publicDoodad(stringname,intcount){Id=Guid.NewGuid();Name=name;Count=count;}publicGuidId{get;set;}publicstringName{get;init;}publicintCount{get;init;}}
If we add a public parameterless constructor to the type, we’re good to go and this will work without further annotations.
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.
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.
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:
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.
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.
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:
publicrecordDoodad(GuidId,stringName,intCount);
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.
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.
There are several types built into .NET that are considered primitive and eligible for serialization without additional effort on the part of the developer:
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 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.
publicenumColors{ [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))]publicclassBaseClass{//Members of the base class}[DataContract]publicclassDerivedClass: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.
4.5 - 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:
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.
Be advised that .NET 8 and .NET 9 reach end of support in November 2026. Support for both will be dropped
in Dapr v1.20; only .NET 10 and .NET 11 will be supported through the end of 2027.
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 Interfacesdotnet new classlib -o MyActor.Interfaces
cd MyActor.Interfaces
# Add Dapr.Actors nuget package. Please use the latest package version from nuget.orgdotnet 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.
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 actordotnet new web -o MyActorService
cd MyActorService
# Add Dapr.Actors.AspNetCore nuget package. Please use the latest package version from nuget.orgdotnet add package Dapr.Actors.AspNetCore
# Add Actor Interface referencedotnet 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:
usingDapr.Actors;usingDapr.Actors.Runtime;usingMyActor.Interfaces;usingSystem;usingSystem.Threading.Tasks;namespaceMyActorService{internalclassMyActor: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>publicMyActor(ActorHosthost):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>protectedoverrideTaskOnActivateAsync(){// Provides opportunity to perform some optional setup.Console.WriteLine($"Activating actor id: {this.Id}");returnTask.CompletedTask;}/// <summary>/// This method is called whenever an actor is deactivated after a period of inactivity./// </summary>protectedoverrideTaskOnDeactivateAsync(){// Provides Opporunity to perform optional cleanup.Console.WriteLine($"Deactivating actor id: {this.Id}");returnTask.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>publicasyncTask<string>SetDataAsync(MyDatadata){// 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.awaitthis.StateManager.SetStateAsync<MyData>("my_data",// state namedata);// 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>publicTask<MyData>GetDataAsync(){// Gets state from the state store.returnthis.StateManager.GetStateAsync<MyData>("my_data");}/// <summary>/// Register MyReminder reminder with the actor/// </summary>publicasyncTaskRegisterReminder(){awaitthis.RegisterReminderAsync("MyReminder",// The name of the remindernull,// User state passed to IRemindable.ReceiveReminderAsync()TimeSpan.FromSeconds(5),// Time to delay before invoking the reminder for the first timeTimeSpan.FromSeconds(5));// Time interval between reminder invocations after the first invocation}/// <summary>/// Get MyReminder reminder details with the actor/// </summary>publicasyncTask<IActorReminder>GetReminder(){awaitthis.GetReminderAsync("MyReminder");}/// <summary>/// Unregister MyReminder reminder with the actor/// </summary>publicTaskUnregisterReminder(){Console.WriteLine("Unregistering MyReminder...");returnthis.UnregisterReminderAsync("MyReminder");}// <summary>// Implement IRemindeable.ReceiveReminderAsync() which is call back invoked when an actor reminder is triggered.// </summary>publicTaskReceiveReminderAsync(stringreminderName,byte[]state,TimeSpandueTime,TimeSpanperiod){Console.WriteLine("ReceiveReminderAsync is called!");returnTask.CompletedTask;}/// <summary>/// Register MyTimer timer with the actor/// </summary>publicTaskRegisterTimer(){returnthis.RegisterTimerAsync("MyTimer",// The name of the timernameof(this.OnTimerCallBack),// Timer callbacknull,// User state passed to OnTimerCallback()TimeSpan.FromSeconds(5),// Time to delay before the async callback is first invokedTimeSpan.FromSeconds(5));// Time interval between invocations of the async callback}/// <summary>/// Unregister MyTimer timer with the actor/// </summary>publicTaskUnregisterTimer(){Console.WriteLine("Unregistering MyTimer...");returnthis.UnregisterTimerAsync("MyTimer");}/// <summary>/// Timer callback once timer is expired/// </summary>privateTaskOnTimerCallBack(byte[]data){Console.WriteLine("OnTimerCallBack is called!");returnTask.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:
usingMicrosoft.AspNetCore.Builder;usingMicrosoft.AspNetCore.Hosting;usingMicrosoft.Extensions.DependencyInjection;usingMicrosoft.Extensions.Hosting;namespaceMyActorService{publicclassStartup{publicvoidConfigureServices(IServiceCollectionservices){services.AddActors(options=>{// Register actor types and configure actor settingsoptions.Actors.RegisterActor<MyActor>();});}publicvoidConfigure(IApplicationBuilderapp,IWebHostEnvironmentenv){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 Clientdotnet new console -o MyActorClient
cd MyActorClient
# Add Dapr.Actors nuget package. Please use the latest package version from nuget.orgdotnet add package Dapr.Actors
# Add Actor Interface referencedotnet 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:
usingSystem;usingSystem.Threading.Tasks;usingDapr.Actors;usingDapr.Actors.Client;usingMyActor.Interfaces;namespaceMyActorClient{classProgram{staticasyncTaskMainAsync(string[]args){Console.WriteLine("Startup up...");// Registered Actor Type in Actor ServicevaractorType="MyActor";// An ActorId uniquely identifies an actor instance// If the actor matching this id does not exist, it will be createdvaractorId=newActorId("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. varproxy=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}...");varresponse=awaitproxy.SetDataAsync(newMyData(){PropertyA="ValueA",PropertyB="ValueB",});Console.WriteLine($"Got response: {response}");Console.WriteLine($"Calling GetDataAsync on {actorType}:{actorId}...");varsavedData=awaitproxy.GetDataAsync();Console.WriteLine($"Got response: {savedData}");}}}
Running the code
The projects that you’ve created can now to test the sample.
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
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.
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.
Runtime requirement and connectivity
Dapr.Actors.Next requires Dapr runtime v1.18.0 or later. It hosts actors over persistent, app-initiated SubscribeActorEvents gRPC streams, so actor callbacks arrive over that stream rather than as inbound HTTP requests to mapped actor-handler endpoints, which is what Dapr.Actors required. The application still exposes its gRPC server port, which the runtime uses for its precondition checks; what changes is how actor callbacks are delivered, not whether the app runs a gRPC server.
Status of Dapr.Actors
Active development on Dapr.Actors has stopped; this package now receives security fixes only, and all new actor development happens in Dapr.Actors.Next. There is no deprecation date for Dapr.Actors but it’s considered to be in a maintenance-only mode.
Moving from Dapr.Actors is incremental, not necessarily all at once
Dapr.Actors.Next is a new package family with a different API, so adopting it is a rewrite of your actor layer rather than an in-place upgrade. It is wire-compatible with the runtime and addresses actors by the same type name, so you can migrate one actor type at a time rather than in a single big-bang cutover. We recommend performing that migration across separate projects or services rather than hosting both SDKs in the same one: Dapr.Actors and Dapr.Actors.Next have meaningfully different behaviors — around hosting, dispatch, and serialization — and mixing them in a single project invites confusion about which SDK owns a given actor. We also have no testing that conclusively demonstrates the two packages running side-by-side in the same project, so while it is technically possible, we don’t recommend it. Cross-SDK calling works where the payload serialization matches: the new SDK uses System.Text.Json by default, which lines up with the old non-remoting (JSON) proxy but not with the old DataContract remoting path. The highest-stakes thing to verify before moving a type is that its existing persisted state remains readable under the new serializer. See the migration guide for more information.
What changed
This rewrite was born out of the two core ideas:
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.
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
Concern
Dapr.Actors (original)
Dapr.Actors.Next
Runtime connectivity
Inbound actor-handler endpoints the runtime calls into per invocation
Actor callbacks over an outbound persistent SubscribeActorEvents stream; no actor endpoints to map (the app’s gRPC server port remains)
Proxies and dispatch
Reflection (DispatchProxy, runtime IL)
Source-generated; no reflection on the hot path
Native AOT and trimming
Not supported
Supported; runtime packages are trim/AOT clean
Nullable reference types
Partial
Enabled across the public surface
Registration
Manual RegisterActor<T>() per type
Source-generated discovery; actors are not registered by hand
Hosting setup
app.MapActorsHandlers() endpoint mapping
AddDaprActors() only; the host dials the runtime
Dependency injection
Bespoke
Per-activation DI scope; constructor injection of registered services
Proxy flavors
Remoting proxy (.NET-to-.NET) and non-remoting proxy (cross-app)
One proxy; no remoting/non-remoting distinction
Serialization
DataContract via the remoting proxy, or System.Text.Json via the non-remoting proxy
Pluggable IDaprSerializer (default System.Text.Json) for every call; no DataContract built in
Configuration
Bespoke
Options pattern (IOptions<DaprActorsOptions>)
Testing
Integration tests against a sidecar
In-memory deterministic runtime plus optional Coyote deep testing
State versioning
Manual
State-envelope schema versioning with upcaster chains
State machines
Not provided
First-class StateMachineActor<TState, TData> model
Pub/sub integration
Not provided
Built-in [Subscribe] subscription streams
Per-actor state cache
Yes
Yes (retained)
Serialization is a clean break from Dapr.Actors
In Dapr.Actors the serialization format was tied to the proxy you chose. The remoting proxy used DataContract and worked only between .NET actors, while the non-remoting proxy used System.Text.Json and supported cross-app calls. Dapr.Actors.Next removes that split. There is a single serialization path, IDaprSerializer (System.Text.Json by default), used for every call regardless of how you invoke the actor, and DataContract is not supported out of the box. If you need DataContract, supply it through a custom IDaprSerializer implementation. The strongly-typed proxy resembles the former remoting client in calling syntax only; that resemblance does not imply DataContract, and the way you invoke an actor has nothing to do with how its payloads are serialized.
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] metadata and explicit stream registration.
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, remote-call simulation, paused state writes and deactivation races, virtual timers/reminders, callback-stream fault injection, and optional Coyote integration.
Per-type runtime options are supported through options.Actors.RegisterActor<TActor>(...), including idle timeout, reentrancy, draining, and state-migration overrides.
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. The solution also includes a seventh per-type options example under /examples/Actor.Next/. It is the fastest way to understand what changed and why.
5.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.
Runtime requirement
Dapr.Actors.Next requires Dapr runtime v1.18.0 or later. It hosts actors over persistent, app-initiated SubscribeActorEvents gRPC streams rather than inbound HTTP actor-handler endpoints. Confirm your clusters are on 1.18+ before you begin; there is no supported way to run the new SDK against an older sidecar. While the Dapr.Actors and Dapr.Actors.AspNetCore packages will work against all supported versions of the Dapr runtime, they will not use any of the gRPC functionality and an app using both the old and new Actors SDKs will need a second port opened for both the HTTP and gRPC callbacks from the runtime.
Before you start: the two things most likely to bite
Read these first. Everything else in the migration is mechanical by comparison.
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.
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.
Move one actor type at a time
Because both SDKs address actors by the same type name and are wire-compatible with the runtime, you do not need a full-scale cutover. Stand up the new host (or a new mode in an existing app), migrate a single actor type and the code that calls it, verify state reads and behavior, then move the next. Keep Dapr.Actors installed for the types you have not moved yet. You might also keep the full namespaces attached to types to help distinguish which is used for which to avoid mid-migration conflicts.
IActorReminderScheduler.ScheduleAsync(…) routed to a named actor method
Options
ActorRuntimeOptions / ActorReentrancyConfig
DaprActorsOptions (options pattern)
DI
Scope-per-activation via the AspNetCore DI activator; ctor takes ActorHost
Per-activation scope; plain constructor injection
Testing
Integration tests against a sidecar
In-memory deterministic runtime (own page)
One interface, many actor types
Not practical
Supported
One actor type, many interfaces
Not supported
Supported
Multi-parameter non-remoting methods
Not 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><PackageReferenceInclude="Dapr.Actors"Version="…"/><PackageReferenceInclude="Dapr.Actors.AspNetCore"Version="…"/></ItemGroup>
<!-- After: one reference brings the runtime, source generators, and analyzers --><ItemGroup><PackageReferenceInclude="Dapr.Actors.Next"Version="…"/></ItemGroup>
// Before (Program.cs): register, then map the actor HTTP endpoints.varbuilder=WebApplication.CreateBuilder(args);builder.Services.AddActors(options=>{options.Actors.RegisterActor<CartActor>();options.ActorIdleTimeout=TimeSpan.FromMinutes(30);});varapp=builder.Build();app.MapActorsHandlers();// maps dapr/config, deactivation, method, remind, timer, healthzapp.Run();
// After: no endpoint mapping. AddDaprActors starts the host that streams from the runtime.varbuilder=WebApplication.CreateBuilder(args);builder.Services.AddDaprActors(options=>{options.ActorIdleTimeout=TimeSpan.FromMinutes(30);});varapp=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().
Your app still runs a gRPC server
Removing the actor HTTP handlers does not mean your app stops listening. The application still exposes its gRPC server port, which the runtime uses for its precondition checks (e.g. in case you’re using other Dapr functionality that requires a gRPC endpoint on your app). What changed is only how actor callbacks are delivered. See Author, register, and call actors for the host setup in full.
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.
If actors live in a referenced project, opt in to reference scanning
By default, the generator scans only the executing assembly. If your [DaprActor] types (or state upcasters) live in a class library the host references, add <CompilerVisibleProperty Include="DaprActorsScanReferences" /> to the host .csproj so they are discovered. This better facilitates those that want to colocate actor types in a shared project and then reference them specifically in their apps. Especially if your app doesn’t intend to host all possible discoverable actor types, you should set EnableAutoActorRegistration to false and manually register them yourself. See Cross-assembly discovery.
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.
// BeforepublicinterfaceICartActor:IActor// derives from Dapr.Actors.IActor, no attribute{TaskAddItem(CartItemitem);Task<CartSummary>GetSummary();}[Actor(TypeName = "CartActor")]publicclassCartActor:Actor,ICartActor{publicCartActor(ActorHosthost):base(host){}// required base ctorpublicasyncTaskAddItem(CartItemitem){varcart=awaitStateManager.GetOrAddStateAsync("cart",newCartState());cart.Items.Add(item);awaitStateManager.SetStateAsync("cart",cart);// and/or rely on end-of-turn save}publicasyncTask<CartSummary>GetSummary(){varcart=awaitStateManager.GetOrAddStateAsync("cart",newCartState());returnnewCartSummary(cart.Items.Count);}}
// AfterusingDapr.Actors.Next;[GenerateActorClient]// required marker on the contractpublicinterfaceICartActor:IActor{TaskAddItem(CartItemitem,CancellationTokenct=default);Task<CartSummary>GetSummary(CancellationTokenct=default);}[DaprActor]// required marker on the implementation, optional name argument, supports more than one IActor interfacepublicsealedclassCartActor(ActorActivationContextcontext):Actor,ICartActor{protectedoverrideActorIdId=>context.ActorId;protectedoverrideIActorStateAccessorState=>context.State;publicasyncTaskAddItem(CartItemitem,CancellationTokenct=default){varcart=awaitState.GetOrCreateAsync("cart",()=>newCartState(),ct);cart.Value.Items.Add(item);// mutate through .Value; saved at end of turn}publicasyncTask<CartSummary>GetSummary(CancellationTokenct=default){varcart=awaitState.GetOrCreateAsync("cart",()=>newCartState(),ct);returnnewCartSummary(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.
Both attributes are required, and they are a pair
[GenerateActorClient] on the interface and [DaprActor] on the class are not interchangeable and neither is optional. The generator builds actors only from [DaprActor] classes that implement a [GenerateActorClient] interface. If an actor “compiles but is never called,” the first thing to check is that its interface derives from IActor and carries [GenerateActorClient] (the DAPR1421 analyzer flags a [DaprActor] with no decorated contract).
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.
// AfterprotectedoverrideValueTaskOnActivateAsync(CancellationTokenct=default){…}protectedoverrideValueTaskOnDeactivateAsync(CancellationTokenct=default){…}protectedoverrideValueTaskOnPreActorMethodAsync(ActorMethodContextcontext,CancellationTokenct=default){…}protectedoverrideValueTaskOnPostActorMethodAsync(ActorMethodContextcontext,Exception?exception,CancellationTokenct=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.
// Beforevarcart=awaitStateManager.GetOrAddStateAsync("cart",newCartState());cart.Items.Add(item);awaitStateManager.SetStateAsync("cart",cart);// explicit set; save happens at end of turn
// Aftervarcart=awaitState.GetOrCreateAsync("cart",()=>newCartState(),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 .Value
Prefer mutating .Value inside a turn
AddStateAsync / TryAddStateAsync
GetOrCreateAsync / SetAsync
No dedicated add-only method
AddOrUpdateStateAsync<T>(…)
Mutate .Value (or SetAsync)
The add/update dance is unnecessary with the wrapper
RemoveStateAsync / TryRemoveStateAsync
RemoveAsync(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.
State serialization is System.Text.Json on both sides — but verify the options
Dapr.Actors serialized actor state with System.Text.Json by default (Web defaults), and Dapr.Actors.Next serializes through IDaprSerializer, whose default is also System.Text.Json. So existing state is generally readable. The risk is a mismatch in JsonSerializerOptions (naming policy, enum handling, null/default emission) or a custom IActorStateSerializer you registered on the old side. If you changed a state type’s shape, do not try to make the bytes line up by hand — use the built-in state migration, which reads legacy plain values and folds them forward on read. See also Serialization.
Records and primary-constructor types just work as payloads now
Under Dapr.Actors, the remoting proxy used the Data Contract Serializer for method payloads, which required a public parameterless constructor or [DataContract]/[DataMember] annotations — so records and primary-constructor classes needed a redundant constructor or attributes to travel as arguments. Dapr.Actors.Next uses System.Text.Json for every call, so those types work as written with no serialization attributes. (Actor state was already JSON in the old SDK, so this specifically removes the friction the remoting proxy imposed on argument and return types.)
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)varcart=ActorProxy.Create<ICartActor>(ActorId.Create(userId),"CartActor");awaitcart.AddItem(item);// Before — non-remoting (JSON, cross-app; single payload argument)varcart=ActorProxy.Create(ActorId.Create(userId),"CartActor");awaitcart.InvokeMethodAsync<CartItem>("AddItem",item);varsummary=awaitcart.InvokeMethodAsync<CartSummary>("GetSummary");
// After — one proxy, injected factory, strongly typed, works cross-app toopublicsealedclassCheckoutService(IActorProxyFactoryproxies){publicasyncTaskAdd(stringuserId,CartItemitem){varcart=proxies.Create<ICartActor>(ActorId.Create(userId),"CartActor");awaitcart.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.
For calls without a compile-time contract, there is a dynamic client
The old non-remoting InvokeMethodAsync("MethodName", …) by string maps to IDynamicActorClient.InvokeAsync(type, id, method, argsJson), paired with IActorRegistry for discovery. If you used the non-remoting proxy specifically because you did not have the contract at compile time (a gateway, tooling, a cross-language caller), that is now a first-class, documented path rather than an overload of the proxy. See Dynamic invocation.
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 reflectionprotectedoverrideasyncTaskOnActivateAsync(){awaitRegisterTimerAsync(timerName:"refresh-prices",callback:nameof(RefreshPrices),callbackParams:Encoding.UTF8.GetBytes(sku),dueTime:TimeSpan.FromMinutes(1),period:TimeSpan.FromMinutes(5));}publicTaskRefreshPrices(byte[]data){…}// 0 or 1 param, must return Task
// After — inject the scheduler; route to a named operation with a typed argument[DaprActor("Cart")]publicsealedclassCartActor(ActorActivationContextcontext,IActorTimerSchedulertimers):Actor,ICartActor{protectedoverrideActorIdId=>context.ActorId;protectedoverrideIActorStateAccessorState=>context.State;publicasyncTaskAddItem(CartItemitem,CancellationTokenct=default){awaittimers.RescheduleAsync(actorType:"Cart",actorId:Id,name:"refresh-prices",dueTime:TimeSpan.FromMinutes(1),operationName:nameof(RefreshPrices),arguments:item.Sku,period:TimeSpan.FromMinutes(5),cancellationToken:ct);}publicTaskRefreshPrices(stringsku,CancellationTokenct=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")]publicclassCartActor:Actor,ICartActor,IRemindable{publicCartActor(ActorHosthost):base(host){}publicasyncTaskStartAbandonWatch(){awaitRegisterReminderAsync(reminderName:"abandon-cart",state:Array.Empty<byte>(),dueTime:TimeSpan.FromMinutes(30),period:TimeSpan.Zero);}publicasyncTaskReceiveReminderAsync(stringreminderName,byte[]state,TimeSpandueTime,TimeSpanperiod){switch(reminderName)// one method fans out to many reminders{case"abandon-cart":varcart=awaitStateManager.GetOrAddStateAsync("cart",newCartState());cart.Abandoned=true;awaitStateManager.SetStateAsync("cart",cart);break;}}}
// After — schedule through the injected scheduler; the reminder name IS the operation[DaprActor("Cart")]publicsealedclassCartActor(ActorActivationContextcontext,IActorReminderSchedulerreminders):Actor,ICartActor{protectedoverrideActorIdId=>context.ActorId;protectedoverrideIActorStateAccessorState=>context.State;publicasyncTaskStartAbandonWatch(CancellationTokenct=default){awaitreminders.ScheduleAsync(actorType:"Cart",actorId:Id,name:nameof(AbandonCart),// the reminder name routes to this methoddueTime:TimeSpan.FromMinutes(30),period:TimeSpan.Zero,arguments:Array.Empty<byte>(),ttl:TimeSpan.FromDays(1),overwrite:true,cancellationToken:ct);}publicasyncTaskAbandonCart(CancellationTokenct=default){varcart=awaitState.GetOrCreateAsync("cart",()=>newCartState(),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.
// Afterbuilder.Services.AddDaprActors(options=>{options.ActorIdleTimeout=TimeSpan.FromMinutes(30);options.DrainRebalancedActorsTimeout=TimeSpan.FromSeconds(5);options.DrainRebalancedActors=true;options.EnableReentrancy=true;// was ReentrancyConfig.Enabledoptions.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 failure paths testable: an invoke can be redelivered, and a reminder can re-fire if its acknowledgment is lost, even after the handling turn commits. Every actor method, reminder, and subscription handler must be safe to run more than once. Both state mutations and external side effects (publishing, calling another actor, charging a card) require idempotency or deduplication; using actor state alone does not make an increment or append safe to repeat. Use a decorated callback transport to inject response loss after commit, then explicitly exercise 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(stringsku,intquantity,stringcurrency,CancellationTokenct=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]publicinterfaceIQueueActor:IActor{TaskEnqueue(WorkItemitem,CancellationTokenct=default);}[DaprActor]// actor type "OrdersQueueActor"publicsealedclassOrdersQueueActor:Actor,IQueueActor{/* … */}[DaprActor]// actor type "EmailsQueueActor", same contractpublicsealedclassEmailsQueueActor: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")]publicsealedclassDocumentActor(ActorActivationContextcontext):Actor,IDocumentReader,IDocumentWriter{protectedoverrideActorIdId=>context.ActorId;protectedoverrideIActorStateAccessorState=>context.State;// Read() and Write() on the two contracts share one activation and one state.}
Confirm the runtime is on 1.18+ everywhere the actor will run.
Pick one actor type to move first — ideally one with modest state and few callers.
Create the contract with [GenerateActorClient]. If callers live in other apps, put the interface in a shared assembly both reference.
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.
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.
Move the callers of that type to IActorProxyFactory so the call and the actor share one serialization format, avoiding a remoting/JSON straddle.
Add unit tests with ActorTestRuntime for the paths you could not test before.
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 state updates and external side effects idempotent or deduplicate requests.
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.
5.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.
Unlike Dapr.Actors, you do not map actor handler endpoints for the runtime to call into per invocation. The host opens a persistent gRPC connection to the runtime and receives actor callbacks over that stream, so there is nothing like MapActorsHandlers() to wire up. The application still exposes its gRPC server port as usual; the runtime continues to use it for its gRPC precondition checks. What changes is how actor callbacks are delivered, not whether the app runs a gRPC server.
Package references
All projects within the Dapr .NET SDK repository are referenced using relative paths. As meta-packages like Dapr.Workflows or Dapr.Actors.Next are only an artifact of the build pipeline, the individual projects in the source code refernce each of the individual projects within the repository (e.g. Dapr.Actors.Next.SourceGenerators or Dapr.Actors.Next.Abstractions). These packages are not intended to be published to NuGet and only for local SDK experimentation and development. When using this package in your own projects, whether host or test projects, it is intended that you install Dapr.Actors.Next from NuGet.
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].
usingDapr.Actors.Next;[GenerateActorClient]publicinterfaceICartActor:IActor{TaskAddItem(CartItemitem,CancellationTokenct=default);Task<CartSummary>GetSummary(CancellationTokenct=default);}[DaprActor]publicsealedclassCartActor(ActorActivationContextcontext,IPricingClientpricing):Actor,ICartActor{protectedoverrideActorIdId=>context.ActorId;protectedoverrideIActorStateAccessorState=>context.State;publicasyncTaskAddItem(CartItemitem,CancellationTokenct=default){varcart=awaitState.GetOrCreateAsync("cart",()=>newCartState(),ct);cart.Value.Items.Add(item);// mutate through .Value; saved at end of turn}publicasyncTask<CartSummary>GetSummary(CancellationTokenct=default){varcart=awaitState.GetOrCreateAsync("cart",()=>newCartState(),ct);returnnew(cart.Value.Items.Count,awaitpricing.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:
A [DaprActor] with no decorated interface produces nothing
If a [DaprActor] class does not implement an interface marked with [GenerateActorClient], the generator emits nothing for it: no client proxy, no dispatcher, no activation factory, and no registration. The class compiles and looks like an actor, but it is never hosted and cannot be called, with no error to point at. If an actor you defined is simply not working, the first thing to check is that its interface derives from IActor and carries [GenerateActorClient]. The two attributes are a pair; [DaprActor] alone is not enough.
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 write
The source generator produces
[GenerateActorClient] on interface ICartActor : IActor
A strongly-typed client proxy for calling the actor, and the contract’s entry in the interface manifest
[DaprActor] on an implementation of a decorated interface
A dispatcher that routes incoming invocations to your methods
[DaprActor] on an implementation of a decorated interface
A typed activation factory that constructs the actor from DI
[DaprActor] on an implementation of a decorated interface
An 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]publicinterfaceIQueueActor:IActor{TaskEnqueue(WorkItemitem,CancellationTokenct=default);}[DaprActor]// actor type "OrdersQueue"publicsealedclassOrdersQueueActor:Actor,IQueueActor{/* ... */}[DaprActor]// actor type "EmailsQueue", same contract, different actorpublicsealedclassEmailsQueueActor: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.
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:
Both proxies route to the same activation, so a write through IDocumentWriter is visible to a subsequent read through IDocumentReader on the same id.
Method names must be unique across the contracts
The runtime routes an incoming call to an actor by its method name, so the methods across the interfaces a single actor implements must have distinct names. Two contracts on the same actor that both declare a method with the same name collide on the wire, and only one is dispatched. Keep the method names distinct, or, when the two contracts genuinely share an operation, declare it on a common base interface both derive from so it is one method rather than two.
Method signatures
Actor interface methods must return Task, Task<T>, ValueTask, ValueTask<T>, or IAsyncEnumerable<T>, and their parameters and return types must be serializable by the configured serializer. An analyzer (DAPR1417) flags methods that don’t comply.
Register at startup, the minimum
The only required call is AddDaprActors. The options delegate is optional.
varbuilder=WebApplication.CreateBuilder(args);builder.Services.AddDaprActors();// discovers and wires every [DaprActor] in this assemblyvarapp=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.
Actors are no longer registered by hand
There is no RegisterActor<T>() call and no app.MapActorsHandlers(). Actors are discovered by the source generator at build time, and the host connects to the runtime over the stream rather than exposing an endpoint. Calling AddDaprActors() is enough.
Use options.Actors.RegisterActor<TActor>(...) when one actor type needs a different runtime profile from the app-wide defaults. Only values set in the per-type delegate override the app-wide options; unset values inherit them.
The idle timeout, draining settings, and reentrancy settings are advertised to the Dapr sidecar for the registered actor type. DisableStateMigration is applied locally by the SDK when that actor’s state is persisted and is not advertised to the sidecar. The complete runnable version is in examples/Actor.Next/07-PerTypeOptions.
The SDK also enforces EnableReentrancy and MaxReentrantDepth when admitting a matching call chain locally, using per-type values before application-wide values. Configure these explicitly to match your application’s call graph.
There is no lifetime parameter
AddDaprActors does not accept a ServiceLifetime. An actor’s lifetime is its activation, which the runtime owns: it is keyed by id, spans many turns, and ends on deactivation. That is not a DI lifetime, so it is not configurable. The actor instance is constructed per activation in a dedicated DI scope, cached across turns, and disposed together with its scope on deactivation. Your actor’s dependencies keep whatever lifetimes you registered them with.
Deactivation is serialized with actor work: an external request waits behind active work, while one requested by the active call chain is deferred until the turn and its admitted reentrant children finish. The activation and its scope are not disposed underneath an in-flight turn.
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:
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 optionalpublicsealedclassQueueActor:Actor,IQueueActor{/* ... */}// ...varqueueActorTypeName=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.
Keep the [DaprActor] attribute; its name is optional
Aliasing changes the name a type is hosted under; it does not replace the [DaprActor] attribute. The attribute is what the source generator uses to produce the proxy, dispatcher, and activation factory, so the type must still carry [DaprActor] even when you supply the name elsewhere. The name argument on the attribute is optional, though: if you omit it or leave it blank, the generator falls back to the actor implementation’s type name. So when you intend to alias from configuration you can leave the attribute bare, as [DaprActor], and let the alias supply the name. What you must not do is remove the attribute, which would stop the generator from producing anything for that type.
A custom name is still a durable address
Choosing the actor type name from configuration is supported, but the name is part of the durable address: state and placement are keyed by it. Changing the name that a set of existing instances was created under makes those instances unreachable under the new name. Treat a configuration-driven name as fixed for the lifetime of the data behind it, and change it only with the same care you would give a data migration.
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:
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]publicsealedclassCartActor(ActorActivationContextcontext,IPricingClientpricing,DaprStateManagementClientstateStores):Actor,ICartActor{protectedoverrideActorIdId=>context.ActorId;protectedoverrideIActorStateAccessorState=>context.State;// pricing and stateStores resolve per activation, provided you registered them, and are disposed on deactivation}
Avoid mutable shared state in fields
Because an actor instance is cached across turns, anything captured in a field lives for the whole activation. Holding mutable shared state in a field, especially a captured singleton, breaks turn isolation. Keep per-actor state in State, and keep injected services stateless. An analyzer (DAPR1419) flags non-readonly fields whose type is not turn-safe.
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]publicsealedclassCartActor(ActorActivationContextcontext):Actor,ICartActor{protectedoverrideActorIdId=>context.ActorId;protectedoverrideIActorStateAccessorState=>context.State;protectedoverrideValueTaskOnActivateAsync(CancellationTokenct=default)=>/* runs when the instance is activated */;protectedoverrideValueTaskOnDeactivateAsync(CancellationTokenct=default)=>/* runs before deactivation */;protectedoverrideValueTaskOnPreActorMethodAsync(ActorMethodContextcontext,CancellationTokenct=default)=>/* before each method */;protectedoverrideValueTaskOnPostActorMethodAsync(ActorMethodContextcontext,Exception?exception,CancellationTokenct=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.
usingDapr.Actors.Next;usingDapr.Actors.Next.Core.Timers;[DaprActor("Cart")]publicsealedclassCartActor(ActorActivationContextcontext,IActorTimerSchedulertimers,IActorReminderSchedulerreminders):Actor,ICartActor{protectedoverrideActorIdId=>context.ActorId;protectedoverrideIActorStateAccessorState=>context.State;publicasyncTaskAddItem(CartItemitem,CancellationTokenct=default){varcart=awaitState.GetOrCreateAsync("cart",()=>newCartState(),ct);cart.Value.Items.Add(item);// One-shot activation-local timer. If the activation goes away before// this fires, the callback is lost.awaittimers.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.awaittimers.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);awaitreminders.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);}publicTaskRefreshPrices(stringsku,CancellationTokenct=default){returnTask.CompletedTask;}publicasyncTaskAbandonCart(CancellationTokenct=default){varcart=awaitState.GetOrCreateAsync("cart",()=>newCartState(),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.
Callback names are validated at build time
Because a callback is a string, a typo or a stale name after a rename would otherwise only surface when the timer or reminder fires. Analyzers catch this while you type. A callback must resolve to a method the runtime can dispatch, which means a method on an interface that both derives from IActor and carries [GenerateActorClient] (a method that exists only on the class, or on an actor interface without [GenerateActorClient], is never dispatched). When the target actor type is defined in this application (or a referenced project), DAPR1429 flags a callback that matches no dispatchable method (with a code fix that suggests the closest match), and DAPR1431 flags one that names a method which exists but is not exposed through a [GenerateActorClient] contract. When the actor type string cannot be found in this application, DAPR1430 warns that it may be hosted by another Dapr app and cannot be verified here. All three apply to the timer operationName and the reminder name.
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:
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:
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.
Timer and reminder callbacks are independent actor turns, not reentrant continuations of the registering turn. They wait for an in-flight turn on the same actor to finish. Canceling a registration does not undo a callback that has already been admitted for execution, so a callback already in progress may still finish.
Inspect and manage registrations
SDK 1.19.0 or later also exposes timer/reminder inspection and reminder bulk cancellation:
GetAsync returns null when the registration does not exist. List operations return named registration
records. Timers are host-local: inspect them through the sidecar hosting that actor, not an arbitrary
sidecar in the cluster. Reminder listing can be scoped to an actor ID or to the whole actor type.
To cancel all reminders for one actor:
awaitreminders.CancelAllAsync("Cart",Id,ct);
Omitting actorId cancels reminders for every actor of the given type. Use that broader operation only
when intentionally managing the whole type.
Reminder failure policy
Supply failurePolicy when registering a reminder to override the runtime default for failed ticks:
ActorReminderFailurePolicy.Drop drops a failed tick. Constant retries at the specified interval; an
unset MaxRetries permits indefinite retries. This affects failed reminder delivery, not timer durability,
the recurring period, or the actor’s own application retry logic.
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:
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:
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 after commit), the caller can observe a failure even though the state change succeeded. Depending on the configured retry policy, the invocation may run again. Pending actor state is saved before the response is sent, and those steps are not atomic.
Your actor methods must therefore be safe to run more than once, including their state mutations. Incrementing a counter or appending an item is not automatically idempotent merely because it uses actor State; a retry against committed state can apply the mutation again. Use naturally idempotent updates or persist a stable request identity and its result with the state change so duplicates can be recognized. External side effects (publishing an event, calling another actor, charging a card) also require an idempotency strategy. A lost response does not imply rollback.
A reminder can be redelivered until it is acknowledged, subject to its runtime failure policy. A configured
constant policy changes the retry interval/limit, while a drop policy can stop retries for that tick.
Reminder handlers must therefore be idempotent, and repeated failures must be surfaced rather than swallowed.
The practical rule
Design every actor method, reminder, and subscription handler to be safe to run more than once. Both state changes and external side effects need idempotency. Treat a missing response as an uncertain outcome, not evidence that the operation never ran or that its state changes were rolled back.
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.
No remoting/non-remoting proxies, and no DataContract
If you are coming from Dapr.Actors, note that the strongly-typed proxy here is not the old remoting client. There is no remoting versus non-remoting distinction, and the form of invocation you choose does not change serialization. Every call, strongly-typed or dynamic, serializes through IDaprSerializer (System.Text.Json by default). DataContract is not supported unless you provide a custom IDaprSerializer. Pick the proxy or the dynamic client based only on whether you have the contract at compile time; the two are interchangeable from the runtime’s point of view.
Get a proxy from IActorProxyFactory
The DI-friendly way to obtain a proxy is to inject IActorProxyFactory and call Create<TActor>(id, actorType):
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.
Avoid the static ActorProxy facade
The static ActorProxy.Create, Configure, and Reset are a convenience over process-wide mutable state: Create<T>() uses whichever factory was last passed to Configure(...), and Reset() clears it. That global state is a footgun. It can leak between tests, bind proxies to the wrong service provider, silently paper over a missing DI registration, and behave unpredictably in a process that runs more than one host or test runtime. Because it is shared and mutable, its behavior depends on call order across your whole process, which is exactly what you do not want from something on the call path.
Inject IActorProxyFactory instead. It is scoped to the container that created it, so there is no ambient state to leak or misconfigure, and a missing registration fails loudly rather than resolving to a stale global. The static facade exists for two narrow reasons: an easy entry point when you are genuinely outside DI (a tiny sample, or a spot with no container), and preserving the familiar shape for users coming from Dapr.Actors.Client.ActorProxy.Create. The test runtime’s ActorTestRuntime.CreateActor<T>() is a legitimate controlled use, because it configures the generated factory itself before using it. Outside of cases like those, reach for the injected factory.
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]publicsealedclassCartActor(ActorActivationContextcontext,IActorProxyFactoryproxies,IPricingClientpricing):Actor,ICartActor{protectedoverrideActorIdId=>context.ActorId;protectedoverrideIActorStateAccessorState=>context.State;publicasyncTaskCheckout(CancellationTokenct=default){varorders=proxies.Create<IOrderActor>(ActorId.Create($"order-{Id}"),"OrderActor");awaitorders.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.
Configure reentrancy explicitly
Set DaprActorsOptions.EnableReentrancy and bound the depth with DaprActorsOptions.MaxReentrantDepth. Per-type settings override application settings. The SDK admits a reentrant invocation only when it matches the occupied actor’s active call chain; it rejects such admission with ActorInvocationException when reentrancy is explicitly disabled or the depth limit would be exceeded. Dapr separately controls reentrant delivery according to the advertised actor configuration, so do not rely on a cyclic call graph progressing when it is disabled.
The canonical propagated header is Dapr-Reentrancy-Id. The SDK accepts it case-insensitively, also recognizes dapr-reentrant-id and x-dapr-reentrant-id, and prefers the canonical name if multiple aliases are present. For custom invocation infrastructure, ActorHeaders.WithCurrentReentrancy(headers) captures the active chain without mutating the supplied collection; explicit call-chain headers take precedence over ambient context. Normal actor proxy calls handle this for you.
Await nested actor calls rather than launching parallel or fire-and-forget branches over mutable actor state. Reentrant admission is not a general thread-safety guarantee.
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:varcart=proxies.Create<ICartActor>(ActorId.Create(userId),"CartActor");awaitcart.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.
Keep the type name stable
The actor type name is part of the durable address; state and placement are both keyed by it. Choose it deliberately and do not change it, or existing instances become unreachable. To evolve behavior or state, version the state rather than the type name. See State migration.
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.
ID
Flags
Category
Severity
DAPR1410
A state shape change that breaks deserialization of shipped state
Compatibility
Warning
DAPR1411
Work that escapes the actor scheduler, such as Task.Run
Concurrency
Warning
DAPR1412
A blocking call inside an actor turn
Concurrency
Warning
DAPR1413
Reading wall-clock time instead of an injected TimeProvider
Determinism
Warning
DAPR1414
An unseeded nondeterministic source, such as Guid.NewGuid() or Random
Determinism
Warning
DAPR1415
A state migration target that is unreachable because no upcaster migrates to it
Compatibility
Warning
DAPR1416
Business logic inside an IActorTurnFilter
Design
Info
DAPR1417
An actor interface method with an unsupported return type
Usage
Warning
DAPR1418
An in-place change to a shipped actor interface wire contract
Compatibility
Warning
DAPR1419
Mutable shared state held in an actor instance field
Concurrency
Warning
DAPR1420
Multiple actors that share a contract register the same actor type name
Usage
Warning
DAPR1421
A [DaprActor] implementation that does not expose a [GenerateActorClient] contract
Usage
Warning
DAPR1423
A state type that looks like part of a migration family but is not connected to it
Compatibility
Warning
DAPR1424
A gap between ordered fragments in an actor state migration chain
Compatibility
Warning
DAPR1425
A non-additive state migration step that requires a hand-authored upcaster
Compatibility
Warning
DAPR1426
An actor state migration family with more than one fold path to a target
Compatibility
Warning
DAPR1427
One persisted actor state name used with multiple migration families
Usage
Warning
DAPR1428
Actor code that targets an older state type while a later reachable version exists
Usage
Info
DAPR1429
A scheduled reminder/timer callback that matches no dispatchable actor method
Usage
Error
DAPR1430
A scheduled reminder/timer that targets an actor type not found in this application
Usage
Warning
DAPR1431
A scheduled reminder/timer callback method that is not exposed through a generated actor client
Usage
Error
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.
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:
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:
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.
Why System.Text.Json by default
The generated proxies and dispatchers serialize using only the strongly-typed generic paths with concrete compile-time types, which keeps the whole actor path trim- and AOT-safe. This is the same serializer abstraction used by Dapr State and Workflows, so an application serializes consistently across building blocks.
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:publicrecordCartItem(stringSku,intQuantity);publicsealedclassCartState(stringownerId){publicstringOwnerId{get;}=ownerId;publicList<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.
This guarantee is the default serializer's, not the abstraction's
The compatibility above is a property of System.Text.Json, which is the default IDaprSerializer. If you replace the serializer with your own IDaprSerializer implementation, these guarantees no longer apply and depend entirely on what your implementation supports: a custom serializer may impose its own constructor, attribute, or type-shape requirements. What holds for everyone is the abstraction; what holds for records and primary constructors specifically is the behavior of the System.Text.Json default. If you swap the serializer, validate your payload and state types against the rules of the serializer you chose.
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.
publicasyncTaskAddItem(CartItemitem,CancellationTokenct=default){varcart=awaitState.GetOrCreateAsync("cart",()=>newCartState(),ct);// cache hit after first loadcart.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.
The end-of-turn save interacts with retries
The save is what makes a turn’s state changes durable, and the response the SDK sends afterward signals completion. Those two steps are not a single atomic operation, so a turn can be retried after its state was already committed (see delivery guarantees). State-only changes are not automatically safe under a re-run: incrementing a counter or appending an item can apply twice. Make both state updates and external side effects idempotent, or deduplicate using a stable request identity persisted with the change. Callback-stream fault injection can test response loss after commit; it does not roll state back.
The serializer is part of your at-rest contract
State is stored using whatever serializer and settings are configured, so those settings are part of the durability contract, not just a runtime detail. Changing the serializer, or a setting that changes the output shape (enum-as-string versus number, a property naming policy, how nullability or defaults are emitted), can make existing state unreadable even though the CLR type did not change. Treat serializer configuration for state types as a versioned decision: change it deliberately, and if a change would alter the persisted shape, handle it the same way you would a state-shape change, with an upcaster (noting that upcasters do not migrate between serialization configurations) or a compatibility setting. Actor state also lives in a state store with its own value-size limits, so keep per-actor state reasonably sized; the whole state saves as one value per turn.
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.
Make state mutations and external side effects idempotent, or deduplicate requests, so a re-run turn stays safe under at-least-once delivery.
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.publicsealedclassCartStateV1{publicList<string>Skus{get;init;}=[];}publicsealedclassCartStateV2{publicList<CartLine>Lines{get;init;}=[];}publicsealedclassCartStateV3{publicList<CartLine>Lines{get;init;}=[];publicintTotalQuantity{get;init;}}publicsealedrecordCartLine(stringSku,intQuantity);// The hops: the only migration code you write.publicsealedclassCartStateV1ToV2:IActorStateUpcaster<CartStateV1,CartStateV2>{publicValueTask<CartStateV2>UpcastAsync(CartStateV1s,CancellationTokenct=default)=>ValueTask.FromResult(newCartStateV2{Lines=s.Skus.GroupBy(x=>x,StringComparer.Ordinal).Select(g=>newCartLine(g.Key,g.Count())).ToList(),});}publicsealedclassCartStateV2ToV3:IActorStateUpcaster<CartStateV2,CartStateV3>{publicValueTask<CartStateV3>UpcastAsync(CartStateV2s,CancellationTokenct=default)=>ValueTask.FromResult(newCartStateV3{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")]publicsealedclassMigratingCartActor(ActorActivationContextcontext):Actor,IMigratingCartActor{protectedoverrideActorIdId=>context.ActorId;protectedoverrideIActorStateAccessorState=>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.publicasyncTask<CartStateV3>GetState(CancellationTokenct=default)=>(awaitState.GetOrCreateAsync("cart",()=>newCartStateV3(),ct)).Value;publicTaskClear(CancellationTokenct=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.
Keep upcasters pure and deterministic
An upcaster may take injected dependencies, but its body must be pure and deterministic: no wall-clock time, no randomness, no external side effects. This is not stylistic. Invokes are at-least-once, so a turn that folds-and-saves can be re-run after a mid-flight failure, and only a deterministic upcaster produces the identical result on a re-run, which is what makes re-migration idempotent. The determinism analyzers (DAPR1413 for wall-clock time, DAPR1414 for unseeded randomness) apply to upcaster bodies for exactly this reason. Ensuring that non-deterministic functionality is not injected and used in the upcaster is ultimately the responsibility of the developer; by making injection available, the SDK cannot detect all non-deterministic usage.
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.
publicsealedclassMyState{publicstringName{get;init;}="";}publicsealedclassMyStateV2{publicstringName{get;init;}="";publicintAge{get;init;}}publicsealedclassMyStateV3{publicstringName{get;init;}="";publicintAge{get;init;}publicboolActive{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.
Method
Behavior
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.
Actor code never sees the stored version
Reads surface only the migrated value. There is deliberately no SchemaVersion on the returned state, because version-conditional branching in actor code is the anti-pattern migration exists to remove; that logic belongs in an upcaster. If you need to confirm that a fold happened, assert it in a test with the state snapshot API (see Testing) rather than reading a discriminator at run time. Writing branchy if (version == 2) code against your state is a sign a hop is missing.
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:
awaitState.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.
Graduation is reversible
The plain form is pinned to the CLR type you graduated to, so re-enrolling later is just adding a new version and an upcaster from that type (CartStateV3 to CartStateV4): reads deserialize the plain bytes as the graduated type and resume the chain, with that type as the new origin. A store holding a mix of plain and re-enveloped instances is read correctly with no extra work, so graduating is not a one-way door.
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.
Rollback is safe-fail, not first-class
Rolling an older build back onto newer state it cannot interpret is safe but not supported as a working path: the read fails loud and the turn does not persist, so you get a safe, non-corrupting failure rather than a silent downgrade. If you need to support running an older build against newer data, plan for it explicitly (for example by keeping changes additive) rather than assuming a rollback will transparently read forward-written state.
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.
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.
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.
Aspect
State machine actor
Dapr Workflow
Unit of work
A long-lived, addressable entity
An orchestration of steps across services
Lifetime
Indefinite; transitions and persists across activations
Long-lived; can loop indefinitely and continue as new executions, so it is not required to terminate
Execution
Turn-based, single-threaded turns over persisted state
Code replayed against recorded history, compacted by continuing as new
Reading current state
Synchronous, from memory in one turn
Queryable orchestration status
Event rate
Handles high-frequency events as cheap turns
Frequent external events grow replay history, mitigated by compaction
Orientation
Is the thing others interact with
Orchestrates 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.
publicenumAuctionState{Open,Sold,Expired}publicsealedrecordAuctionData{publicdecimalHighBid{get;init;}publicstring?HighBidder{get;init;}}[DaprActor("Auction")]publicsealedclassAuctionActor(ActorActivationContextcontext,IActorTimerSchedulertimers):StateMachineActor<AuctionState,AuctionData>(context,timers,"Auction",newAuctionData()),IAuctionActor{protectedoverridevoidConfigure(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 erroredsm.OnUnhandled(ctx=>thrownewInvalidActorEventException(ctx.State,ctx.Event));}// A command method reifies its payload as an event; the return comes from the matched handler's Reply.publicTask<BidResult>PlaceBid(Bidbid,CancellationTokenct=default)=>Raise<BidResult>(bid,ct);privatevoidScheduleSoftClose(IEffectContext<AuctionState,AuctionData,object>ctx)=>ctx.Timers.Schedule("soft-close",TimeSpan.FromSeconds(30));privatevoidCancelSoftClose(IEffectContext<AuctionState,AuctionData,object>ctx)=>ctx.Timers.Cancel("soft-close");privatevoidAcceptBid(IEffectContext<AuctionState,AuctionData,Bid>ctx){ctx.Update(d=>dwith{HighBid=ctx.Event.Amount,HighBidder=ctx.Event.Bidder});ctx.Timers.Reschedule("soft-close",TimeSpan.FromSeconds(30));// resets the soft-close on each bidctx.Reply(BidResult.Accepted);}privatevoidStartFulfillment(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:
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):
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:awaittimers.RescheduleAsync("Auction",Id,"soft-close",TimeSpan.FromSeconds(30),nameof(Close),arguments:Array.Empty<byte>());publicTaskClose(CancellationTokenct=default)=>Raise<object?>(newCloseAuction(),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
Verb
Meaning
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.
Effects run inside an at-least-once turn
A method invocation is an event, and like any actor invoke it can be redelivered after its state commits if the response is lost (see delivery guarantees). A transition is safe to repeat only if the machine is designed to handle that event idempotently from the committed state. A self-transition, a repeated effect that mutates Data, or another transition accepting the same event can apply changes again. Test duplicate events explicitly and deduplicate where needed. Effects and entry actions that publish events or start workflows also need idempotency, for example by keying a started workflow on a deterministic instance id derived from the actor and the transition rather than a fresh one each time.
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]publicasyncTaskLast_second_bid_extends_close_and_is_not_lost(){awaitusingvarrt=newActorTestRuntime();varauction=rt.CreateActor<IAuctionActor>(ActorId.Create("a1"),"AuctionActor");awaitauction.PlaceBid(newBid(100m,"alice"));rt.Time.Advance(TimeSpan.FromSeconds(29));// just before soft-closevarlate=auction.PlaceBid(newBid(110m,"bob"));// a bid turn and a close-timer turn are now both pendingrt.Time.Advance(TimeSpan.FromSeconds(2));awaitrt.RunToIdle();// scheduler explores the order of the two turnsAssert.Equal(BidResult.Accepted,awaitlate);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.
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.
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.
Package
Subscription streams ship in the Dapr.Actors.Next.Streams namespace, included in the Dapr.Actors.Next meta-package. It uses Dapr pub/sub and actor invocation as they exist today, so it works against the current runtime with no runtime changes.
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.
The routing key determines the actor id that gets the event. The streaming service opens the subscription, deserializes the event, and forwards the invocation; the application supplies the subscription metadata through the registry. The routing key can be a property of the event, a CloudEvents attribute such as subject, or a content path.
[Subscribe] documents the actor method’s subscription target, but the current implementation does not discover that attribute automatically. The application must add the corresponding ActorStreamSubscription to the stream registry. The streaming host that opens the subscriptions is a separate service you opt into:
The registry entry supplies the pub/sub component, topic, actor type, dispatch method, and routing key. The attribute and registry entry should describe the same subscription; the registry is the configuration that the hosted subscriber uses.
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.
Order and hosting are the component's, not the actor's
Two things follow from the subscription living at the host and delivery coming from the pub/sub component. First, ordering is whatever the component provides, which is generally not a total order across events, and the forward-invoke plus placement adds no ordering of its own, so do not assume a handler sees events in publish order. Where order matters, carry a sequence or timestamp in the event and let the actor reconcile, rather than relying on arrival order. Second, an event is only consumed if an instance of the hosting app is running to receive it; the subscription is not durable in the actor. If no host is up, delivery follows the component’s retention and redelivery behavior, not the actor’s. Combined with at-least-once delivery, this is why [Subscribe] handlers must be idempotent and order-tolerant.
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:
privatestaticreadonlyActorStreamSubscriptionSubscription=new("orders-pubsub","inventory-restocked","CartActor",nameof(ICartActor.OnRestock),nameof(RestockEvent.CartId));privatestaticreadonlyJsonSerializerOptionsWebJson=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.privatestaticActorStreamSubscriptionRunnerRunner(ActorTestRuntimert){varinvocationClient=(IActorInvocationClient)rt.GetType().GetProperty("Runtime",BindingFlags.Instance|BindingFlags.NonPublic)!.GetValue(rt)!;returnnewActorStreamSubscriptionRunner(newActorStreamForwarder(invocationClient,newActorStreamRoutingKeyExtractor()),newDefaultActorStreamFailureClassifier());}privatestaticActorStreamEventEvent(RestockEventevt)=>new("event-1","orders-pubsub","inventory-restocked",Encoding.UTF8.GetBytes(JsonSerializer.Serialize(evt,WebJson)),newDictionary<string,string>());
[Fact]publicasyncTaskRestock_event_routes_to_the_cart_in_its_route_key(){awaitusingvarrt=newActorTestRuntime(services=>services.AddDaprActors(_=>{}));varcart=rt.CreateActor<ICartActor>(ActorId.Create("u1"),"CartActor");vardelivery=Runner(rt).ProcessEventAsync(Subscription,Event(newRestockEvent{CartId="u1",Sku="sku-1"}));awaitrt.RunToIdle();Assert.Equal(ActorStreamDeliveryAction.Ack,awaitdelivery);Assert.Contains("sku-1",(awaitcart.GetSummary()).Available);}[Fact]publicasyncTaskTransient_failure_is_retried_not_acked(){awaitusingvarrt=newActorTestRuntime(services=>services.AddDaprActors(_=>{}));_=rt.CreateActor<ICartActor>(ActorId.Create("u1"),"CartActor");rt.Faults.FailNextStateWrite<CartState>();// first delivery fails mid-turnvardelivery=Runner(rt).ProcessEventAsync(Subscription,Event(newRestockEvent{CartId="u1",Sku="sku-1"}));awaitrt.RunToIdle();Assert.Equal(ActorStreamDeliveryAction.Retry,awaitdelivery);// 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.
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.
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.privatestaticIServiceProviderProvider(ActorTestRuntimert)=>(IServiceProvider)rt.GetType().GetField("provider",BindingFlags.Instance|BindingFlags.NonPublic)!.GetValue(rt)!;[Fact]publicasyncTaskGateway_forwards_a_dynamic_call_to_the_actor(){awaitusingvarrt=newActorTestRuntime(services=>services.AddDaprActors(_=>{}));rt.CreateActor<ICartActor>(ActorId.Create("u1"),"CartActor");// host the target in the runtimevarclient=Provider(rt).GetRequiredService<IDynamicActorClient>();varjson=awaitclient.InvokeAsync("CartActor","u1","GetSummary","{}");Assert.NotNull(json);}[Fact]publicasyncTaskRegistry_exposes_hosted_types_and_their_signatures(){awaitusingvarrt=newActorTestRuntime(services=>services.AddDaprActors(_=>{}));varregistry=Provider(rt).GetRequiredService<IActorRegistry>();Assert.True(registry.TryGet("CartActor",outvarcart));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.
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.
.NET framework support
Be advised that .NET 8 and .NET 9 reach end of support in November 2026. Support for both will be dropped
in Dapr v1.20; only .NET 10 and .NET 11 will be supported through the end of 2027.
Package
The in-memory runtime is in the Dapr.Actors.Next.Testing namespace. It is one of the packages that make up the Dapr.Actors.Next meta-package, and it is also published to NuGet on its own, so you install it directly into your test project rather than pulling the full meta-package there. It drives your real actors through the same generated dispatchers used in production, so there are no test-only actor implementations.
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, from a seed, with strategies that include a seeded random walk and a priority-based strategy. Independent 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. StepAsync starts one runnable turn; RunToIdle drives scheduled work until no runnable or in-flight turns remain.
The runtime also provides virtual time through TimeProvider, production in-process timer/reminder adapters, and introspection of persisted state and the scheduling transcript. Remote-call simulation checks call chains without ambient execution-context propagation. State-write pauses let you hold a turn at a known persistence boundary while testing competing work. Fault injection covers state writes, migration, and invocation; callback-stream faults additionally require a decorated transport and a running stream manager. For state-machine actors, structural analysis detects defects in the transition table.
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:
Create a runtime with your application’s actor and dependency registrations, create an actor proxy, drive the scheduler, and assert. The examples assume the illustrated actor contracts and implementations are discoverable by AddDaprActors; include any dependencies they require in the service configuration. The [Fact] and Assert surface is the same as earlier xUnit versions.
Start each call before advancing the scheduler, and await its result afterward. Awaiting a queued call before RunToIdle can leave the test waiting indefinitely. Use StepAsync instead when a test intentionally pauses a turn: RunToIdle cannot finish while that turn remains paused.
CreateActor always takes the actor type name
CreateActor<TActor>(actorId, actorType) requires the actor type name; there is no overload that infers it from TActor. Pass the name the actor is hosted under: its [DaprActor] name (or the type-name fallback), or the alias you registered it with (see registering an actor under a custom name). Requiring the name also removes any ambiguity when one interface backs several actor types.
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.varactorType=configuration.GetValue("QUEUE_ACTOR_TYPE_NAME","QueueActor");varqueue=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.
The runtime injects CoreActorTimerScheduler and CoreActorReminderScheduler, the production in-process adapters, backed by rt.Time. Run the turn that registers a timer or reminder, advance the clock to admit due callbacks, and then drive their mailbox turns:
[Fact]publicasyncTaskAbandoned_cart_reminder_fires_after_thirty_minutes(){awaitusingvarrt=newActorTestRuntime(services=>services.AddDaprActors());varcart=rt.CreateActor<ICartActor>(ActorId.Create("u1"),"CartActor");varadd=cart.AddItem(newCartItem("sku-1",1));awaitrt.RunToIdle();awaitadd;rt.Time.Advance(TimeSpan.FromMinutes(30));// admits the registered abandon-cart reminderawaitrt.RunToIdle();varsummary=cart.GetSummary();awaitrt.RunToIdle();Assert.True((awaitsummary).IsAbandoned);}
The example assumes AddItem registers the reminder. Callbacks are independent turns, not reentrant continuations of the turn that registered them. The adapters suppress execution-context capture, so a timer or reminder does not inherit that turn’s call chain.
Test replacement and cancellation by registering the same timer or reminder name again, canceling it, and advancing time. Cancellation prevents future callback admission; a callback already admitted may still complete. VirtualActorTimeProvider implements TimeProvider.CreateTimer, including change, disposal, and execution-context capture; its direct ScheduleTimer and ScheduleReminder helpers remain available. Calls to Time.Advance must not overlap or recurse.
The provider visits each due time in order, breaking ties by registration order, and invokes timer callbacks synchronously outside its registration lock. Those callbacks can change or cancel timers. If a timer callback throws, advancement stops at that due time. Actor callback admission is separate from executing the queued actor turn, which still requires the controlled scheduler.
Virtual time does not advance a real sidecar’s clock or reproduce durable scheduler delivery. Keep those checks in integration tests.
State-write failure
A state-write fault fails the matching persistence attempt; it does not automatically retry the invocation. Inspect persisted state with StateOf rather than issuing a read turn that could flush dirty cached state:
This checks a previously unpersisted "cart" entry. It does not assert rollback of actor fields or cached mutations, or of writes that already succeeded. Test recovery explicitly according to your application’s retry and idempotency policy.
Turn ordering: a reminder runs after the in-flight turn, not during it
Independent 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. Use a state-write pause to ensure the method is actually in flight before advancing time, rather than relying on how quickly an asynchronous call starts. This example assumes AddItem registers the abandon-cart reminder:
[Fact]publicasyncTaskReminder_due_during_a_turn_runs_after_it_and_sees_committed_state(){awaitusingvarrt=newActorTestRuntime(services=>services.AddDaprActors());varcart=rt.CreateActor<ICartActor>(ActorId.Create("u1"),"CartActor");usingvartimeout=newCancellationTokenSource(TimeSpan.FromSeconds(10));usingvarpause=rt.Faults.PauseNextStateWrite<CartState>(stateName:"cart");varadd=cart.AddItem(newCartItem("sku-1",1));awaitrt.StepAsync(timeout.Token);awaitpause.Reached.WaitAsync(timeout.Token);try{rt.Time.Advance(TimeSpan.FromMinutes(30));Assert.False(awaitrt.StepAsync(timeout.Token));// this actor is still occupiedAssert.Null(rt.StateOf(cart).Get<CartState>("cart"));}finally{pause.Release();}awaitrt.RunToIdle(timeout.Token);awaitadd;varsummary=cart.GetSummary();awaitrt.RunToIdle(timeout.Token);varresult=awaitsummary;Assert.Equal(1,result.ItemCount);Assert.True(result.IsAbandoned);}
Only an invocation in the active, matching call chain can be admitted as reentrant, subject to EnableReentrancy and MaxReentrantDepth. The canonical header is Dapr-Reentrancy-Id. The SDK also accepts the legacy dapr-reentrant-id and x-dapr-reentrant-id aliases case-insensitively, preferring the canonical name.
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.
Testing reentrancy across a remote boundary
Set ActorTestRuntimeOptions.SimulateRemoteCalls to remove the caller’s ambient ExecutionContext from invocations through the test invocation client while preserving propagated headers. This exercises self-calls and A-to-B-to-A callbacks without relying on AsyncLocal state surviving a transport boundary:
Unmatched invocations do not consume this one-shot control. Simulation still uses in-memory serialization and dispatch; it does not test network routing, placement, authentication, or the real wire protocol.
Set reentrancy options explicitly when comparing in-memory and sidecar behavior. The SDK enforces an explicit EnableReentrancy = false and the maximum depth; per-type settings override application settings. When unset, the in-process runtime retains its historical local-reentrancy behavior, while Dapr separately enforces the advertised configuration.
Await nested calls. Reentrant admission does not make actor fields or cached state safe for arbitrary parallel branches or fire-and-forget work. A scheduler seed reproduces its ordering decisions, not every thread-pool interleaving introduced by remote-call simulation; use explicit pauses to pin boundaries and Coyote where deeper exploration is needed.
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.
awaitusingvarrt=newActorTestRuntime(services=>services.AddDaprActors(),options:newActorTestRuntimeOptions{// Replay the exact ordering by re-seeding the scheduler the failing run reported.Scheduler=newPriorityActorScheduler(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]publicasyncTaskConcurrent_bids_never_lose_the_high_bid(){for(varseed=0;seed<1000;seed++){awaitusingvarrt=newActorTestRuntime(services=>services.AddDaprActors(_=>{}),newActorTestRuntimeOptions{Scheduler=newSeededRandomActorScheduler(seed)});varauction=rt.CreateActor<IAuctionActor>(ActorId.Create("a1"),"Auction");vara=auction.PlaceBid(newBid(100m,"alice"));varb=auction.PlaceBid(newBid(110m,"bob"));awaitrt.RunToIdle();awaitTask.WhenAll(a,b);Assert.Equal(110m,rt.StateOf(auction).Data<AuctionData>()!.HighBid);// the seed pins the failing order}}
Fault injection
rt.Faults queues controls for the next matching operation. Every FailNext... control is one-shot, including when transient: false is passed. transient: true (the default) selects ActorInjectedTransientException; false selects ActorInjectedPermanentException. It changes the exception classification, not how many operations fail. Queue another fault for each additional failure you want to exercise. Nonmatching operations do not consume a fault.
Control
Boundary and filters
FailNextStateWrite<TState>()
Before persisting a value of that type, including an explicit save or end-of-turn save. Optional stateName: limits it to one entry.
FailNextMigration<TState>()
Before a migrating read targeting that type. Optional stateName:.
FailNextUpcastHop<TFrom, TTo>()
Before a specific migration hop. Optional stateName:.
FailNextInvocation()
Before dispatch at the test invocation boundary. Optional actorType: and methodName:.
PauseNextStateWrite<TState>()
Pauses before persistence and returns an ActorFaultPause. Optional stateName:.
SimulateNextRemoteInvocation()
Removes ambient execution context from the next matching invocation through the test invocation client, preserving headers. Optional actorType: and methodName:.
FailNextStreamRead()
After receiving an inbound frame but before delivering it to the runtime. Optional frame kind:; requires a decorated callback transport.
FailNextStreamWrite()
Before forwarding an outbound frame. Optional frame kind:; requires a decorated callback transport.
Fault injection does not install an automatic invocation retry policy. Assert the failed attempt, then explicitly exercise recovery or redelivery. A state-write failure and a response lost after a successful commit are different scenarios: neither provides a general rollback or exactly-once guarantee. See delivery guarantees and state migration.
Pause a state write and race deactivation
PauseNextStateWrite<TState> stops the next matching write immediately before persistence. Start the call, use StepAsync to let it reach the boundary, and await ActorFaultPause.Reached before introducing competing work:
External deactivation is queued behind active work, so drive the scheduler before awaiting it. A deactivation requested by the active call chain is deferred until the turn and its admitted children finish. The activation is not disposed while that work is still using it.
Release() allows the write to continue; calling it before the boundary is reached is safe. Disposing the pause also releases it. A paused write honors its own cancellation token. Canceling RunToIdle cancels the scheduler wait, not an already-running turn; always release the pause during cleanup. You can also advance virtual time while paused to test callback ordering, or run a call to another actor to verify independent progress.
Inject callback-stream failures
These controls target the actor-hosting SubscribeActorEvents stream, not a pub/sub subscription stream. ActorTestRuntime does not open that transport. Run a SubscribeActorEventsStreamManager over a fake transport for a protocol-level unit test, or use a real actor host for an integration test, and decorate its transport with FaultInjectingActorEventsTransport.
Register the decorator after actor services in the test host:
createTransport is your existing factory for the underlying fake transport or DaprActorEventsTransport; preserve its endpoint and API-token configuration. Do not resolve ISubscribeActorEventsTransport inside that factory, which would recursively resolve the decorator. The IActorStateFaultInjector registration is needed only if this host also uses state-write or migration faults.
Resolve the same ActorFaults instance from the test host and arm a fault before sending the request under test:
A read fault consumes the selected inbound frame without dispatching it. A write fault prevents the selected response from being forwarded; for a successful invoke response, actor state has already committed. Omitting kind selects the next frame in that direction, which may be the initial registration frame rather than an invoke. Both controls leave nonmatching frames untouched and throw the selected injected exception once.
Assert reconnection, re-advertisement of the actor type, and subsequent progress. Allow for the stream manager’s five-second reconnect delay; a fake-transport test can drive its injected TimeProvider, while rt.Time does not control a real host automatically. The decorator does not redeliver a request for you. Explicitly resend it in a fake-transport test or exercise your configured retry path with a real sidecar, and assert that an idempotent operation does not duplicate state changes or external effects after response loss.
The stream reader continues admitting callbacks while earlier calls are suspended, so nested callbacks are not blocked behind a fixed worker limit. Outstanding calls still consume memory proportional to admitted load. Disconnects cancel accepted work; actor code must honor cancellation, and code that ignores it may outlive the failed stream.
The SDK’s testing guide describes regression coverage in RuntimeConcurrencyTests, TimerConcurrencyTests, StreamConcurrencyTests, ActorStreamFaultTests, VirtualTimerConcurrencyTests, and RealSidecarConcurrencyTests.
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.
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:
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:
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 concurrencycoyote rewrite ./bin/Debug/net10.0/MyActors.Tests.dll
# Systematically explore a test entry point for N iterationscoyote 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.
When to reach for Coyote
Start with the built-in runtime; it requires no rewriting and covers the actor model’s nondeterminism out of the box. Add Coyote when an actor’s handlers legitimately do complex in-process asynchrony you want explored, or when you want data-race detection across code the SDK does not own.
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.
What unit tests prove, and what they do not
The in-memory runtime is a faithful model of the SDK’s own scheduler, time, and delivery behavior, so it proves your logic, your ordering assumptions, your fault handling, and your migrations. It is not the real runtime. It does not prove that your serialized payloads deserialize against a live daprd, that placement and reminders behave as configured, that a real stream reconnect re-advertises correctly, or that wall-clock timing holds under load. Keep the bulk of your coverage in the fast in-memory tests, and use a small number of integration tests to prove the parts only a real sidecar can. A green unit suite is necessary but not sufficient for the protocol edges.
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.
publicsealedclassDaprActorFixture: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.publicasyncValueTaskInitializeAsync(){/* start containers */}publicasyncValueTaskDisposeAsync(){/* stop containers */}}publicclassCartActorIntegrationTests(DaprActorFixturefixture):IClassFixture<DaprActorFixture>{ [Fact]publicasyncTaskReminder_fires_through_the_real_scheduler(){varcart=ActorProxy.Create<ICartActor>(ActorId.Create("u1"),"CartActor");awaitcart.AddItem(newCartItem("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.
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.
Package references
All projects within the Dapr .NET SDK repository are referenced using relative paths. Meta-packages like Dapr.Actors.Next are comprised of all their individual projects (e.g. Dapr.Actors.Next.Streams, Dapr.Actors.Next.Testing, etc.) and are combined only an artifact of the build pipeline. As such, the individual projects in the examples below refernce each of these individual projects within the repository. These packages are not intended to be published to NuGet and only for local SDK experimentation and development. When using this package in your own projects, whether host or test projects, it is intended that you install Dapr.Actors.Next from NuGet. When cloning this repository, they’ll build and run using these local references, but there are no corresponding packages for each in NuGet - only Dapr.Actors.Next.
Temporarily placed in `feature-actors-next` branch
The Dapr.Actors.Next package has been released as a preview and is subject to change. It has not yet been merged into the master branch of the Dapr .NET SDK repository, but if you’d like to clone the branch to contribute or experiment with the examples, you can find it here. This is expected to be released as stable alongside the v1.19 release of the Dapr runtime at this point it will be merged into master and easily accessible.
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.
Part 1: The cart, the modern baseline. The actor you already know, with the ceremony removed. The reminder test that used to need a sidecar now needs nothing.
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, 10, or 11 SDK (the examples target .NET 10).
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.
.NET framework support
Be advised that .NET 8 and .NET 9 reach end of support in November 2026. Support for both will be dropped
in Dapr v1.20; only .NET 10 and .NET 11 will be supported through the end of 2027.
If you are new to the SDK, skim the overview first; each part links to the relevant concept page for reference detail.
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]publicinterfaceICartActor:IActor{TaskAddItem(CartItemitem,CancellationTokenct=default);Task<CartSummary>GetSummary(CancellationTokenct=default);TaskAbandonCart(CancellationTokenct=default);}publicsealedrecordCartItem(stringSku,intQuantity);publicsealedrecordCartSummary(intItemCount,decimalTotal,boolAbandoned);publicinterfaceIPricingClient{ValueTask<decimal>GetPriceAsync(stringsku,CancellationTokencancellationToken=default);}publicsealedclassCartState{publicDictionary<string,int>Items{get;set;}=[];publicDictionary<string,decimal>Prices{get;set;}=[];publicboolAbandoned{get;set;}}[DaprActor("Cart")]publicsealedclassCartActor(ActorActivationContextcontext,IPricingClientpricing,IActorTimerSchedulertimers):Actor,ICartActor{privatestaticreadonlyTimeSpanAbandonAfter=TimeSpan.FromMinutes(20);protectedoverrideActorIdId=>context.ActorId;protectedoverrideIActorStateAccessorState=>context.State;publicasyncTaskAddItem(CartItemitem,CancellationTokenct=default){varcart=awaitState.GetOrCreateAsync("cart",()=>newCartState(),ct);cart.Value.Items[item.Sku]=cart.Value.Items.GetValueOrDefault(item.Sku)+item.Quantity;// mutate through .Valuecart.Value.Prices[item.Sku]=awaitpricing.GetPriceAsync(item.Sku,ct);// injected per activationcart.Value.Abandoned=false;// Re-arm the idle timer on every add; its callback is the AbandonCart method.awaittimers.RescheduleAsync("Cart",Id,"abandon-cart",AbandonAfter,nameof(AbandonCart),string.Empty,cancellationToken:ct);}publicasyncTask<CartSummary>GetSummary(CancellationTokenct=default){varcart=awaitState.GetOrCreateAsync("cart",()=>newCartState(),ct);varitemCount=cart.Value.Items.Values.Sum();vartotal=cart.Value.Items.Sum(entry=>entry.Value*cart.Value.Prices.GetValueOrDefault(entry.Key));returnnewCartSummary(itemCount,total,cart.Value.Abandoned);}publicasyncTaskAbandonCart(CancellationTokenct=default){varcart=awaitState.GetOrCreateAsync("cart",()=>newCartState(),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.
[Fact]publicasyncTaskAdvancing_virtual_time_fires_the_abandon_timer(){awaitusingvarruntime=CreateRuntime();varcart=runtime.CreateActor<ICartActor>(ActorId.Create("idle-cart"),"Cart");varadd=cart.AddItem(newCartItem("sku-1",1));awaitruntime.RunToIdle();awaitadd;runtime.Time.Advance(TimeSpan.FromMinutes(20));// fires the abandon timer; no real waitawaitruntime.RunToIdle();varsummaryTask=cart.GetSummary();awaitruntime.RunToIdle();Assert.True((awaitsummaryTask).Abandoned);}privatestaticActorTestRuntimeCreateRuntime(){_=typeof(CartActor);returnnewActorTestRuntime(services=>{services.AddSingleton<IPricingClient,FakePricingClient>();services.AddDaprActors(_=>{});});}privatesealedclassFakePricingClient:IPricingClient{publicValueTask<decimal>GetPriceAsync(stringsku,CancellationTokencancellationToken=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.
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/.
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 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.
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")]publicsealedclassRestockingCartActor(ActorActivationContextcontext):Actor,IRestockingCartActor{protectedoverrideActorIdId=>context.ActorId;protectedoverrideIActorStateAccessorState=>context.State; [Subscribe("orders-pubsub", "inventory-restocked", RouteBy = nameof(RestockEvent.CartId))]publicasyncTaskOnRestock(RestockEventevt,CancellationTokenct=default){varcart=awaitState.GetOrCreateAsync("cart",()=>newRestockingCartState(),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.
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.
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/.
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]publicasyncTaskLast_second_bid_wins_race_with_close_timer(){awaitusingvarruntime=CreateRuntime(newPriorityActorScheduler(7));varauction=runtime.CreateActor<IAuctionActor>(ActorId.Create("auction-1"),"Auction");awaitPlaceBid(runtime,auction,newBid(100,"alice"));runtime.Time.Advance(TimeSpan.FromSeconds(29));varlateBid=auction.PlaceBid(newBid(110,"bob"));awaitruntime.RunToIdle();Assert.Equal(BidResult.Accepted,awaitlateBid);runtime.Time.Advance(TimeSpan.FromSeconds(2));awaitruntime.RunToIdle();Assert.Equal(AuctionState.Open,awaitReadState(runtime,auction));Assert.Equal("bob",(awaitReadData(runtime,auction)).HighBidder);}[Fact]publicvoidState_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);}privatestaticActorTestRuntimeCreateRuntime(ControlledActorScheduler?scheduler=null){_=typeof(AuctionActor);returnnewActorTestRuntime(services=>services.AddDaprActors(_=>{}),newActorTestRuntimeOptions{Scheduler=scheduler});}privatestaticasyncTask<BidResult>PlaceBid(ActorTestRuntimeruntime,IAuctionActorauction,Bidbid){varplaced=auction.PlaceBid(bid);awaitruntime.RunToIdle();returnawaitplaced;}privatestaticasyncTask<AuctionState>ReadState(ActorTestRuntimeruntime,IAuctionActorauction){varread=auction.GetState();awaitruntime.RunToIdle();returnawaitread;}privatestaticasyncTask<AuctionData>ReadData(ActorTestRuntimeruntime,IAuctionActorauction){varread=auction.GetData();awaitruntime.RunToIdle();returnawaitread;}
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.
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.publicstaticclassDeviceManagementDemo{publicstaticInterpretedMachineDefinitionSmartLockDefinition(boolincludeUnlockingCompletion=true,stringmotorEffect="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),};publicstaticInterpretedMachineVerificationResultVerify(IInterpretedMachineVerifierverifier,InterpretedMachineDefinitiondefinition)=>verifier.Verify(definition);privatestaticIReadOnlyList<InterpretedTransitionDefinition>Transitions(boolincludeUnlockingCompletion,stringmotorEffect){vartransitions=newList<InterpretedTransitionDefinition>{new(){Source="Locked",Event="Unlock",Branches=[newInterpretedBranchDefinition{Guards=["CheckBattery"],Target="Unlocking",Effects=[motorEffect]}],},new(){Source="Unlocked",Event="Lock",Branches=[newInterpretedBranchDefinition{Otherwise=true,Target="Locking",Effects=[motorEffect]}],},new(){Source="Locking",Event="MotorStopped",Branches=[newInterpretedBranchDefinition{Otherwise=true,Target="Locked"}],},};if(includeUnlockingCompletion){transitions.Add(newInterpretedTransitionDefinition{Source="Unlocking",Event="MotorStopped",Branches=[newInterpretedBranchDefinition{Otherwise=true,Target="Unlocked"}],});}returntransitions;}}
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]publicvoidVerification_rejects_device_type_that_can_strand_a_lock(){varverifier=newInterpretedMachineVerifier(newDeviceCapabilityRegistry());varstranded=DeviceManagementDemo.SmartLockDefinition(includeUnlockingCompletion:false);varresult=DeviceManagementDemo.Verify(verifier,stranded);Assert.False(result.IsValid);Assert.Contains(result.Defects,defect=>defect.Contains("State 'Unlocking' is a dead end",StringComparison.Ordinal));}[Fact]publicasyncTaskDefinition_with_unregistered_effect_is_rejected_before_rollout(){varregistry=newDeviceCapabilityRegistry();varverifier=newInterpretedMachineVerifier(registry);varstore=newInMemoryInterpretedMachineStore();vardeployer=newInterpretedMachineDeployer(verifier,store);vardefinition=DeviceManagementDemo.SmartLockDefinition(motorEffect:"UnknownMotor");Assert.False(registry.TryGetEffect("UnknownMotor",out_));varex=awaitAssert.ThrowsAsync<InvalidOperationException>(()=>deployer.DeployAsync("SmartLock",FrontDoor,definition).AsTask());Assert.Contains("Effect 'UnknownMotor'",ex.Message,StringComparison.Ordinal);Assert.Null(awaitstore.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.
5.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.
publicstaticInterpretedMachineDefinitionExpenseReport(boolincludeSettlementCompletion=true,stringsettlementEffect=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]},newInterpretedStateDefinition{Name="Rejected",Terminal=true},newInterpretedStateDefinition{Name="Archived",Terminal=true},newInterpretedStateDefinition{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"]},newInterpretedBranchDefinition{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.
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.
publicoverrideasyncTask<SettlementResult>RunAsync(WorkflowContextcontext,SettlementInputinput){// 1. Fan out: notify every party in parallel.awaitTask.WhenAll(input.Parties.Select(party=>context.CallActivityAsync(nameof(NotifyPartiesActivity),newPartyNotification(input.DocumentId,party))));// 2. Retry: charge under a backoff policy.try{awaitcontext.CallActivityAsync(nameof(ChargeOrProvisionActivity),newChargeRequest(input.DocumentId,input.Amount,input.SimulateChargeFailure),ChargeRetry);}catch(WorkflowTaskFailedException){// 3. Compensate, then report the failure back to the document entity.awaitcontext.CallActivityAsync(nameof(ReleaseReservationActivity),newReleaseRequest(input.DocumentId,input.Amount));awaitcontext.CallActivityAsync(nameof(SignalDocumentActivity),newDocumentSignal(input.DocumentId,"SettlementFailed"));returnnewSettlementResult(Settled:false,FinalState:"SettlementFailed");}// 4. Success: drive the document to its archived outcome.awaitcontext.CallActivityAsync(nameof(SignalDocumentActivity),newDocumentSignal(input.DocumentId,"SettlementCompleted"));returnnewSettlementResult(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]publicasyncTaskApproving_schedules_settlement_with_a_deterministic_id_and_does_not_double_schedule(){varscheduled=newList<string>();varworkflowClient=newMock<IDaprWorkflowClient>();workflowClient.Setup(client=>client.ScheduleNewWorkflowAsync(It.IsAny<string>(),It.IsAny<string?>(),It.IsAny<object?>())).Returns((string_,string?instanceId,object?_)=>{scheduled.Add(instanceId!);returnTask.FromResult(instanceId!);});vareffect=newStartSettlementEffect(workflowClient.Object,NullLogger.Instance);varcontext=ApprovedContext("exp-9",amount:250m,parties:["finance","alice"]);awaiteffect.ExecuteAsync(context);awaiteffect.ExecuteAsync(context);// simulate an at-least-once re-run of the approving turnvarexpected=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]publicasyncTaskCharge_that_exhausts_its_retries_compensates_and_fails_the_document(){varinput=newSettlementInput("exp-2","ExpenseReport","carol",8500m,["finance","carol","vendor"],SimulateChargeFailure:true);varcontext=MockContext();context.Setup(ctx=>ctx.CallActivityAsync(nameof(ChargeOrProvisionActivity),It.IsAny<ChargeRequest>(),It.IsAny<WorkflowTaskOptions>())).Returns(Task.FromException(newWorkflowTaskFailedException("charge failed",newWorkflowTaskFailureDetails("Declined","Charge was declined"))));varresult=awaitnewSettlementWorkflow().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.
Dapr.AI provides a dedicated client for interacting with Dapr’s AI workloads from a .NET application. Today, Dapr provides the Conversational API to engage with large language models, and the DaprConversationClient is the entry point for sending prompts, configuring response formats, managing prompt cache retention, and reading token usage statistics.
For developers already working with the Microsoft.Extensions.AI ecosystem, the Dapr.AI.Microsoft.Extensions package implements IChatClient on top of the Dapr Conversation building block, so you can use Dapr as a backend for any IChatClient-compatible code, including tool and function calling.
Runtime requirements
The Dapr Conversation building block requires Dapr runtime v1.16.0 or later. Response format, prompt cache retention,
and token usage statistics require Dapr runtime v1.17.0 or later.
The Dapr AI client package allows you to interact with the AI capabilities provided by the Dapr sidecar.
Note
The Dapr Conversation building block requires Dapr runtime v1.16.0 or later. Response format, prompt cache retention,
and token usage statistics require Dapr runtime v1.17.0 or later.
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().
vardaprConversationClient=newDaprConversationClientBuilder().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.
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 canceled, it can throw an OperationCanceledException.
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.
varbuilder=WebApplication.CreateBuilder(args);builder.Services.AddDaprConversationClient();//Registers the `DaprConversationClient` to be injected as neededvarapp=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 DaprConversationClientBuilder and exposes methods for configuring
the necessary options.
varbuilder=WebApplication.CreateBuilder(args);builder.Services.AddDaprConversationClient((_,daprConversationClientBuilder)=>{//Set the API tokendaprConversationClientBuilder.UseDaprApiToken("abc123");//Specify a non-standard HTTP endpointdaprConversationClientBuilder.UseHttpEndpoint("http://dapr.my-company.com");});varapp=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:
varbuilder=WebApplication.CreateBuilder(args);//Register a fictional service that retrieves secrets from somewherebuilder.Services.AddSingleton<SecretService>();builder.Services.AddDaprConversationClient((serviceProvider,daprConversationClientBuilder)=>{//Retrieve an instance of the `SecretService` from the service providervarsecretService=serviceProvider.GetRequiredService<SecretService>();vardaprApiToken=secretService.GetSecret("DaprApiToken").Value;//Configure the `DaprConversationClientBuilder`daprConversationClientBuilder.UseDaprApiToken(daprApiToken);});varapp=builder.Build();
Sending a conversation request
Use ConversationInput and ConversationOptions to send prompts to your conversation component:
usingDapr.AI.Conversation;usingDapr.AI.Conversation.ConversationRoles;varinputs=new[]{newConversationInput(newIConversationMessage[]{newSystemMessage{Content=[newMessageContent("You are a helpful assistant.")]},newUserMessage{Content=[newMessageContent("Explain Dapr service invocation briefly.")]}})};varoptions=newConversationOptions("my-conversation-component"){Temperature=0.2};varresponse=awaitdaprConversationClient.ConverseAsync(inputs,options);foreach(varoutputinresponse.Outputs){foreach(varchoiceinoutput.Choices){Console.WriteLine($"{choice.Index}: {choice.Message.Content} ({choice.FinishReason})");}}
Each ConversationInput contains a list of role-specific messages. The SDK supports SystemMessage,
DeveloperMessage, UserMessage, AssistantMessage, and ToolMessage. Content consists of text
MessageContent entries; this API does not expose image/audio message parts.
Each response output contains Choices, optional Model, and optional Usage. Each choice has an
Index, nullable FinishReason, and a Message with text and a list of tool calls. Do not assume the
response always contains a single text choice.
Request options and conversation context
Option
Behavior
ConversationComponentId
Required component name supplied to the ConversationOptions constructor.
ContextId
Optional existing conversation context. A returned context is available as ConversationResponse.ConversationId; retention depends on the runtime/component.
Requests scrubbing of response data. ConversationInput.ScrubPII separately requests scrubbing of input data.
Metadata
Component-specific string key/value metadata.
Parameters
Custom parameters represented as Google.Protobuf.WellKnownTypes.Any values.
Tools, ToolChoice
Per-request tool declarations and selection policy; see tool calling.
ResponseFormat
Optional protobuf Struct describing the requested response schema.
PromptCacheRetention
Optional cache-retention duration where supported.
The client sends these options to the runtime; provider support varies. A context ID is not a client-side
message history store. Keep/send any history your provider requires. ConverseAsync returns a complete
response; this client has no token-streaming API in this release.
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.
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.
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:
Be advised that .NET 8 and .NET 9 reach end of support in November 2026. Support for both will be dropped
in Dapr v1.20; only .NET 10 and .NET 11 will be supported through the end of 2027.
To get started with the Dapr AI .NET SDK client, install the Dapr.AI package from NuGet:
dotnet add package Dapr.AI
These guides target SDK 1.19.0 or later. Configure a conversation component and credentials for its
model provider. Import Dapr.AI.Conversation and Dapr.AI.Conversation.Extensions.
The API is experimental (DAPR_CONVERSATION).
A DaprConversationClient maintains access to networking resources in the form of TCP sockets used to communicate with the Dapr sidecar.
Dependency Injection
The AddDaprConversationClient() method registers the client with 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.AddDaprConversationClient();
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.AddDaprConversationClient((serviceProvider,clientBuilder)=>{//Inject a service to source a value fromvaroptionsProvider=serviceProvider.GetRequiredService<DefaultOptionsProvider>();varstandardTimeout=optionsProvider.GetStandardTimeout();//Configure the value on the client builderclientBuilder.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().
6.3 - How to: Handle conversation tool calls in .NET
Declare conversation tools, inspect model requests, and return tool results to the model
The conversation client in SDK 1.19.0 or later can declare function tools and return model-requested
tool calls. Declaring a tool does not execute it. Your application validates the request, invokes an allowed
operation, and sends the result back.
Declare a function
usingDapr.AI.Conversation;usingDapr.AI.Conversation.ConversationRoles;usingDapr.AI.Conversation.Tools;varmessages=newList<IConversationMessage>{newUserMessage{Content=[newMessageContent("What time is it in UTC?")]}};varoptions=newConversationOptions("conversation"){Tools= [
new ToolFunction("get_utc_time")
{
Description = "Returns the current UTC time.",
Parameters = new Dictionary<string, object?>
{
["type"]="object", ["properties"]=newDictionary<string,object?>()}}],ToolChoice=ToolChoice.Auto};varresponse=awaitconversationClient.ConverseAsync( [new ConversationInput(messages)],options,cancellationToken);
ToolFunction.Parameters describes a JSON schema, not a dictionary of argument values to execute.
ToolChoice.None, Auto, and Required select a policy, or new ToolChoice("get_utc_time") requests a
particular function by its case-sensitive name. Runtime/provider support for these policies can vary.
Return tool results
Select a response choice according to your application policy. Preserve its assistant tool calls in the next
request, and correlate each result with the returned tool-call ID:
if(response.Outputs.Count==0||response.Outputs[0].Choices.Count==0){thrownewInvalidOperationException("The model returned no conversation choices.");}varchoice=response.Outputs[0].Choices[0];if(choice.Message.ToolCalls.Count==0){Console.WriteLine(choice.Message.Content);return;}messages.Add(newAssistantMessage{Content=[newMessageContent(choice.Message.Content)],ToolCalls=choice.Message.ToolCalls});foreach(vartoolCallinchoice.Message.ToolCalls){if(toolCallisnotCalledToolFunctioncall||call.Name!="get_utc_time"){thrownewInvalidOperationException("The model requested an unsupported tool.");}messages.Add(newToolMessage{Name=call.Name,Id=call.Id,Content=[newMessageContent(DateTimeOffset.UtcNow.ToString("O"))]});}varfollowUp=awaitconversationClient.ConverseAsync( [new ConversationInput(messages)],optionswith{ContextId=response.ConversationId,ToolChoice=ToolChoice.None},cancellationToken);
For tools with arguments, CalledToolFunction.JsonArguments contains the model’s JSON. Parse it into a
known schema and validate values and authorization before executing anything. Never dispatch arbitrary
method names or commands directly from model output. Put a limit on tool-call rounds and make side-effecting
tools idempotent. The example disables further tool calls in the follow-up request.
Be advised that .NET 8 and .NET 9 reach end of support in November 2026. Support for both will be dropped
in Dapr v1.20; only .NET 10 and .NET 11 will be supported through the end of 2027.
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. 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.
Dapr Conversation Building Block
Do note that Dapr’s conversation building block is still in an alpha state, meaning that the shape of the API
is likely to change future releases. It’s the intent of this SDK package to provide an API that’s aligned with
Microsoft’s AI extensions that also maps to and conforms with the Dapr API, but the names of types and properties
may change from one release to the next, so please be aware of this possibility when using this SDK.
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.
Limited Support
Note that Microsoft’s AI extension provide many more properties and methods than Dapr’s conversation building block currently
supports. This package will only map those properties that have Dapr support and will ignore the others, so just because
it’s available in the Microsoft.Extensions.AI package doesn’t mean it’s supported by Dapr. Rely on this documentation
and the exposed XML documentation in the package to understand what is and isn’t supported.
Service Registration
The DaprChatClient can be registered with the dependency injection container using several extension methods. First,
import Dapr.AI.Conversation.Extensions, Dapr.AI.Microsoft.Extensions, and Microsoft.Extensions.AI.
Register the DaprConversationClient that’s part of the Dapr.AI package from NuGet:
services.AddDaprConversationClient();
Then register the DaprChatClient with your conversation component name:
Pass configure: null explicitly in 1.19.0 to disambiguate overloads with optional parameters.
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):
Resolve them with [FromKeyedServices("support")] IChatClient or
GetRequiredKeyedService<IChatClient>("support"). The component-name-only overload also registers a keyed
client using the component name as its key.
Usage
Once registered, you can inject and use IChatClient in your services:
publicclassChatService(IChatClientchatClient){publicasyncTask<IReadOnlyList<string>>GetResponseAsync(stringmessage){varresponse=awaitchatClient.GetResponseAsync([newChatMessage(ChatRole.User,"Please write me a poem in iambic pentameter about the joys of using Dapr to develop distributed applications with .NET")]);returnresponse.Messages.Select(msg=>msg.Text).ToList();}}
Streaming Conversations
The DaprChatClient does not support streaming responses in SDK 1.19.0.
GetStreamingResponseAsync throws NotImplementedException.
Tool Integration
The client maps tool declarations to the model and returns requested calls as FunctionCallContent.
Calling GetResponseAsync does not automatically execute a function. An application-owned tool loop or
function-invocation middleware must execute allowed functions and return results.
stringGetCurrentWeather()=>Random.Shared.NextDouble()>0.5?"It's sunny today!":"It's raining today!";vartoolChatOptions=newChatOptions{Tools=[AIFunctionFactory.Create(GetCurrentWeather,"weather")]};vartoolResponse=awaitchatClient.GetResponseAsync("What's the weather like today?",toolChatOptions);foreach(vartoolRespintoolResponse.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.
Mapping in SDK 1.19.0
The adapter implements a subset of IChatClient, not the complete ConversationOptions surface:
Microsoft.Extensions.AI surface
Dapr mapping
User, assistant, system, and tool roles
Text-only conversation messages. Other roles throw ArgumentOutOfRangeException; non-text parts and structured tool-result/call history are not preserved by the input mapping.
ChatOptions.Temperature
ConversationOptions.Temperature
ChatOptions.ConversationId
ConversationOptions.ContextId
ChatOptions.ToolMode
None, auto, required, or a named required function
ChatOptions.Tools
Tool name, description, and AdditionalProperties mapped to function parameters; validate the schema actually sent for your tool.
ChatOptions.AdditionalProperties
Stringified component metadata, not protobuf Parameters.
Response
Conversation ID, finish reason, text, returned function calls, and each raw choice in RawRepresentation. Token usage and model are not mapped to ChatResponse in this release.
Options such as response format, prompt cache retention, and PII scrubbing are not mapped by this adapter.
Use DaprConversationClient directly when you need those capabilities or full control over the
tool-call conversation history.
Best Practices
Service Lifetime: Use ServiceLifetime.Scoped or ServiceLifetime.Singleton for the DaprChatClient registration to avoid creating multiple instances unnecessarily.
Error Handling: Always wrap calls in appropriate try-catch blocks to handle both Dapr-specific and general exceptions.
Resource Management: DaprChatClient.Dispose is a no-op in this release. DI manages the separately
registered DaprConversationClient; manually constructed applications must keep that client alive and dispose it themselves.
Configuration: Configure your Dapr conversation component properly to ensure optimal performance and reliability.
Dapr.Jobs provides a dedicated client for interacting with the Dapr Job APIs from a .NET application. With DaprJobsClient you can schedule future operations to run according to a predefined schedule with an optional payload. Three scheduling modes are supported: one-time jobs that fire at a specific point in time, interval-based jobs that recur on a fixed TimeSpan, and Cron-based jobs that use a Cron expression for calendar-aware scheduling. The SDK also provides minimal-API endpoint mapping so your application receives trigger invocations when a job fires.
The client is registered with dependency injection via AddDaprJobsClient() and can be configured from IConfiguration, environment variables, or a DaprJobsClientBuilder for advanced scenarios.
Core concepts
How to: Author and manage Dapr Jobs in the .NET SDK: client registration with dependency injection, IConfiguration, and manual instantiation; mapping job trigger endpoints in minimal APIs; scheduling one-time, interval-based, and Cron-based jobs; retrieving and deleting jobs.
DaprJobsClient usage: lifetime management, builder configuration, environment variables, gRPC channel options, cancellation, the three dependency injection overloads, payload serialization with JSON helper extensions, and error handling.
7.1 - How to: Author and manage Dapr Jobs in the .NET SDK
Schedule jobs, receive HTTP or gRPC callbacks, and manage job registrations in .NET
Use Dapr.Jobs version 1.19.0 or later with a running Dapr sidecar and Scheduler. The complete
management surface described here requires a runtime that supports the corresponding operations; use
Dapr 1.19 for the 1.19 features. All client operations use version-aware stable/alpha API fallback, but that
does not make newer management operations available on an older runtime.
dotnet add package Dapr.Jobs
Jobs are scoped to the scheduling application’s Dapr app ID. The client does not accept a different target
app ID when scheduling a trigger. See the
Jobs sample.
Register the client and callback handler
Register the client before building the host, and map one handler for dynamically named jobs:
HandleJobAsync represents your application logic. The callback receives the job name and original payload
bytes; additional services can be resolved from request DI. Its cancellation token includes the configured
handler timeout. Handlers must observe that token themselves; a timeout does not forcibly stop arbitrary code.
Return a Task for asynchronous work so the callback waits for it before acknowledging the trigger.
For HTTP callbacks, the mapper registers POST /job/{jobName}. Start the application with a matching app port:
dapr run --app-id jobsapp --app-port 5000 -- dotnet run --urls http://localhost:5000
gRPC callbacks
AddDaprJobsClient and MapDaprScheduledJobHandler also register the gRPC callback path. Choose it with
--app-protocol grpc. For plaintext gRPC, configure a dedicated HTTP/2 listener before builder.Build():
An HTTP/1-only listener, or plaintext Http1AndHttp2 without protocol negotiation, cannot serve this gRPC
callback. Use separate listeners if the same app must also accept HTTP/1 traffic.
Register the job
Resolve DaprJobsClient and use ScheduleJobAsync for every schedule type:
The builder also supports WithTimeZone(string) and WithTimeZone(TimeZoneInfo). DaprJobSchedule.TimeZone exposes an embedded CRON_TZ
identifier. Use valid IANA time-zone IDs and account for daylight-saving changes when choosing calendar schedules.
JSON and string payload helpers
ScheduleJobWithPayloadAsync from Dapr.Jobs.Extensions accepts the same schedule:
The object overload JSON-serializes with web defaults unless you pass jsonSerializerOptions. The string
overload encodes UTF-8 text without JSON quoting. Deserialize callback bytes with the matching convention.
There are no separate ScheduleCronJobAsync or ScheduleOneTimeJobWithPayloadAsync helpers in this release.
JobFailurePolicyConstantOptions retries at the specified interval; an unset MaxRetries allows indefinite
retries. JobFailurePolicyDropOptions drops a failed trigger. An omitted policy leaves runtime defaults in
effect. This policy is separate from the job’s recurring schedule and repeat count. Make callback side effects
idempotent because triggers can be delivered more than once.
DaprJobDetails exposes Name, Schedule, DueTime, Ttl, RepeatCount, Payload, FailurePolicy, and Overwrite.
Name can be null for manually constructed or older-runtime results. Inspect
details.Schedule.ExpressionValue and schedule classification properties such as IsCronExpression and
IsPointInTimeExpression. The response does not expose top-level Interval or CronExpression properties.
The result is an IReadOnlyList<DaprJobDetails> scoped to the current app ID. An empty list normally means
there are no jobs; also consider the release-specific cancellation behavior described below.
The prefix match is case-sensitive. A null, empty, or whitespace prefix deletes all jobs for this app ID.
Validate a user-supplied prefix before calling this operation.
This explicitly requests deletion of every job associated with the current app ID, not every job in the cluster.
Errors and cancellation
Runtime failures are wrapped in DaprException; inspect InnerException. Invalid arguments can throw standard
argument exceptions. In 1.19.0, several Jobs methods catch caller cancellation instead of throwing
OperationCanceledException: writes/deletes can return without confirmation and listing can return empty.
GetJobAsync reports a missing expected result as DaprException after cancellation. See
DaprJobsClient usage for client configuration and payload conventions.
Analyzer
The Dapr.Jobs.Analyzers package ships diagnostic DAPR1501 (warning, category Usage). It fires when
ScheduleJobAsync is called with a literal job name and no MapDaprScheduledJobHandler call exists in the same
source file, with the message “Job invocations require the MapDaprScheduledJobHandler be set and configured for
job name ‘{name}’ on IEndpointRouteBuilder”. Add the handler mapping, or suppress the diagnostic with
#pragma warning disable DAPR1501 or an .editorconfig rule if the handler is intentionally mapped elsewhere.
7.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().
vardaprJobsClient=newDaprJobsClientBuilder().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.
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.
Pass cancellation tokens to bound calls. In SDK 1.19.0, scheduling, deletion, and listing catch
caller-initiated cancellation: write methods can complete without confirming a change and listing can return
an empty list. GetJobAsync can surface a DaprException after cancellation. Do not rely on an
OperationCanceledException being thrown by every Jobs operation, or interpret a canceled call as confirmation
that a job was created/deleted. Check the token and reconcile with the runtime when the outcome matters.
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.
varbuilder=WebApplication.CreateBuilder(args);builder.Services.AddDaprJobsClient();//Registers the `DaprJobsClient` to be injected as neededvarapp=builder.Build();
AddDaprJobsClient() also registers the gRPC AppCallbackAlpha callback service and configures Kestrel for HTTP/1 + HTTP/2, enabling the sidecar to deliver job triggers over either HTTP or gRPC without any additional setup.
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 DaprJobsClientBuilder and exposes methods for configuring
the necessary options.
varbuilder=WebApplication.CreateBuilder(args);builder.Services.AddDaprJobsClient((_,daprJobsClientBuilder)=>{//Set the API tokendaprJobsClientBuilder.UseDaprApiToken("abc123");//Specify a non-standard HTTP endpointdaprJobsClientBuilder.UseHttpEndpoint("http://dapr.my-company.com");});varapp=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:
varbuilder=WebApplication.CreateBuilder(args);//Register a fictional service that retrieves secrets from somewherebuilder.Services.AddSingleton<SecretService>();builder.Services.AddDaprJobsClient((serviceProvider,daprJobsClientBuilder)=>{//Retrieve an instance of the `SecretService` from the service providervarsecretService=serviceProvider.GetRequiredService<SecretService>();vardaprApiToken=secretService.GetSecret("DaprApiToken").Value;//Configure the `DaprJobsClientBuilder`daprJobsClientBuilder.UseDaprApiToken(daprApiToken);});varapp=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.
For JSON-serializable values, use ScheduleJobWithPayloadAsync from Dapr.Jobs.Extensions. It accepts a
DaprJobSchedule, an object payload, and optional JsonSerializerOptions.
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:
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:
varnow=DateTimeOffset.UtcNow;varoneWeekFromNow=now.AddDays(7);awaitdaprJobsClient.ScheduleJobWithPayloadAsync("myOtherJob",DaprJobSchedule.FromDateTime(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.
Deserialize with the same format used when scheduling. For JSON, use
JsonSerializer.Deserialize<T>(jobPayload.Span, options) with matching serializer options; for UTF-8 text,
use Encoding.UTF8.GetString. The client does not infer the payload’s type or encoding.
To deserialize the bytes to a string, the following helper method can be used:
varpayloadAsString=Encoding.UTF8.GetString(jobPayload.Span);//If successful, returns a string with the value
Error handling
DaprJobsClient wraps runtime/gRPC failures in DaprException. Invalid argument values raise standard
exceptions such as ArgumentOutOfRangeException or ArgumentNullException.
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.
8 - Dapr Cryptography .NET SDK
Overview of the Dapr Cryptography building block for .NET
Dapr.Cryptography provides a dedicated client for performing high-performance encryption and decryption operations with Dapr. The DaprEncryptionClient supports streaming operations that can handle data of any size, offloading the cryptographic work to the Dapr runtime and its configured cryptography components.
The client is registered with dependency injection via AddDaprEncryptionClient() and can be configured from IConfiguration, environment variables, or a DaprEncryptionClientBuilder for advanced scenarios.
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 DaprEncryptionClient 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().
vardaprEncryptionClient=newDaprEncryptionClientBuilder().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.
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 canceled, it can throw an OperationCanceledException. Stream failures are surfaced while
enumerating results; see streaming and operation options.
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.
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.
varbuilder=WebApplication.CreateBuilder(args);builder.Services.AddDaprEncryptionClient((_,daprEncryptionClientBuilder)=>{//Set the API tokendaprEncryptionClientBuilder.UseDaprApiToken("abc123");//Specify a non-standard HTTP endpointdaprEncryptionClientBuilder.UseHttpEndpoint("http://dapr.my-company.com");});varapp=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:
varbuilder=WebApplication.CreateBuilder(args);//Register a fictional service that retrieves secrets from somewherebuilder.Services.AddSingleton<SecretService>();builder.Services.AddDaprEncryptionClient((serviceProvider,daprEncryptionClientBuilder)=>{//Retrieve an instance of the `SecretService` from the service providervarsecretService=serviceProvider.GetRequiredService<SecretService>();vardaprApiToken=secretService.GetSecret("DaprApiToken").Value;//Configure the `DaprEncryptionClientBuilder`daprEncryptionClientBuilder.UseDaprApiToken(daprApiToken);});varapp=builder.Build();
8.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
Be advised that .NET 8 and .NET 9 reach end of support in November 2026. Support for both will be dropped
in Dapr v1.20; only .NET 10 and .NET 11 will be supported through the end of 2027.
These examples target SDK 1.19.0 or later. Configure a cryptography component and a key supported by
that component before running them. Import Dapr.Cryptography.Encryption,
Dapr.Cryptography.Encryption.Models, and Dapr.Cryptography.Encryption.Extensions.
The client is experimental (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 fromvaroptionsProvider=serviceProvider.GetRequiredService<DefaultOptionsProvider>();varstandardTimeout=optionsProvider.GetStandardTimeout();//Configure the value on the client builderclientBuilder.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().
Choose a key-wrap algorithm supported by your configured key and component. The SDK exposes AES key-wrap
(Aes, A256kw), AES-CBC (A128cbc, A192cbc, A256cbc), and RSA (Rsa, RsaOaep256) choices;
exposing an enum value does not guarantee that every cryptography component supports it.
Encrypt and decrypt streams
The stream overloads return IAsyncEnumerable<ReadOnlyMemory<byte>>. Consume all output chunks and write
them to your destination instead of buffering the file:
The caller owns the input and destination streams. Enumeration completes after both request and response
processing finish. Errors can occur while enumerating, not just when obtaining the enumerable. Disposing the
enumerator early cancels processing and waits for it to stop; do not treat a partial output file as a complete
encrypted or decrypted document.
Encryption and decryption options
Setting
Behavior
EncryptionOptions.KeyWrapAlgorithm
Required constructor argument; selects the key-wrap algorithm.
EncryptionOptions.EncryptionCipher
AesGcm by default, or ChaCha20Poly1305.
EncryptionOptions.DecryptionKeyName
Optional key name/version to record for decryption. If omitted, the SDK asks the runtime to omit the decryption key name from the encrypted header. Supply the key when decrypting.
EncryptionOptions.StreamingBlockSizeInBytes
Upload block size, 4 KiB by default; must be positive and generally should not exceed 64 KiB.
DecryptionOptions.StreamingBlockSizeInBytes
Decryption upload block size, also 4 KiB by default and required to be positive.
DecryptAsync accepts optional DecryptionOptions. Cryptography operates on bytes, not JSON, regardless
of the shared builder’s JSON serialization options. The dedicated client implements streaming encryption
and decryption; it does not expose key retrieval, signing, verification, or the runtime’s other subtle APIs.
Errors and cancellation
Pass a cancellation token through both client calls and destination writes. gRPC and stream-processing failures
must be observed while awaiting or enumerating the result; they are not successful empty outputs. Cancellation
or a failed stream does not remove any partial destination file your application has already written.
9 - Dapr Secrets Management .NET SDK
Overview of the Dapr Secrets Management building block for .NET
Dapr.SecretsManagement provides a dedicated client for retrieving secrets from the Dapr Secrets API in a .NET application. With DaprSecretsManagementClient you can retrieve individual or bulk secrets from configured secret store components, pass metadata to requests, and take advantage of a source generator that provides strongly-typed access to your secrets through dependency injection using [SecretStore] and [Secret] attributes.
The client is registered with dependency injection via AddDaprSecretsManagement() and can be configured from IConfiguration, environment variables, or a DaprSecretsManagementClientBuilder for advanced scenarios.
9.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:
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:
The main Program.cs contains all three usage patterns.
The IMyVaultSecrets.cs file demonstrates the typed secret store interface.
Prerequisites
These examples target SDK 1.19.0 or later. This is a read API: there are no SDK operations to create,
rotate, update, delete, or watch secrets in a Dapr secret store.
Be advised that .NET 8 and .NET 9 reach end of support in November 2026. Support for both will be dropped
in Dapr v1.20; only .NET 10 and .NET 11 will be supported through the end of 2027.
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:
varbuilder=WebApplication.CreateBuilder(args);//Add anywhere between these two linesbuilder.Services.AddDaprSecretsManagementClient();varapp=builder.Build();
Import Dapr.SecretsManagement for the client and Dapr.SecretsManagement.Extensions for registration.
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:
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:
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:
varbuilder=WebApplication.CreateBuilder();//Create the configurationvarconfiguration=newConfigurationBuilder().AddInMemoryCollection(newDictionary<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.
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.
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:
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.
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.
usingDapr.SecretsManagement.Abstractions;[SecretStore("my-vault")]publicpartialinterfaceIMyVaultSecrets{ [Secret("db-connection-string")]stringDatabaseConnection{get;}stringApiKey{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():
A concrete implementation of your interface that is backed by DaprSecretsManagementClient.GetBulkSecretAsync.
An IHostedService that pre-loads all mapped secrets at application startup.
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:
DaprSecretsManagementClient is abstract, but has no parameterless constructor. Supply its gRPC client and HTTP
client constructor dependencies when creating a test double. Alternatively, isolate direct access behind an
application-owned interface. The generated property interface below is a simpler seam for most consumer tests.
Unit testing with the source generator
The generated implementation wraps a plain interface with get-only properties, so you can mock it directly:
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.
9.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().
vardaprSecretsClient=newDaprSecretsManagementClientBuilder().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.
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 canceled, it throws an OperationCanceledException.
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.
varbuilder=WebApplication.CreateBuilder(args);builder.Services.AddDaprSecretsManagementClient();//Registers the `DaprSecretsManagementClient` to be injected as neededvarapp=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.
varbuilder=WebApplication.CreateBuilder(args);builder.Services.AddDaprSecretsManagementClient((_,daprSecretsClientBuilder)=>{//Set the API tokendaprSecretsClientBuilder.UseDaprApiToken("abc123");//Specify a non-standard HTTP endpointdaprSecretsClientBuilder.UseHttpEndpoint("http://dapr.my-company.com");});varapp=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:
varbuilder=WebApplication.CreateBuilder(args);//Register a fictional service that retrieves secrets from somewherebuilder.Services.AddSingleton<SecretService>();builder.Services.AddDaprSecretsManagementClient((serviceProvider,daprSecretsClientBuilder)=>{//Retrieve an instance of the `SecretService` from the service providervarsecretService=serviceProvider.GetRequiredService<SecretService>();vardaprApiToken=secretService.GetSecret("DaprApiToken").Value;//Configure the `DaprSecretsManagementClientBuilder`daprSecretsClientBuilder.UseDaprApiToken(daprApiToken);});varapp=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")]publicpartialinterfaceIMyVaultSecrets{/// <summary>/// The database connection string, retrieved from the "db-connection-string" secret key./// </summary> [Secret("db-connection-string")]stringDatabaseConnection{get;}/// <summary>/// The API key. Uses the property name "ApiKey" as the secret name./// </summary>stringApiKey{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.
This is a startup snapshot, not a secret rotation subscription. Do not resolve the generated interface before
the host has started: its loader rejects access before loading. A missing mapped key throws
KeyNotFoundException when the property is read.
The generator flattens inner dictionary keys returned by bulk retrieval into its property lookup.
For multi-value secrets, [Secret("...")] must therefore name the desired inner value key, not necessarily the
outer secret name. Duplicate inner keys across secrets collide in the flattened snapshot; use the direct API
when the outer/inner hierarchy matters.
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.
10 - Dapr State Management .NET SDK
Overview of the Dapr State Management building block for .NET
Dapr.StateManagement provides a dedicated client for interacting with the Dapr State Management API from a .NET application. With DaprStateManagementClient you can 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 through dependency injection using [StateStore] and [State] attributes.
The client is registered with dependency injection via AddDaprStateManagement() and can be configured from IConfiguration, environment variables, or a DaprStateManagementClientBuilder for advanced scenarios.
Core concepts
How to: Manage state with the Dapr State Management .NET SDK: end-to-end walkthrough covering save, retrieve, delete, and query operations, ETags, bulk operations, state transactions, the source generator for typed state store access, and testing.
DaprStateManagementClient usage: lifetime management, builder configuration, environment variables, gRPC channel options, dependency injection overloads, JSON serialization defaults, API response shapes, StateOptions for consistency and concurrency, error handling, and migration from DaprClient.
10.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:
Be advised that .NET 8 and .NET 9 reach end of support in November 2026. Support for both will be dropped
in Dapr v1.20; only .NET 10 and .NET 11 will be supported through the end of 2027.
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
These examples target Dapr.StateManagement1.19.0 or later. Import Dapr.StateManagement,
Dapr.StateManagement.Extensions, and System.Text.Json for the client, registration, and serialization.
State option and operation models are in Dapr.StateManagement.
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.
The client builder inherits from DaprGenericClientBuilder<T>, which supports reading configuration values from IConfiguration. The following configuration keys are recognized:
Key
Description
DAPR_GRPC_ENDPOINT
The gRPC endpoint for the Dapr sidecar
DAPR_HTTP_ENDPOINT
The HTTP endpoint for the Dapr sidecar
DAPR_API_TOKEN
The API token for authenticating with the Dapr sidecar
These values can be provided via environment variables, appsettings.json, or any other IConfiguration source.
builder.Configuration.AddEnvironmentVariables(prefix:"MYAPP_");// MYAPP_DAPR_GRPC_ENDPOINT will be read automaticallybuilder.Services.AddDaprStateManagementClient();
Save and retrieve state
Use GetStateAsync<TValue> to retrieve a single value and SaveStateAsync<TValue> to save one:
varclient=app.Services.GetRequiredService<DaprStateManagementClient>();// Save a widgetvarwidget=newWidget("medium","blue");awaitclient.SaveStateAsync("statestore","my-widget",widget);// Retrieve itvarloaded=awaitclient.GetStateAsync<Widget>("statestore","my-widget");Console.WriteLine($"Widget: {loaded?.Size} / {loaded?.Color}");
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 ETagvar(existingValue,etag)=awaitclient.GetStateAndETagAsync<Widget>("statestore","my-widget");if(etagisnotnull){// Modify the valuevarupdated=existingValue!with{Color="green"};// Save only if nobody else has modified it since our readboolsaved=awaitclient.TrySaveStateAsync("statestore","my-widget",updated,etag);Console.WriteLine(saved?"ETag save succeeded.":"ETag mismatch — concurrent modification.");}
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.
Upserts use serialized bytes in StateTransactionRequest, rather than the generic save method’s automatic
object serialization. Use matching serializer settings:
StateTransactionRequest can carry per-operation ETags, options, and metadata. Request-level metadata is
passed separately to ExecuteStateTransactionAsync. A bulk save is not a substitute for an atomic transaction.
Query state
If the underlying state store supports queries, you can use QueryStateAsync with a JSON query expression:
Query support depends on the state store component. Refer to the Dapr state store query API documentation for query syntax and limitations. Follow response.Token in the next query’s pagination fields when paging; do not assume all results fit in one response.
Query fields refer to the stored JSON document. The example uses the client’s default web JSON settings,
which serialize Widget.Color and Widget.Size as color and size; adapt the query to your serializer and
component’s indexing requirements.
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:
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")]:
A sealed internal implementation class that forwards all IDaprStateStoreClient calls to
DaprStateManagementClient with the store name "statestore" pre-filled.
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():
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.
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:
This release catches caller-initiated cancellation in the state gRPC client. Single reads can return default
values, bulk/query reads can return empty results, conditional writes/deletes can return false, and other
writes can complete without confirming a remote change. These outcomes are not sufficient evidence that a
key is absent, an ETag conflicted, or a write succeeded. Check the caller’s token after an operation if you need
to distinguish cancellation, and reconcile uncertain writes with the store. Cancellation never guarantees
rollback of a request already accepted by the runtime.
Dependency injection registration
Registration overloads
The AddDaprStateManagementClient method has the following signature:
Each [StateStore]-annotated interface generates an extension method on IDaprStateManagementBuilder. The methods return IDaprStateManagementBuilder, so they chain naturally:
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:
Property
Type
Description
Key
string
The state key
Value
TValue?
The deserialized value, or null if the key was not found
ETag
string
The ETag for optimistic concurrency control
TrySaveStateAsync / TryDeleteStateAsync
Both return bool — true if the operation succeeded, false on an ETag conflict. In 1.19.0,
caller cancellation can also return false; check cancellation before treating it as a concurrency conflict.
QueryStateAsync
QueryStateAsync<TValue> returns StateQueryResponse<TValue>, which contains:
Property
Type
Description
Results
IReadOnlyList<StateQueryItem<TValue>>
The list of matching items
Token
string?
Pagination token for continuing the query, or null if no more results
Metadata
IReadOnlyDictionary<string, string>
Additional metadata returned by the state store
Each StateQueryItem<TValue> contains:
Property
Type
Description
Key
string
The state key
Data
TValue?
The deserialized value
ETag
string?
The ETag for the item
Error
string?
An error message if the item could not be retrieved, or null on success
Although StateQueryItem<TValue> has an Error property, the 1.19.0 client throws DaprException
when the runtime returns per-item query errors, rather than returning a successful response containing those
failed items. Do not rely on partial results being returned in that case.
StateOptions
The StateOptions class controls consistency and concurrency behavior for individual operations:
Controls whether reads reflect the latest write. null uses the store default.
Concurrency
FirstWrite, LastWrite
Controls conflict resolution. FirstWrite fails on ETag mismatch; LastWrite always overwrites. null uses the store default.
Error handling
Methods on DaprStateManagementClient throw DaprException for runtime failures. Invalid argument values
raise standard exceptions such as ArgumentNullException or ArgumentException. Caller cancellation has
the release-specific behavior described above.
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.
APIs that remain on DaprClient
The dedicated client’s public surface is typed state access. It does not expose these DaprClient helpers:
Legacy method
Purpose
SaveByteStateAsync, TrySaveByteStateAsync
Save already-encoded bytes, optionally conditional on an ETag.
GetByteStateAsync, GetByteStateAndETagAsync
Retrieve raw state bytes, optionally with an ETag.
Non-generic GetBulkStateAsync
Retrieve bulk entries without deserializing values to an application type.
GetStateEntryAsync<T>
Return a StateEntry<T> tracking value, key/store, and ETag for save/delete helper operations.
Use DaprClient where these APIs are needed; do not assume substituting byte[] into a JSON generic
method preserves raw bytes. JSON serialization encodes a byte array rather than bypassing serialization.
For large files and chunked streaming, use the separate binary store building
block instead of buffering file content in state.
The two client families also use types in different namespaces. Dedicated state operations use
Dapr.StateManagement option/operation models; older APIs use their Dapr.Client counterparts.
Check types as well as method names when migrating, and qualify overlapping names when using both.
11 - Dapr Binary Store .NET SDK
Store and retrieve binary files using the Dapr Binary Store .NET SDK
The Dapr Binary Store package lets .NET applications store, retrieve, stream, and delete binary files in a configured Dapr binary store component. It supports stream-based uploads and downloads for large files, as well as byte-array access for files that fit in memory. A source generator can also create a strongly typed client bound to a specific store.
11.1 - How to: Manage binary files with the Dapr Binary Store .NET SDK
Learn how to store, retrieve, stream, and delete binary files using the Dapr Binary Store .NET SDK
Use the Dapr.BinaryStore package to store and retrieve files using a Dapr binary store component. The client streams file content in chunks, so you can work with large files without loading the entire file into memory. In this guide, you will:
Register and configure DaprBinaryStoreClient
Upload and download files using streams
Choose between streaming and in-memory operations
Bind a typed client to a store with the source generator
Be advised that .NET 8 and .NET 9 reach end of support in November 2026. Support for both will be dropped
in Dapr v1.20; only .NET 10 and .NET 11 will be supported through the end of 2027.
Install the package in your project:
dotnet add package Dapr.BinaryStore
Configure an in-memory binary store
For local development, create a components directory in your application directory and save the following component as components\filestore.yaml:
This component stores files in the Dapr sidecar’s memory without requiring an external storage service. It allows files up to 4 MiB and a combined total of 512 MiB. Data is lost when the sidecar stops and is not shared across sidecars, so use it for local development and testing rather than durable storage.
The component’s metadata.name, filestore, is the store name supplied to the client methods or the [BinaryStore] attribute in the examples below.
Register the client
Register the client in dependency injection. By default, it reads the Dapr endpoint and API token from the environment or IConfiguration.
Resolve the registered client and pass the binary store component name, a file name, and a stream. The example uses the filestore component configured above. Place a report.pdf file no larger than 4 MiB in your application’s working directory before running the example. Add the following code after var app = builder.Build(); in Program.cs, with the using directives at the top of the file.
The example uploads report.pdf to the in-memory store as uploads/report.pdf and downloads it to downloaded-report.pdf. Run the remaining examples against the same sidecar to use the uploaded file before deleting it.
The Stream overload of SetBinaryFileAsync reads the input stream from its current position and sends it to the runtime in chunks. The default upload chunk size is 32 KiB; pass chunkSize to choose another positive size. Set overwrite: true to replace an existing file. By default, an upload fails if the file already exists.
File names can include nested paths such as uploads/report.pdf. Avoid a leading or trailing slash, empty path segments, . or .. segments, and the characters \, *, ?, [, ], and #; the Dapr runtime rejects these names.
Work with in-memory content
For content that already fits in memory, pass ReadOnlyMemory<byte> to upload or use GetBinaryFileBytesAsync to retrieve the complete file:
The package includes a source generator for a typed client that is bound to one component. Define a partial interface that extends IDaprBinaryStoreClient and use [BinaryStore] to specify the component name:
The generated WithFileStore method registers an implementation of IFileStore. Inject that interface where it is needed; its methods are the same as IDaprBinaryStoreClient but do not take a store name:
Essential guidance for configuring and using DaprBinaryStoreClient
The operations on this page target SDK 1.19.0. Use that release or later with
Dapr runtime 1.19 or later.
Operation surface
Operation
API
Upload
SetBinaryFileAsync with a Stream or ReadOnlyMemory<byte>, optionally overwriting an existing file.
Download
GetBinaryFileChunksAsync, GetBinaryFileAsync into a destination stream, or GetBinaryFileBytesAsync to buffer the complete file.
Delete
DeleteBinaryFileAsync.
Store-bound access
[BinaryStore] on a partial IDaprBinaryStoreClient interface and the generated With... DI registration.
This SDK release does not expose list-files, file metadata, conditional/ETag writes, or range-read methods.
Binary store files are not state entries; state transactions and state query APIs do not apply to them.
Client lifetime
DaprBinaryStoreClient uses network resources and implements IDisposable. Reuse a long-lived client rather than creating one for each operation. The dependency injection registration uses ServiceLifetime.Singleton by default; use the optional lifetime argument to select another lifetime when needed.
DaprBinaryStoreClientBuilder inherits the common Dapr client builder configuration. The client reads these values from environment variables or IConfiguration:
Setting
Description
DAPR_GRPC_ENDPOINT
gRPC endpoint address for the Dapr sidecar
DAPR_HTTP_ENDPOINT
HTTP endpoint address for the Dapr sidecar
DAPR_API_TOKEN
API token used to authenticate with the Dapr sidecar
DAPR_GRPC_PORT
gRPC port used if DAPR_GRPC_ENDPOINT is not set
DAPR_HTTP_PORT
HTTP port used if DAPR_HTTP_ENDPOINT is not set
Values explicitly set on the builder take precedence over values from configuration:
The builder also supports UseHttpEndpoint, UseDaprApiToken, UseGrpcChannelOptions, and UseTimeout for applications that need to customize the common Dapr client settings.
Streaming and memory use
Use the Stream overload of SetBinaryFileAsync and GetBinaryFileAsync for large files. Uploads use a 32 KiB chunk size by default; specify chunkSize on upload to tune this, and ensure it is greater than zero. GetBinaryFileChunksAsync returns an asynchronous sequence of chunks chosen by the runtime.
The ReadOnlyMemory<byte> overload of SetBinaryFileAsync and GetBinaryFileBytesAsync are convenient for small files already held in memory. GetBinaryFileBytesAsync buffers the complete download in a byte array.
The caller owns the streams. Downloads write at the destination’s current position and do not rewind or
truncate it; uploads read at the source’s current position. Chunk enumeration is lazy, and runtime errors
can be thrown during enumeration. A failed download can leave partial bytes in the destination, which your
application must not treat as a complete file.
Cancellation
All asynchronous operations accept a CancellationToken. Pass the token through uploads, downloads, chunk enumeration, and deletes so the operation can be canceled when the caller no longer needs it:
The client throws DaprException when the Dapr runtime reports a failure. For example, a file that does not exist cannot be retrieved or deleted, and an upload without overwrite: true fails if a file with the same name already exists. Inspect InnerException for details from the runtime. Invalid client arguments, such as an empty store or file name or a non-positive upload chunk size, result in standard argument exceptions.
12 - Dapr Search .NET SDK
Index and query JSON documents using the Dapr Search .NET SDK
The Dapr.Search package provides a dedicated .NET client for Dapr’s Search building block. It supports index management, typed document upserts and retrieval, full-text and provider-native queries, filters, sorting, projection, highlighting, and continuation-token pagination. A bundled source generator can create a typed client bound to a component, index, and document type.
These guides target Dapr .NET SDK 1.19.0 or later and Dapr runtime 1.19 or later.
Search accepts index-ready JSON objects; it does not extract text, chunk documents, or generate embeddings. For similarity search over embeddings, use the Vector .NET SDK.
12.1 - How to: Index and search documents with the Dapr .NET SDK
Create indexes, write typed documents, search, and retrieve documents using Dapr.Search
Use DaprSearchClient to index JSON documents and run full-text queries through a Dapr search component. In this guide, you will configure a local store, create an index, write and search typed documents, and delete them.
The Dapr.Search NuGet package, version 1.19.0 or later
.NET framework support
Be advised that .NET 8 and .NET 9 reach end of support in November 2026. Support for both will be dropped
in Dapr v1.20; only .NET 10 and .NET 11 will be supported through the end of 2027.
The in-memory component needs no external service. Data is lost when the sidecar stops and is not shared between sidecars. Use it for local development and testing, not durable storage.
For production, configure a supported search component. Component capabilities differ: for example, the in-memory component does not support highlighting or native queries.
Register and resolve the client
Add the following to Program.cs. The client reads the sidecar endpoint and API token from configuration or the environment.
Run the application with a sidecar that loads the component:
dapr run --app-id search-example --resources-path .\components -- dotnet run
The .NET Search client uses gRPC; it is separate from DaprClient and does not require AddDaprClient().
Create and inspect an index
conststringstoreName="searchstore";conststringindexName="articles";awaitclient.CreateIndexAsync(storeName,indexName);varinfo=awaitclient.GetIndexAsync(storeName,indexName);Console.WriteLine($"{info.Index}: approximately {info.DocumentCount} documents");varindexes=awaitclient.ListIndexesAsync(storeName);Console.WriteLine(string.Join(", ",indexes));
Creating an existing index fails with AlreadyExists; it does not update the index settings. If you keep the sidecar running between executions, delete the example index before creating it again.
CreateIndexAsync accepts component-specific settings in its metadata argument. For example, the Meilisearch component requires filterableAttributes and sortableAttributes to declare fields used for filtering and sorting. These settings are not needed for the in-memory example.
Index typed documents
varwriteOptions=newIndexingOptions{Mode=IndexingMode.WaitForCompletion,WaitTimeout=TimeSpan.FromSeconds(10),OnWaitTimeout=IndexingWaitTimeoutAction.FailRequest};varwrite=awaitclient.IndexDocumentsAsync<Article>(storeName,indexName, [
new("doc-1", new Article("Getting started with Dapr", "guide", 10),
new Dictionary<string, string> { ["author"]="alice"}),new("doc-2",newArticle("Dapr pub/sub deep dive","guide",25)),new("doc-3",newArticle("Search API reference","reference",30))],writeOptions);foreach(varfailureinwrite.FailedItems){Console.Error.WriteLine($"{failure.Id}: {failure.ErrorCode} {failure.ErrorMessage}");}if(write.FailedItems.Count!=0||write.Ack!=IndexAck.Completed){thrownewInvalidOperationException("Not all documents were confirmed indexed.");}
SearchDocument<T> contains an ID, typed content, and optional string metadata. The SDK serializes content to UTF-8 JSON using the client’s JSON options. Content must serialize to a JSON object; invalid content is reported as an item failure by the runtime. IDs must be non-empty and unique within a write request. Writing an existing ID upserts that document.
Document metadata is stored and returned unchanged, but is not indexed or filterable. Filters address fields of Content, not SearchDocument<T>.Metadata.
Without writeOptions, a write returns on acceptance and can return IndexAck.Queued. An empty FailedItems list at that boundary does not guarantee eventual indexing success. The explicit wait above lets the following reads observe completed writes. See write acknowledgement guidance.
Search and paginate
Add using System.Text.Json; at the top of Program.cs, then search for guides sorted by price:
The default JSON naming policy is camelCase, so field names use title, category, and price, not the .NET property names. A continuation token belongs to the query that produced it: keep the text, filter, sort, page size, and field options unchanged between pages.
SearchAsync<T> returns one page, not an automatically enumerated stream. Content is omitted unless IncludeContent is true. Omit the query, or omit both Text and Native, to match all documents, subject to any filter.
Found documents are returned in request order; missing IDs are omitted. Without includeContent: true, Content is default(T).
Delete documents and the index
varack=awaitclient.DeleteDocumentsAsync(storeName,indexName,["doc-2"],writeOptions);if(ack!=IndexAck.Completed){thrownewInvalidOperationException("Document deletion was not confirmed complete.");}awaitclient.DeleteIndexAsync(storeName,indexName);
Deleting a missing document ID is not an error. Deleting an index removes its documents as well.
Configure DaprSearchClient, shape queries, manage write acknowledgements, and generate typed index clients
DaprSearchClient in Dapr.Search implements the alpha Search API through gRPC. Use .NET SDK 1.19.0 or later with a Dapr runtime and component that support Search. See the overview for release requirements and the how-to for a complete document lifecycle.
Client lifetime and configuration
Reuse a long-lived client rather than creating one per operation. Dependency injection registers a singleton by default and owns its disposal:
The callback can resolve configuration or other services through serviceProvider. Use the optional lifetime argument to choose another ServiceLifetime when required.
For applications without dependency injection, create and dispose a client yourself:
The builder accepts an optional IConfiguration. Defaults resolve DAPR_GRPC_ENDPOINT, or fall back to the local endpoint using DAPR_GRPC_PORT (50001 by default); DAPR_API_TOKEN supplies sidecar authentication. Explicit builder settings override defaults; use UseDaprApiToken to override the token.
Use UseJsonSerializationOptions for document serialization and UseGrpcChannelOptions for gRPC settings. When supplying channel options, retain ThrowOperationCanceledOnCancellation = true. Although the shared builder also exposes HTTP endpoint, HTTP client factory, and HTTP timeout settings, Search operations use gRPC; changing the HTTP endpoint does not switch their transport, and UseTimeout is not a per-RPC deadline. Pass a time-limited CancellationToken to bound how long the caller waits.
API coverage
All operations accept optional component-specific request metadata (IReadOnlyDictionary<string, string>) and a CancellationToken.
Operation
Result
CreateIndexAsync, DeleteIndexAsync
Create or remove an index. Creation metadata supplies provider-specific settings.
GetIndexAsync
SearchIndex with Index, approximate DocumentCount, and component Properties.
ListIndexesAsync
Index names in the component.
IndexDocumentsAsync<T>
IndexDocumentsResult with Ack and FailedItems.
GetDocumentsAsync<T>
Found SearchDocument<T> entries in request order.
DeleteDocumentsAsync
IndexAck for the deletion.
SearchAsync<T>
One SearchResult<T> page.
Request metadata configures an operation; it is separate from each document’s stored, opaque Metadata.
Query options and results
SearchQuery is a record, so use with expressions when changing only the continuation token.
Property
Behavior
Text
Portable full-text query. Mutually exclusive with Native; neither means match all.
Native
Provider-native query represented by a JSON object (JsonElement).
Filter
Portable JSON-object filter addressing content fields, including dotted nested paths.
TopK
Nullable uint page size; null or zero selects the component default.
ContinuationToken
Opaque token from the previous response. Keep the rest of the query unchanged.
SearchFields
Content fields to search.
Sort
Ordered SortClause entries with Ascending, Descending, or provider-default Unspecified order.
IncludeContent
Null or false omits content; returned Content is default(T).
ReturnFields
Fields projected into returned content. Use with IncludeContent = true.
HighlightFields
Fields for which highlighted fragments are requested, if the component supports them.
Filters support $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $exists, $and, $or, and $not. Multiple conditions in an object are ANDed, and a direct field value is an equality test. Use serialized JSON field names, which are camelCase by default. See the portable filter language and your component’s requirements for filterable and sortable fields.
SearchResult<T>.Hits contains SearchHit<T> entries with a Document, unnormalized provider-specific Score, and a Highlights dictionary keyed by field. Higher scores indicate greater relevance within that result set; do not compare them across providers. TotalHits is nullable, and TotalHitsRelation is Unspecified, Exact, LowerBound, or Estimate. Stop paging when ContinuationToken is null or empty.
Projection and highlighting
Using the Article type and client from the how-to, the following query requires a component that supports highlighting, such as Meilisearch:
Projection can omit members expected by your model. Choose a compatible result type or JsonElement for partial documents. Highlight fragments can contain provider-generated markup; treat them as untrusted content when rendering a UI.
Provider-native queries
Pass a JSON object in Native rather than Text for provider-specific features:
This example uses Meilisearch syntax and will not work with the in-memory component. Meilisearch pagination and sorting must still use the portable TopK, ContinuationToken, and Sort properties. Setting both Text and Native, or supplying a non-object Native or Filter, throws ArgumentException before sending an RPC.
Write acknowledgements
IndexDocumentsAsync<T> and DeleteDocumentsAsync accept Dapr.Search.IndexingOptions.
Setting
Behavior
Omitted options, ReturnOnAcceptance, or Unspecified mode
Return after acceptance. IndexAck.Queued means background work was accepted, not that indexing succeeded. Synchronous components can return Completed.
WaitForCompletion
Requires a positive WaitTimeout and explicit OnWaitTimeout. Completed means the final write result is available, including any item failures.
OnWaitTimeout = ContinueAsync
Return Queued if the wait expires; requires component support for queued acknowledgement.
OnWaitTimeout = FailRequest
Fail with DeadlineExceeded if the wait expires. Provider-side work may still complete.
Do not supply WaitTimeout or OnWaitTimeout with acceptance mode. WaitTimeout must be shorter than any remaining RPC deadline and must leave headroom below the store resiliency timeout of at least 10% of that timeout and at least 100 ms. See the runtime acknowledgement rules.
Always inspect both IndexDocumentsResult.Ack and FailedItems. A completed batch can contain failed documents without throwing for the whole request. Each SearchFailedItem includes Id, canonical numeric ErrorCode, ErrorMessage, and Details preserving protobuf TypeUrl and raw Value bytes. At a queued boundary, only failures already known can be reported; there is no client API to poll for a final indexing result.
Serialization
Content uses System.Text.Json with JsonSerializerDefaults.Web by default. Pass .NET objects to SearchDocument<T>; do not manually base64-encode them or pass an already serialized JSON string, which would serialize as a string rather than an object. Use JsonElement for document shapes without a fixed .NET model.
For source-generated JSON serialization, configure the client with a context covering the content type:
The generated index client below does not generate JSON serialization metadata. Configure JSON options separately when reflection-free serialization is required.
Generate a typed index client
The Dapr.Search package bundles a source generator. Declare a top-level, non-generic, non-file-local partial interface, extending exactly one IDaprSearchIndex<T>, without additional abstract members:
The generated wrapper exposes the index-scoped operations in the API table, but not store-wide ListIndexesAsync. It forwards options, request metadata, and cancellation unchanged, inherits the registered client’s lifetime, and does not own or dispose that client. Registration does not create the index; call CreateIndexAsync explicitly.
Invalid declarations, bindings, or contracts produce build errors DAPR1801, DAPR1802, or DAPR1803, respectively.
Cancellation and errors
Invalid client arguments throw argument exceptions before an RPC. RPC failures throw DaprException with the original Grpc.Core.RpcException as InnerException, preserving its status and trailers:
usingDapr;usingGrpc.Core;try{awaitclient.CreateIndexAsync("searchstore","articles");}catch(DaprExceptionex)when(ex.InnerExceptionisRpcException{StatusCode:StatusCode.AlreadyExists}){Console.Error.WriteLine("The index already exists; its settings were not changed.");}
This is distinct from item failures returned by a successful indexing call. Caller cancellation can throw OperationCanceledException and does not guarantee that the provider stopped processing a write. Retry and timeout policies are configured through Dapr resiliency, not by this client.
13 - Dapr Vector .NET SDK
Store embeddings and run similarity queries using the Dapr Vector .NET SDK
The Dapr.Vector package provides a dedicated .NET client for Dapr’s Vector building block. It supports collection management, vector upserts and retrieval, typed or opaque payloads, metadata filters, query-by-vector or query-by-ID, score thresholds, and ordered batch query results. A bundled source generator can create a typed client bound to a component, collection, and payload type.
These guides target Dapr .NET SDK 1.19.0 or later and Dapr runtime 1.19 or later.
Your application must supply precomputed embeddings; this client does not generate them. For lexical/full-text search over JSON documents, use the Search .NET SDK.
13.1 - How to: Store and query vectors with the Dapr .NET SDK
Create collections, store typed vector records, query embeddings, and handle batch results using Dapr.Vector
Use DaprVectorClient to store precomputed embeddings and retrieve similar records through a Dapr vector component. This guide uses three-dimensional vectors for readability; real embeddings must match your model’s dimensions.
The Dapr.Vector NuGet package, version 1.19.0 or later
.NET framework support
Be advised that .NET 8 and .NET 9 reach end of support in November 2026. Support for both will be dropped
in Dapr v1.20; only .NET 10 and .NET 11 will be supported through the end of 2027.
The in-memory component needs no external database. Data is lost when the sidecar stops and is not shared between sidecars. Use a supported durable vector component for production.
Place the remaining operation snippets after this code, then call app.Run(); at the end of Program.cs. Create the payload type in Product.cs:
publicsealedrecordProduct(stringTitle);
Run the application with a sidecar:
dapr run --app-id vector-example --resources-path .\components -- dotnet run
The client reads its endpoint and API token from configuration or the environment and uses gRPC. It is separate from DaprClient and does not require AddDaprClient().
Dimensions must be positive and match every stored embedding. Creating an existing collection fails with AlreadyExists; it does not reconfigure the collection. Delete the example collection before rerunning against a sidecar that still holds it.
Upsert records with typed payloads
varwriteOptions=newIndexingOptions{Mode=IndexingMode.WaitForCompletion,WaitTimeout=TimeSpan.FromSeconds(10),OnWaitTimeout=IndexingWaitTimeoutAction.FailRequest};varattributes=JsonSerializer.Deserialize<JsonElement>("""{"category":"books"}""");varwrite=awaitclient.UpsertVectorsAsync<Product>(storeName,collectionName, [
new("dapr", [1, 0, 0],newProduct("Dapr guide"),attributes),new("distributed",[0.8f,0.6f,0],newProduct("Distributed systems"),attributes),new("other",[0,1,0],newProduct("Other topics"),attributes)],writeOptions);foreach(varfailureinwrite.FailedItems){Console.Error.WriteLine($"{failure.Id}: {failure.ErrorCode} {failure.ErrorMessage}");}if(write.FailedItems.Count!=0||write.Ack!=IndexAck.Completed){thrownewInvalidOperationException("Not all vector records were confirmed written.");}
VectorRecord<T> has an Id, IReadOnlyList<float> embedding Values, optional typed Payload, and optional JSON-object Metadata. IDs must be non-empty and unique within the request. Upserting an existing ID replaces the stored record.
Metadata is structured and filterable; payloads are opaque to the vector component and are not filterable. This differs from Search document metadata, which is not indexed. Other than byte[], payload types are serialized with the client’s JSON options.
Without explicit options, writes can return IndexAck.Queued before records are queryable. Waiting for completion makes the following queries see completed writes, but you must still check FailedItems. Invalid vector dimensions or values can fail individual records while the rest succeed.
VectorQuery.Vector is directly a list of floats, not a VectorRecord<T>. For cosine similarity, higher scores are closer, and the inclusive threshold keeps scores at least 0.75. See the metric guidance for dot product and Euclidean semantics.
Values and payloads are omitted by default on queries. Set IncludeValues and IncludePayload separately as needed.
Query by stored ID and in batches
Use ById instead of Vector to reuse an existing record’s embedding:
With the in-memory component, the missing ID produces an error in that query’s slot without discarding the successful results. Invalid client-side query arguments, such as setting neither Vector nor ById, throw before any query in the batch is sent. Request-wide RPC failures also throw instead of producing per-query errors.
Retrieve and delete records
varrecords=awaitclient.GetVectorsAsync<Product>(storeName,collectionName,["dapr","missing"],includeValues:true);foreach(varrecordinrecords){Console.WriteLine($"{record.Id}: {record.Payload?.Title}, {record.Values.Count} values");}varack=awaitclient.DeleteVectorsAsync(storeName,collectionName,["distributed"],writeOptions);if(ack!=IndexAck.Completed){thrownewInvalidOperationException("Vector deletion was not confirmed complete.");}awaitclient.DeleteCollectionAsync(storeName,collectionName);
Get returns found records in request order and omits missing IDs. Payloads are returned when present; values are omitted unless includeValues is true. There is no includePayload argument on GetVectorsAsync<T>. Deleting a missing record ID is not an error. Deleting a collection removes all its records.
DaprVectorClient in Dapr.Vector implements the alpha Vector API through gRPC. Use .NET SDK 1.19.0 or later with a Dapr runtime and component that support Vector. See the overview for release requirements and the how-to for a complete collection lifecycle.
Client lifetime and configuration
Reuse a long-lived client. Dependency injection registers a singleton by default and disposes it when the container is disposed:
The callback can resolve configuration or other services through serviceProvider. The optional lifetime argument supports other ServiceLifetime values.
Without dependency injection, create and dispose your own instance:
The builder accepts an optional IConfiguration. Defaults use DAPR_GRPC_ENDPOINT, or the local endpoint with DAPR_GRPC_PORT (50001 by default), and DAPR_API_TOKEN for sidecar authentication. Explicit builder settings override defaults; use UseDaprApiToken to override the token.
Use UseJsonSerializationOptions for payload serialization and UseGrpcChannelOptions for gRPC settings. Retain ThrowOperationCanceledOnCancellation = true when replacing channel options. Shared HTTP endpoint, HTTP client factory, and HTTP timeout settings do not switch Vector operations to HTTP or establish a per-RPC deadline. Bound caller wait time with a time-limited CancellationToken, not UseTimeout.
API coverage
Every operation accepts optional component-specific request metadata (IReadOnlyDictionary<string, string>) and a CancellationToken.
Operation
Result
CreateCollectionAsync
Create a collection with positive uint dimensions and an optional distance metric.
GetCollectionAsync
VectorCollection with Collection, approximate RecordCount, Properties, Dimensions, and effective Metric.
ListCollectionsAsync
Collection names in the component.
DeleteCollectionAsync
Delete the collection and its records.
UpsertVectorsAsync<T>
UpsertVectorsResult with Ack and FailedItems.
GetVectorsAsync<T>
Found VectorRecord<T> entries in request order; includeValues defaults to false.
DeleteVectorsAsync
IndexAck for the deletion.
QueryVectorsAsync<T>
VectorQueryResult<T> with Matches (a list of VectorMatch<T>, each exposing the Record and its Score) and the effective Metric.
BatchQueryVectorsAsync<T>
Ordered VectorBatchQueryResult<T> entries, each containing a response or error.
Request metadata configures an operation and is separate from each record’s structured, filterable Metadata. There is no embedding-generation API, hybrid lexical/vector query, or continuation-token pagination in this alpha client. Generate embeddings in your application and use Search separately for lexical queries.
Distance metrics and query options
DistanceMetric
Interpretation
Unspecified
Component default at collection creation; collection metric when querying.
Cosine
Cosine similarity in [-1, 1]; higher is closer.
DotProduct
Unnormalized dot product; higher is closer.
Euclidean
Euclidean distance; lower is closer.
ScoreThreshold is inclusive: cosine and dot-product matches must score at least the threshold; Euclidean matches must score at most it. Scores are not converted to a normalized relevance percentage. Interpret each response using its effective Metric. A query can request a metric override where the component supports it; unsupported metrics are rejected rather than silently substituted.
VectorQuery property
Behavior
Vector
Non-empty IReadOnlyList<float> query embedding. Mutually exclusive with ById.
ById
Non-empty stored record ID whose embedding is used as the query. Exactly one query source is required.
TopK
Nullable uint maximum match count; null or zero uses the component default.
Filter
Portable JSON-object filter addressing record metadata, including dotted paths.
IncludeValues
Null or false omits embedding values; returned Values is empty.
IncludePayload
Null or false omits payloads; returned Payload is default(T).
Metric
Requested metric, defaulting to Unspecified.
ScoreThreshold
Optional inclusive cutoff in the metric’s own scale.
Filters support $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $exists, $and, $or, and $not. Multiple conditions in the same object are ANDed. For example:
The filter addresses record metadata, not payload properties. Metadata and filters must be JSON objects. See the component specifications for restrictions and the filter language.
Payload serialization
VectorRecord<T> stores an optional payload separately from its embedding and metadata. Most payload types use System.Text.Json with JsonSerializerDefaults.Web by default; the client deserializes returned payloads to the T selected by your read or query call. Use a compatible type, or JsonElement for variable JSON shapes.
For opaque binary payloads, use byte[] as the generic type. The SDK transfers these bytes unchanged instead of JSON/base64-serializing them:
varwrite=awaitclient.UpsertVectorsAsync<byte[]>("vectorstore","products", [new("binary", [1, 0, 0],newbyte[]{0,255,128})],newIndexingOptions{Mode=IndexingMode.WaitForCompletion,WaitTimeout=TimeSpan.FromSeconds(10),OnWaitTimeout=IndexingWaitTimeoutAction.FailRequest});foreach(varfailureinwrite.FailedItems){Console.Error.WriteLine($"{failure.Id}: {failure.ErrorMessage}");}if(write.FailedItems.Count!=0||write.Ack!=IndexAck.Completed){thrownewInvalidOperationException("The binary record was not confirmed written.");}varrecords=awaitclient.GetVectorsAsync<byte[]>("vectorstore","products",["binary"],includeValues:true);
Read the bytes back with GetVectorsAsync<byte[]> or QueryVectorsAsync<byte[]> (with IncludePayload = true). An absent binary payload is null, while a present empty payload remains an empty byte array. This raw-byte behavior applies specifically to T = byte[], not arbitrary memory types or a byte[] passed as object.
For source-generated JSON serialization, configure a context covering your payload type, such as the Product record from the how-to:
The typed collection generator does not generate JSON serialization metadata. Configure it separately for reflection-free payload serialization; opaque byte[] payloads bypass the JSON serializer.
Write acknowledgements and partial failures
Vector upserts and record deletions accept Dapr.Vector.IndexingOptions. These have the same behavior as the Search write options, but are distinct types in the Dapr.Vector namespace.
Omitted options use ReturnOnAcceptance. IndexAck.Queued means work was accepted, not that it succeeded or is queryable. WaitForCompletion requires a positive WaitTimeout and explicit OnWaitTimeout. ContinueAsync returns Queued if the wait expires and requires queued-ack support; FailRequest raises DeadlineExceeded, but provider work may still complete. Wait fields are invalid with acceptance mode.
The wait timeout must fit below any remaining RPC deadline and leave at least 10% and at least 100 ms of headroom below the store resiliency timeout. It is a provider wait limit, not a cancellation deadline for your application.
Inspect both UpsertVectorsResult.Ack and FailedItems. Each VectorFailedItem includes Id, canonical numeric ErrorCode, ErrorMessage, and raw protobuf error Details (TypeUrl and Value). A completed batch can have individual failures. The SDK leaves dimension and value validation to the runtime/component so invalid records can fail individually without discarding valid records. At a queued boundary only known failures are returned, and this client has no final-result polling API.
If you use Search and Vector together, qualify or alias IndexingOptions, IndexingMode, IndexingWaitTimeoutAction, and IndexAck to avoid ambiguous names.
Batch query errors
BatchQueryVectorsAsync<T> submits queries against one component and collection with shared request metadata. Results preserve query order and have exactly one populated property:
Response: a VectorQueryResult<T> with matches and effective metric.
Error: a VectorQueryError with canonical numeric Code, Message, and preserved protobuf Details.
Check each outcome, even if other queries succeeded. The batch example demonstrates both outcomes.
Local argument validation occurs for the entire batch before sending the RPC. A query with neither source, both sources, an empty vector, a blank ID, a non-object filter, or an invalid metric throws instead of becoming a per-query result. A valid query that fails during runtime evaluation, such as a missing stored ID, can return an error in its own slot. Request-wide RPC failures throw DaprException.
Generate a typed collection client
The Dapr.Vector package bundles a source generator. Define a top-level, non-generic, non-file-local partial interface extending exactly one IDaprVectorCollection<T> with no additional abstract members:
AddProductVectors is named after the interface with the leading I removed. Inject the interface to query without repeating the component, collection, or payload type:
The wrapper exposes all collection-scoped operations, including creation (which still requires dimensions and accepts an optional metric), writes, reads, single/batch queries, and deletion. Store-wide ListCollectionsAsync remains on DaprVectorClient. The wrapper forwards metadata, options, and cancellation, inherits the client’s registered lifetime, and does not dispose that client. Registration alone does not create a collection.
Invalid declarations, bindings, or contracts produce build errors DAPR1901, DAPR1902, or DAPR1903, respectively.
Cancellation and errors
RPC failures throw DaprException whose InnerException is the original Grpc.Core.RpcException; inspect its StatusCode and trailers for provider/runtime errors. Argument validation and JSON serialization errors are distinct from RPC failures. Batch query errors and upsert item failures are returned in their result models, not thrown as request-wide errors.
Every operation accepts a CancellationToken. Caller cancellation can throw OperationCanceledException, but does not guarantee the database stopped processing a write. Configure retries and timeout policies through Dapr resiliency.
14 - Dapr Metadata .NET SDK
Overview of the Dapr Metadata building block for .NET
Dapr.Metadata provides typed access to Dapr runtime metadata from a .NET application. The package registers a hosted service that fetches sidecar metadata at startup and exposes it through the .NET options pattern as DaprMetadata. You can inject it via IOptions<DaprMetadata>, IOptionsSnapshot<DaprMetadata>, or IOptionsMonitor<DaprMetadata>, giving you access to the components, subscriptions, app connection properties, and other metadata the runtime reports about itself.
The client is registered with dependency injection via AddDaprMetadata() and can be configured from IConfiguration or environment variables.
14.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>
Dapr.Metadata NuGet package installed to your project
Install the package
.NET framework support
Be advised that .NET 8 and .NET 9 reach end of support in November 2026. Support for both will be dropped
in Dapr v1.20; only .NET 10 and .NET 11 will be supported through the end of 2027.
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():
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:
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:
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:
Key
Description
DAPR_HTTP_ENDPOINT
The HTTP endpoint for the Dapr sidecar.
DAPR_HTTP_PORT
The HTTP port for the local Dapr sidecar when DAPR_HTTP_ENDPOINT is not set.
DAPR_API_TOKEN
The API token for authenticating with the Dapr sidecar.
These values can come from environment variables or any registered IConfiguration source.
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.
15 - Dapr Messaging in the .NET SDK
Overview of the unified Dapr Messaging SDK for .NET (Dapr.Messaging)
Dapr.Messaging is the unified Dapr Publish/Subscribe messaging SDK for .NET. It brings all Dapr pub/sub functional modes—event publishing, streaming pull subscriptions, programmatic gRPC push subscriptions, and HTTP subscriptions—together into a single, modern package family.
Rather than fragmenting pub/sub across Dapr.Client and Dapr.AspNetCore, Dapr.Messaging provides a clean, cohesive programming model built on top of modern .NET fundamentals: Roslyn source generators for reflection-free dispatch, compile-time Roslyn analyzers, and the standard Microsoft.Extensions.Options pattern.
Unified package architecture
Dapr.Messaging is a meta-package that brings the runtime client (Dapr.Messaging.Runtime), core abstractions (Dapr.Messaging.Abstractions), source generators (Dapr.Messaging.Generators), and Roslyn analyzers (Dapr.Messaging.Analyzers) in a single package reference:
The Dapr .NET SDK originally distributed messaging capabilities across multiple packages: Dapr.Client provided basic publish and dynamic streaming pull methods, while Dapr.AspNetCore handled controller-based and minimal API push subscriptions via HTTP endpoints.
Dapr.Messaging unifies and modernizes this architecture:
Unified publishing and subscribing: Publish events with IDaprPublishSubscribeClient (including single events, raw byte streams, and bulk publishing) and author subscribers using a single ITopicHandler<TMessage> interface.
Three unified delivery modes: Subscriptions support DeliveryMode.Streaming (streaming subscriptions via gRPC), DeliveryMode.Programmatic (programmatic subscriptions via gRPC push), and DeliveryMode.Http (declarative subscriptions via /dapr/subscribe), all configured through the same [DaprTopic] attribute.
Source generators eliminate reflection: The Dapr.Messaging.Generators source generator inspects [DaprTopic] handlers at compile time and emits typed dispatchers, subscriber registries, and dependency injection wiring. No runtime reflection or runtime code generation is performed on the invocation path.
AOT and trimming considerations: Handler discovery and dispatch registration are generated at compile time, but the current generated subscriber dispatchers use runtime System.Text.Json metadata. Native AOT and trimming scenarios require explicit validation with the target SDK version.
Roslyn analyzers and diagnostics: The SDK includes compile-time analyzers (DAPR1610–DAPR1617) that catch misconfigurations directly in your IDE as you write code:
DAPR1610 (Warning): Conflicting delivery modes on the same topic.
DAPR1611 (Error): [DaprTopic] class not implementing ITopicHandler<TMessage>.
DAPR1612 (Warning): Unregistered message type in Native AOT compilation.
DAPR1613 (Warning): Missing app.MapDaprMessaging() for programmatic subscriptions (with CodeFix).
DAPR1614 (Warning): Direct invocation of internal DaprMessagingRegistration.
DAPR1615 (Warning): Endpoint mapping present without matching topic subscribers.
DAPR1616 (Warning): [DaprTopic] feature enabled without companion properties.
DAPR1617 (Warning): Ignored [DaprTopic] properties for the selected delivery mode or feature.
Standard options and simplified DI: Uses IOptions<DaprMessagingOptions> and a single services.AddDaprMessaging() method that registers options, the publishing client, source-generated subscriber dispatchers, and required hosting services in a single atomic operation.
Built-in Roslyn analyzers and code fixes (DAPR16xx)
Configuration
Custom builder methods
Standard Microsoft.Extensions.Options pattern (DaprMessagingOptions)
CloudEvents support
Manual deserialization or controller bindings
Strongly-typed CloudEvent, CloudEvent<TData>, and TopicContext.CloudEvent
Core concepts
Tutorial: Dapr.Messaging by example: Seven runnable examples covering publishing, streaming, routing, bulk subscriptions, gRPC push, HTTP push, dynamic streaming, and their unit and integration testing patterns.
Publish events how-to: Step-by-step guide to publishing JSON events, CloudEvents, raw payloads, and bulk message batches using IDaprPublishSubscribeClient.
Subscribe to topics how-to: Step-by-step guide to authoring ITopicHandler<TMessage> subscribers, choosing delivery modes with [DaprTopic], compile-time source generation, dynamic streaming subscriptions, and compiler diagnostics.
Configuration and usage guide: Lifetime management, DI options configuration, advanced features (bulk pub/sub, dead-letter topics, CEL routing), and AOT/trimming considerations.
Be advised that .NET 8 and .NET 9 reach end of support in November 2026. Support for both will be dropped
in Dapr v1.20; only .NET 10 and .NET 11 will be supported through the end of 2027.
Register Dapr messaging services and the pub/sub client using dependency injection:
varbuilder=WebApplication.CreateBuilder(args);// Register Dapr Messaging services and the publish/subscribe clientbuilder.Services.AddDaprMessaging(options=>{// Optional: configure sidecar connection endpoints or serializeroptions.DaprGrpcEndpoint="http://localhost:50001";});varapp=builder.Build();
Singleton client lifetime
IDaprPublishSubscribeClient is thread-safe and registered as a Singleton. It maintains persistent gRPC/HTTP channel resources to the Dapr sidecar and should be reused across your application rather than instantiated per operation.
Publish events
Inject IDaprPublishSubscribeClient (or DaprPublishSubscribeClient) into your controllers, minimal API route handlers, or background services.
1. Publish standard JSON events
To publish a serialized object to a topic, call PublishEventAsync<TData>:
publicrecordOrder(stringId,decimalTotal,stringCustomerEmail);publicsealedclassOrderService(IDaprPublishSubscribeClientpubsub,ILogger<OrderService>logger){publicasyncTaskPlaceOrderAsync(Orderorder,CancellationTokencancellationToken=default){logger.LogInformation("Publishing order {OrderId} to 'orders' topic",order.Id);// Publishes the order object serialized as JSON with Content-Type: application/jsonawaitpubsub.PublishEventAsync(pubsubName:"pubsub",topicName:"orders",data:order,cancellationToken:cancellationToken);}}
2. Publish with custom CloudEvent metadata and PublishOptions
You can attach custom CloudEvent properties (such as event type, subject, traceparent, or component TTL metadata) using PublishOptions:
PublishOptions also exposes Source (Uri), ContentType, TraceParent, and TraceState. Component
metadata and CloudEvent overrides are different concepts: use metadata such as ttlInSeconds only with
components that support it. Byte publishing and bulk publishing also accept PublishOptions.
Setting a content type does not change how a typed object is serialized. Use PublishByteEventAsync when
you already have encoded bytes. A successful publish confirms acceptance by the runtime/component, not
successful processing by every subscriber.
3. Publish strongly-typed CloudEvents
When you want complete control over the CloudEvent envelope, wrap your payload in CloudEvent<TData>:
varcloudEvent=newCloudEvent<Order>(order){Id=Guid.NewGuid().ToString(),Source=newUri("urn:service:checkout"),Type="com.myapp.order.created",Subject=$"orders/{order.Id}",Time=DateTimeOffset.UtcNow,TraceId=Activity.Current?.TraceId.ToString()};// Publishes with Content-Type: application/cloudevents+jsonawaitpubsub.PublishEventAsync("pubsub","orders",cloudEvent,cancellationToken);
4. Publish raw byte payloads
If you are transmitting binary data, pre-serialized payloads, or proprietary encodings, use PublishByteEventAsync:
15.2 - How to: Author subscriptions and handle topic messages with the Dapr Messaging .NET SDK
Learn how to author topic handlers, configure streaming, programmatic, and HTTP delivery modes, and wire up reflection-free dispatchers with Dapr.Messaging
This guide demonstrates how to author and manage Dapr topic subscriptions using the modern Dapr.Messaging SDK for .NET. You will learn how to author strongly-typed topic handlers with ITopicHandler<TMessage>, configure delivery modes and routing rules with [DaprTopic], leverage compile-time source generation, and manage dynamic streaming subscriptions.
Dapr.Messaging is a meta-package that bundles the abstractions, runtime client, source generators, and Roslyn analyzers in a single package reference. Installing Dapr.Messaging automatically enables compile-time source generation and diagnostic analysis for your project.
Define a topic handler
.NET framework support
Be advised that .NET 8 and .NET 9 reach end of support in November 2026. Support for both will be dropped
in Dapr v1.20; only .NET 10 and .NET 11 will be supported through the end of 2027.
Topic subscribers implement the strongly-typed ITopicHandler<TMessage> interface (or its generic variant ITopicHandler<TMessage, TResult>) and are decorated with the [DaprTopic] attribute.
Why use ITopicHandler?
Strongly-typed payloads: Automatically deserializes incoming JSON or CloudEvent payloads into strongly-typed .NET models (TMessage) without manual boilerplate.
Explicit message disposition: The HandleAsync method returns a TopicResponseAction (Success, Retry, or Drop), giving you precise control over acknowledgment, retries, and dead-letter queue routing.
Scoped dependency injection: Handlers are resolved from an isolated IServiceScope for each message delivery, enabling clean constructor injection for scoped dependencies like Entity Framework DbContext, repositories, or loggers.
Compile-time source generation: Annotated handlers are discovered at build time for reflection-free registration and dispatch. Validate Native AOT separately because generated message deserialization still uses runtime JSON metadata.
Analyzer Validation
DAPR1611 (Error): If a class decorated with [DaprTopic] does not implement ITopicHandler<TMessage> or ITopicHandler<TMessage, TResult>, DAPR1611 triggers a build error.
DAPR1612 (Warning): If a handler’s message type is not registered in a source-generated JsonSerializerContext during Native AOT compilation, DAPR1612 warns that AOT deserialization may fail.
Dapr pub/sub delivery protocol
In accordance with the Dapr runtime specification (gRPC AppCallback.OnTopicEvent and HTTP topic endpoints), topic delivery handlers return message acknowledgment status (TopicResponseAction.Success, Retry, or Drop). The Dapr pub/sub runtime does not accept arbitrary business data payloads in response to event delivery.
Example: Topic handler with ITopicHandler<TMessage>
usingDapr.Messaging;usingDapr.Messaging.PublishSubscribe;publicrecordOrder(stringId,decimalTotal,stringCustomerEmail);[DaprTopic("pubsub", "orders")]publicsealedclassOrderHandler(ILogger<OrderHandler>logger,IInventoryServiceinventory):ITopicHandler<Order>{publicasyncTask<TopicResponseAction>HandleAsync(Ordermessage,TopicContextcontext,CancellationTokencancellationToken){logger.LogInformation("Processing order {OrderId} from pubsub {PubSub}",message.Id,context.PubsubName);try{awaitinventory.ReserveStockAsync(message.Id,cancellationToken);returnTopicResponseAction.Success;}catch(InventoryUnavailableExceptionex){logger.LogWarning(ex,"Insufficient inventory for order {OrderId}; dropping message",message.Id);returnTopicResponseAction.Drop;}catch(Exceptionex){logger.LogError(ex,"Transient error processing order {OrderId}; retrying",message.Id);returnTopicResponseAction.Retry;}}}
Response actions
The HandleAsync method returns a TopicResponseAction that tells the Dapr runtime how to acknowledge the message:
Response Action
Description
TopicResponseAction.Success
The message was processed successfully and should be acknowledged (deleted) from the pub/sub component.
TopicResponseAction.Retry
Processing failed due to a transient error; Dapr will redeliver the message according to the component’s retry policy.
TopicResponseAction.Drop
Processing failed permanently; Dapr will delete the message or route it to a configured dead-letter topic without further retries.
Accessing delivery context with TopicContext
The TopicContext passed to HandleAsync provides rich metadata about the delivery:
publicasyncTask<TopicResponseAction>HandleAsync(Ordermessage,TopicContextcontext,CancellationTokencancellationToken){varpubsubName=context.PubsubName;// Name of the Dapr pub/sub componentvartopicName=context.TopicName;// Name of the topicvarmessageId=context.MessageId;// Unique message identifiervarmetadata=context.Metadata;// Additional component-specific metadatavarheaders=context.Headers;// CloudEvent headers forwarded with the deliveryvarrawBytes=context.RawPayload;// Raw payload bytes (when EnableRawPayload is true)varcloudEvent=context.CloudEvent;// Strongly-typed CloudEvent envelopereturnTopicResponseAction.Success;}
Configure subscriptions with [DaprTopic]
The [DaprTopic] attribute specifies which topic to subscribe to and how messages are delivered.
Positional constructor arguments
The [DaprTopic] constructor requires two positional string arguments:
pubsubName (1st argument): The name of the Dapr pub/sub component (as defined in your component YAML’s metadata.name, e.g., "pubsub" or "messagebus").
topicName (2nd argument): The name of the topic or queue to subscribe to (e.g., "orders" or "orders.us").
// Subscribes to the "orders" topic on the "pubsub" component[DaprTopic("pubsub", "orders")]publicclassOrderHandler:ITopicHandler<Order>{/* ... */}
Not all properties on [DaprTopic] are relevant to every delivery mode. The table below outlines which properties apply to each mode:
Property
Description
DeliveryMode.Streaming
DeliveryMode.Programmatic
DeliveryMode.Http
PubsubName
Name of the Dapr pub/sub component
Required
Required
Required
TopicName
Name of the topic
Required
Required
Required
Delivery
Delivery mode (Streaming, Programmatic, Http)
Optional (Default)
Optional
Optional
Route
Target HTTP path for event dispatching
Ignored
Ignored
Applicable
Match
CEL expression for content-based routing
Ignored
Applicable
Applicable
Priority
Priority order for matching CEL rules
Ignored
Applicable
Applicable
DeadLetterTopic
Fallback topic for dropped/failed messages
Applicable
Applicable
Applicable
EnableRawPayload
Forward raw bytes without CloudEvent parsing
Applicable
Applicable
Applicable
BulkSubscribe
Enable batch message delivery
Ignored
Applicable
Applicable
MaxMessagesCount
Maximum batch size for bulk subscriptions
Ignored
Applicable
Applicable
MaxAwaitDurationMs
Maximum wait duration (ms) for batch assembly
Ignored
Applicable
Applicable
MetadataKeys
Keys linking [DaprTopicMetadata] to this topic
Applicable
Applicable
Applicable
Property scope rules & analyzer validation
Route: Only used when Delivery = DeliveryMode.Http. In Streaming and Programmatic modes, events are delivered directly over gRPC streams or gRPC services rather than HTTP routes, so setting Route has no effect.
Routing Rules (Match / Priority): CEL rule evaluation is handled by the Dapr runtime when pushing events via Programmatic (gRPC) or Http. In Streaming mode, the client initiates the stream directly for the topic without server-side HTTP/gRPC routing rules.
Bulk Subscriptions (BulkSubscribe, MaxMessagesCount, MaxAwaitDurationMs): Configures server-side batch aggregation when Dapr pushes events via Programmatic or Http.
Compile-time property validation (DAPR1616 & DAPR1617): Roslyn analyzers inspect [DaprTopic] configurations at build time. DAPR1616 warns if you opt into a feature (like BulkSubscribe = true) without setting required companion properties (MaxMessagesCount, MaxAwaitDurationMs). DAPR1617 warns if you populate properties that are ignored for the selected delivery mode or feature set (such as setting Route or Match on non-HTTP topics).
The three delivery modes
DeliveryMode controls the communication protocol between the Dapr sidecar and your application:
DeliveryMode.Streaming (Default):
The application initiates a persistent bidirectional gRPC stream (SubscribeTopicEventsAlpha1) to the Dapr sidecar.
Messages are pulled by the application with client-side backpressure buffering.
No inbound HTTP or gRPC endpoints need to be mapped for streaming subscriptions.
DAPR1610 (Warning): Ensures the same pub/sub component and topic are not registered with conflicting delivery modes across different handlers.
DAPR1613 (Warning): Warns when programmatic subscriptions are declared but endpoint mapping (app.MapDaprMessaging()) is omitted from the application pipeline. Includes an automated IDE Code Fix.
DAPR1615 (Warning): Warns when endpoint mapping or subscriber registration methods are called without matching topic subscribers in the application.
Content-based routing with CEL
You can filter and route messages based on Common Expression Language (CEL) expressions using the Match and Priority properties:
Dead-letter topic: Set DeadLetterTopic = "orders-dlq" to route dropped messages to a dead-letter queue.
Bulk subscription: Select HTTP or programmatic delivery and set BulkSubscribe = true, MaxMessagesCount, and MaxAwaitDurationMs to receive batches of messages.
Raw payloads: Set EnableRawPayload = true to receive the unparsed event payload without CloudEvent envelope parsing.
Topic metadata with [DaprTopicMetadata]
You can attach key-value metadata to your topic subscription using the [DaprTopicMetadata] attribute:
[DaprTopic] allows multiple attributes on a single handler class. The handler processes messages from each configured topic:
[DaprTopic("pubsub", "orders.us")][DaprTopic("pubsub", "orders.eu")][DaprTopic("pubsub", "orders.asia")]publicsealedclassMultiRegionOrderHandler:ITopicHandler<Order>{publicTask<TopicResponseAction>HandleAsync(Ordermessage,TopicContextcontext,CancellationTokencancellationToken){Console.WriteLine($"Received order from topic: {context.TopicName}");returnTask.FromResult(TopicResponseAction.Success);}}
What the source generator produces
When you build your project, the Dapr.Messaging.Generators Roslyn source generator discovers all [DaprTopic] classes implementing ITopicHandler<TMessage> and emits:
Generated Artifact
Purpose
Typed Dispatchers
Direct, strongly-typed invokers that deserialize message payloads with System.Text.Json and invoke HandleAsync without reflection-based handler discovery.
IDaprMessagingSubscriberRegistry
A centralized registry containing TopicSubscriptionDescriptor definitions for all discovered topics.
AddDaprMessaging()
An IServiceCollection extension method generated into your assembly that automatically registers options, the publishing client, handlers, dispatchers, subscriber registries, and required hosting services into your DI container.
Handler discovery and dispatch registration are generated at compile time. Message
deserialization currently uses runtime System.Text.Json metadata, so Native AOT and
trimming scenarios require additional validation and an application-owned
JsonSerializerContext.
Register at startup
Register Dapr messaging services in Program.cs using builder.Services.AddDaprMessaging():
varbuilder=WebApplication.CreateBuilder(args);// Register the complete Dapr messaging stack (options, publisher client, and source-generated subscribers)builder.Services.AddDaprMessaging(options=>{// Optional: configure Dapr sidecar connection optionsoptions.DaprGrpcEndpoint="http://localhost:50001";});varapp=builder.Build();// Automatically map the endpoints required by the discovered delivery modesapp.MapDaprMessaging();app.Run();
Registration & endpoint analyzer validation
DAPR1614 (Warning): Emitted if application code attempts to invoke the internal DaprMessagingRegistration class directly instead of using the source-generated builder.Services.AddDaprMessaging() extension.
DAPR1613 (Warning): Triggers if programmatic topic subscriptions are registered but app.MapDaprMessaging() (or app.MapDaprAppCallback()) is missing from Program.cs.
DAPR1615 (Warning): Triggers if app.MapDaprMessaging() or subscriber registration endpoints are present when no corresponding HTTP or programmatic topic handlers exist.
Dynamic streaming subscriptions
In addition to declarative [DaprTopic] handlers, DaprPublishSubscribeClient allows creating imperative, dynamic streaming subscriptions at runtime:
varmessagingClient=app.Services.GetRequiredService<DaprPublishSubscribeClient>();// Configure subscription optionsvaroptions=newDaprSubscriptionOptions(newMessageHandlingPolicy(TimeoutDuration:TimeSpan.FromSeconds(10),DefaultResponseAction:TopicResponseAction.Retry)){DeadLetterTopic="orders-dlq",MaximumQueuedMessages=500,MaximumCleanupTimeout=TimeSpan.FromSeconds(15),ErrorHandler=async(DaprExceptionex)=>{Console.WriteLine($"Subscription error: {ex.Message}");}};// Start the dynamic subscriptionawaitusingvarsubscription=awaitmessagingClient.SubscribeAsync("pubsub","dynamic-orders",options,async(TopicMessagemessage,CancellationTokenct)=>{varpayload=Encoding.UTF8.GetString(message.Data.Span);Console.WriteLine($"Received dynamic message: {payload}");returnTopicResponseAction.Success;});// SubscribeAsync is typed as IAsyncDisposable; the runtime handle also exposes completion.await((IDaprSubscription)subscription).Completion;
Analyzers and diagnostics
Dapr.Messaging ships with Roslyn analyzers that enforce correct messaging practices at build time:
ID
Title
Severity
Description
DAPR1610
Duplicate topic with conflicting delivery modes
Warning
Triggered when the same (pubsubName, topicName) is configured with different DeliveryMode values across [DaprTopic] attributes.
DAPR1611
Handler must implement ITopicHandler<T>
Error
Triggered when [DaprTopic] is placed on a class that does not implement ITopicHandler<TMessage> or ITopicHandler<TMessage, TResult>.
DAPR1612
Message type not registered in JsonSerializerContext
Warning
Warns when a message type is not covered by [JsonSerializable] in a Native AOT compilation.
DAPR1613
Map endpoints for programmatic subscriptions
Warning
Warns when programmatic subscriptions are configured without app.MapDaprAppCallback() or app.MapDaprMessaging(). Includes an automated Code Fix.
DAPR1614
Do not call DaprMessagingRegistration directly
Warning
Warns when DaprMessagingRegistration is called directly from application code instead of calling services.AddDaprMessaging().
DAPR1615
Remove unused subscriber registration
Warning
Warns when subscriber registration or endpoint mapping is present without matching HTTP or programmatic topic subscribers.
DAPR1616
Incomplete [DaprTopic] configuration
Warning
Warns when [DaprTopic] opts into a feature (like BulkSubscribe = true) without setting its required companion properties (MaxMessagesCount, MaxAwaitDurationMs).
DAPR1617
Ignored [DaprTopic] configuration
Warning
Warns when [DaprTopic] sets a property that is ignored for the selected delivery mode or feature set (e.g., Route or Match on non-HTTP topics).
For a complete reference of all Roslyn analyzers, diagnostic severities, and available automated code fixes across the Dapr .NET SDK, see Dapr source code analyzers and generators.
Best practices
Prefer DeliveryMode.Streaming for background workers, console apps, and services where you want bidirectional streaming and client-side backpressure without hosting an inbound HTTP or gRPC server.
Prefer DeliveryMode.Programmatic for high-throughput gRPC services where the Dapr sidecar pushes events directly into ASP.NET Core gRPC endpoints.
Always handle transient vs permanent errors: Return TopicResponseAction.Retry for transient errors (network timeouts, database locks) and TopicResponseAction.Drop for unrecoverable errors (poison messages, schema violations) to route them to a dead-letter topic.
Inject scoped services: ITopicHandler<TMessage> instances are resolved per message within an isolated IServiceScope, ensuring safe resolution of scoped dependencies like Entity Framework DbContext.
Validate Native AOT explicitly: Generated dispatchers currently use runtime System.Text.Json metadata; test Native AOT and trimming with your target SDK version and message types.
Configuration, options, lifetime management, delivery modes, and advanced features for Dapr.Messaging in .NET
This guide covers advanced configuration, service lifetimes, delivery mode trade-offs, serialization, Native AOT support, and resilience patterns when working with the Dapr.Messaging SDK.
The API coverage on this page targets SDK 1.19.0 or later.
Lifetime management and thread safety
Understanding the lifetime of messaging services ensures optimal performance and prevents socket exhaustion or memory leaks.
Client lifetime (IDaprPublishSubscribeClient)
IDaprPublishSubscribeClient (and its underlying implementation DaprPublishSubscribeClient) manages persistent TCP and gRPC channel resources to communicate with the Dapr sidecar.
Singleton registration: The client is thread-safe and designed to be registered as a Singleton in your dependency injection container.
Connection pooling: Reuses underlying HttpClient and GrpcChannel instances across requests.
Do not create per operation: Avoid instantiating or disposing DaprPublishSubscribeClient per operation. A single shared client should serve your entire application.
// Registers the Dapr Messaging stack, including IDaprPublishSubscribeClient and DaprPublishSubscribeClient as a Singletonbuilder.Services.AddDaprMessaging();
Handler lifetime (ITopicHandler<TMessage>)
When messages are delivered to an ITopicHandler<TMessage>:
Scoped execution: The source-generated dispatcher resolves the topic handler from an isolated IServiceScope on every message delivery.
Constructor injection: Handlers can safely inject transient or scoped dependencies (such as Entity Framework Core DbContext instances, repositories, or tenant context providers).
Automatic disposal: Any disposable dependencies resolved by the handler are automatically disposed when the message handling turn completes.
The SDK uses the standard Microsoft.Extensions.Options pattern. You can configure DaprMessagingOptions in code or bind it directly from IConfiguration.
The API token forwarded in outbound Dapr requests for sidecar authentication.
StreamingReconnectDelay
5 seconds
The delay interval applied before attempting to reconnect a dropped streaming subscription.
JsonSerializerOptions
JsonSerializerDefaults.Web
Options used by the messaging publishing client. Generated subscriber dispatchers currently use their generated default options.
Endpoint and environment precedence
The underlying builder understands the standard Dapr endpoint/port and API-token environment variables.
However, in SDK 1.19.0, AddDaprMessaging applies the nonempty
DaprMessagingOptions.DaprGrpcEndpointafter creating the builder. Its default
http://localhost:50001 therefore overrides the environment-derived gRPC address even when you did not
explicitly configure it.
Bind the correct endpoint when the sidecar uses another port:
An explicitly configured DaprApiToken overrides the builder’s environment-derived token. Do not hard-code
production tokens in source; obtain them from your deployment’s configuration or secret mechanism.
Choosing the right delivery mode
Dapr.Messaging unifies three delivery modes under a single [DaprTopic] attribute. Choose the mode that fits your architectural requirements:
Dimension
Streaming (DeliveryMode.Streaming)
Programmatic (DeliveryMode.Programmatic)
HTTP (DeliveryMode.Http)
Direction
Outbound from App (App dials sidecar via gRPC stream)
Inbound to App (Sidecar dials app’s gRPC server)
Inbound to App (Sidecar dials app’s HTTP port)
Endpoint Required
None
gRPC AppCallback service
HTTP endpoint (/dapr/subscribe)
App Configuration
Works with sidecar alone (no app-port required)
Requires app gRPC port configured on sidecar
Requires app HTTP port configured on sidecar
Backpressure
Client-side buffered queue
Runtime-managed concurrency
Web server / ASP.NET pipeline
Best Used For
Workers, console apps, microservices without inbound listeners
High-throughput gRPC services, low latency
Traditional web APIs, existing HTTP routing setups
Endpoint Registration
None
app.MapDaprMessaging()
app.MapDaprMessaging()
Advanced messaging patterns
Bulk publish and subscribe
Bulk operations allow publishing and consuming multiple events in batches, reducing network round-trips to the sidecar:
Bulk publishing
varorders=newList<Order>{new("101",49.99m,"user1@example.com"),new("102",99.50m,"user2@example.com")};varresponse=awaitpubsubClient.BulkPublishEventAsync("pubsub","orders",orders);foreach(varfailedinresponse.FailedEntries){logger.LogError("Failed to publish order {OrderId}: {Error}",failed.Entry.EventData.Id,failed.ErrorMessage);}
Bulk subscribing
Configure bulk consumption by enabling BulkSubscribe = true on the [DaprTopic] attribute:
[DaprTopic("pubsub", "orders", Delivery = DeliveryMode.Http,
BulkSubscribe = true, MaxMessagesCount = 50, MaxAwaitDurationMs = 500)]publicsealedclassBulkOrderHandler:ITopicHandler<Order>{publicasyncTask<TopicResponseAction>HandleAsync(Ordermessage,TopicContextcontext,CancellationTokencancellationToken){logger.LogInformation("Processing order {OrderId}",message.Id);// Process one message from the runtime-delivered batch.returnTopicResponseAction.Success;}}
Bulk delivery batches messages at the Dapr protocol level, but the current SDK dispatches
each entry to the handler separately. The handler therefore implements ITopicHandler<Order>,
not ITopicHandler<IReadOnlyList<Order>>.
Bulk subscription requires HTTP or programmatic push delivery and app.MapDaprMessaging().
Leaving the default streaming delivery mode would ignore the bulk settings.
Dead-letter topics
Configure a dead-letter topic to capture unprocessable or poison messages:
[DaprTopic("pubsub", "orders", DeadLetterTopic = "orders-poison")]publicsealedclassResilientOrderHandler:ITopicHandler<Order>{publicTask<TopicResponseAction>HandleAsync(Ordermessage,TopicContextcontext,CancellationTokencancellationToken){if(string.IsNullOrEmpty(message.CustomerEmail)){// Dropping routes the message to 'orders-poison' because DeadLetterTopic is configuredreturnTask.FromResult(TopicResponseAction.Drop);}returnTask.FromResult(TopicResponseAction.Success);}}
Working with CloudEvents
You can publish typed CloudEvents and inspect the exposed CloudEvent headers on delivery:
publicTask<TopicResponseAction>HandleAsync(Ordermessage,TopicContextcontext,CancellationTokencancellationToken){if(context.CloudEventisnotnull){varcorrelationId=context.CloudEvent.TraceId;vareventSource=context.CloudEvent.Source;logger.LogInformation("Processing CloudEvent {Id} from {Source}",context.CloudEvent.Id,eventSource);}returnTask.FromResult(TopicResponseAction.Success);}
Native AOT and trimming considerations
The Dapr.Messaging.Generators source generator produces dispatch logic and subscriber
registries at build time, avoiding reflection for handler discovery and registration.
Generated subscriber dispatchers currently deserialize messages with runtime
System.Text.Json metadata. Native AOT and trimming scenarios therefore require
explicit validation with the target SDK version; configuring DaprMessagingOptions.JsonSerializerOptions
does not currently replace the generated subscriber deserializer.
AOT Analyzer warning DAPR1612
If a message type used with ITopicHandler<TMessage> is not included in a source-generated
JsonSerializerContext, analyzer DAPR1612 emits a build warning. The analyzer warning
does not by itself make the generated subscriber deserialization trim-safe.
Resiliency and error handling
Backpressure management in streaming mode
When using DeliveryMode.Streaming or dynamic streaming subscriptions, the SDK queues incoming messages in
application memory. MaximumQueuedMessages is nullable and defaults to an unbounded queue; set a positive
limit to bound client buffering and apply backpressure.
You can tune the queue size and cleanup timeout in DaprSubscriptionOptions:
varoptions=newDaprSubscriptionOptions(newMessageHandlingPolicy(TimeoutDuration:TimeSpan.FromSeconds(15),DefaultResponseAction:TopicResponseAction.Retry)){MaximumQueuedMessages=1000,// Max backlog held in-memoryMaximumCleanupTimeout=TimeSpan.FromSeconds(30),// Max wait to flush ACKs on shutdownErrorHandler=async(exception)=>{// Invoked on sidecar streaming or connection faultslogger.LogError(exception,"Streaming subscription encountered an error");}};
Graceful shutdown
Streaming subscriptions attempt to flush pending acknowledgments within MaximumCleanupTimeout (30 seconds
by default). This is a bounded, best-effort shutdown, not a zero-loss or exactly-once guarantee. Delivery can be
repeated when an acknowledgment is not received, including after processing succeeded. Make handlers idempotent.
Observe background subscription failures
SubscribeAsync returns IAsyncDisposable, but its runtime handle implements IDaprSubscription.
Observe IDaprSubscription.Completion to detect a stream or acknowledgment-loop failure after startup.
ErrorHandler is a notification callback, not a recovery mechanism: the original fault still completes the
subscription exceptionally. If that callback also throws, the completion task reports both failures through
an AggregateException. Recreate a failed dynamic subscription according to an application-owned retry policy.
Generated streaming subscribers use the configured reconnect delay. Do not assume that an arbitrary dynamic
subscription automatically reconnects because generated hosting does.
Existing DaprClient and ASP.NET integrations
DaprClient.PublishEventAsync and BulkPublishEventAsync, as well as Dapr.AspNetCore[Topic],
CloudEvents middleware, and subscription endpoint mapping, remain separate supported API surfaces.
The generated [DaprTopic]/ITopicHandler<T> model does not require a wholesale migration of existing
subscribers. The dedicated publisher uses PublishOptions; the older client uses metadata dictionaries.
Avoid registering duplicate subscriptions while migrating a topic.
A seven-part tutorial for publishing and subscribing with the Dapr.Messaging .NET SDK, including unit and integration testing
This tutorial uses the runnable examples in the .NET SDK repository to demonstrate the main capabilities of Dapr.Messaging. Each part includes the implementation pattern and the corresponding unit and integration testing approach.
The examples support .NET 8, .NET 9, .NET 10, or .NET 11 and require Dapr runtime 1.18 or later. The unit tests run without Dapr, Docker, or a message broker. The integration tests use Dapr.Testcontainers to run a real Dapr sidecar and Redis pub/sub component, which is the recommended way to verify delivery behavior in an application.
.NET framework support
Be advised that .NET 8 and .NET 9 reach end of support in November 2026. Support for both will be dropped
in Dapr v1.20; only .NET 10 and .NET 11 will be supported through the end of 2027.
Tutorial content
Part 1: Publish events: Typed events, CloudEvent metadata, bulk publishing, and raw payloads.
Keep message-handling decisions in ordinary C# handlers so they can be tested directly. The example unit tests construct a TopicContext, invoke HandleAsync, and assert the returned TopicResponseAction for valid, invalid, and transient inputs.
Add integration tests for the infrastructure boundary. The examples create a PubSubHarness, configure the application with the harness’s dynamically assigned Dapr endpoints, publish real events, and wait for observable processing results. Use bounded timeouts, IAsyncLifetime cleanup, and a test log sink rather than sleeps alone when validating asynchronous delivery.
Dapr runtime 1.18 or later for streaming subscriptions
Docker or another container runtime for integration tests
The Dapr.Messaging package
When using the SDK repository directly, the examples reference the local SDK projects. In an application, install the published Dapr.Messaging package instead.
15.4.1 - Part 1: Publish events with Dapr.Messaging
Publish typed, bulk, metadata-rich, and raw events with IDaprPublishSubscribeClient
The publishing example introduces IDaprPublishSubscribeClient, the publishing API in Dapr.Messaging.
Register the client
AddDaprMessaging() registers the client with dependency injection:
Inject IDaprPublishSubscribeClient into an endpoint, service, or background worker. The client is intended to be reused rather than created for every message.
The unit tests use Moq to verify that the client receives the expected topic, payload, options, and content type. They do not start Dapr. The integration tests use DaprHarnessBuilder.BuildPubSub(), connect a real client to the harness’s dynamic gRPC and HTTP ports, and verify typed, bulk, and raw publishing against the sidecar and Redis.
Success acknowledges the message, Retry requests redelivery for a transient failure, and Drop rejects an invalid or poison message. The handler also receives TopicContext, which contains the pub/sub name, topic, message ID, headers, and metadata.
Configure the application
builder.Services.AddDaprMessaging();
The source generator discovers the attributed handlers, registers them in DI, and starts the streaming subscriber hosted service. Streaming is application-initiated, so the application does not need an inbound HTTP or gRPC endpoint for delivery. The SDK manages the stream and reconnects when the connection drops.
Test the handler and the stream
Unit tests construct handlers with mocked loggers and a small TopicContext, then assert Success, Retry, and Drop for representative inputs. This keeps validation and acknowledgement logic fast and independent of infrastructure.
The integration test uses DaprTestApplicationBuilder and configures AddDaprMessaging with the harness’s gRPC endpoint. It publishes through the sidecar and waits on a test log sink for successful processing, inventory handling, and dropping an invalid order. This verifies the hosted service and real streaming delivery without requiring an application ingress port.
The example adds an international rule using event.data.destinationCountry != 'US' and a priority-10 catch-all handler for standard domestic shipments. DaprTopicMetadata adds custom key-value data to the generated subscription manifest.
Handle the dead-letter topic
Subscribe to the configured dead-letter topic like any other topic:
[DaprTopic("pubsub", "deadletter-shipments")]publicsealedclassDeadLetterShipmentHandler:ITopicHandler<ShipmentPackage>{publicTask<TopicResponseAction>HandleAsync(ShipmentPackageshipment,TopicContextcontext,CancellationTokencancellationToken){// Record or alert on the rejected message.returnTask.FromResult(TopicResponseAction.Success);}}
Messages can reach the dead-letter topic after a handler returns Drop or after retry delivery is exhausted according to the component configuration.
Test routing decisions
The unit tests invoke each handler directly and cover an express shipment, an invalid weight, an international destination, a standard fallback, and a dead-letter message. These tests prove business decisions without pretending to validate CEL evaluation.
The integration tests are the place to validate the generated subscription manifest, CEL evaluation, priority ordering, and dead-letter forwarding against a real Dapr sidecar and broker.
MaxMessagesCount bounds the batch size and MaxAwaitDurationMs bounds how long Dapr waits before delivering a partial batch. Choose values based on throughput, latency, and broker behavior. The handler remains responsible for the individual message decision.
Inspect response.FailedEntries when bulk publishing because a batch can contain partial failures.
Test batching-related behavior
The unit tests cover valid telemetry and invalid temperature and humidity values. The integration tests publish readings and batches through a real Dapr pub/sub harness to validate the generated bulk subscription configuration and delivery semantics.
In this mode Dapr initiates the gRPC call to the application’s AppCallback service. Unlike streaming subscriptions, the app must expose a reachable gRPC port.
MapDaprMessaging() exposes the generated AppCallback gRPC service used by the sidecar
to list subscriptions and deliver events. Run the application with an app port and gRPC
protocol, for example:
The unit tests verify that valid payments succeed and zero or negative amounts are dropped. The integration tests configure the test application with a gRPC server, start the harness, publish real payments, and observe the handler result. This distinction is important: unit tests validate business rules; integration tests validate AppCallback registration, reachability, and delivery.
The generated mapping exposes GET /dapr/subscribe for Dapr to discover subscriptions and maps POST /api/events/invoices for the topic’s CloudEvents. The route is generated from the Route property; it does not need a separate controller or action.
Inspect the generated manifest with curl http://localhost:5000/dapr/subscribe.
Test HTTP delivery
The unit tests invoke the invoice handler directly for valid, zero, and negative amounts. The integration tests use the test application and a real Dapr sidecar to verify subscription discovery and HTTP push delivery. This lets the test cover route mapping and CloudEvent dispatch without coupling business-rule tests to ASP.NET hosting.
SubscribeAsync returns an async-disposable subscription and an IDaprSubscription.Completion task. A supervisor can await completion, log faults, delay, and open a new subscription. Always dispose the subscription and honor the host cancellation token.
Handle untyped messages safely
Dynamic subscriptions receive TopicMessage, so the handler is responsible for decoding and validating the payload:
Malformed JSON is a permanent input problem and is dropped; transient processing errors should request retry.
Test the supervisor and handler
The unit tests create TopicMessage instances with serialized valid, retry, invalid, and malformed payloads and assert the response action. The integration test registers the worker with DaprTestApplicationBuilder, publishes real tenant events, and waits on a concurrent log sink for processing and drop results. This pattern tests asynchronous background work without relying on arbitrary sleeps.
Overview of the Dapr Distributed Lock building block for .NET
With the Dapr Distributed Lock package, you can create and remove locks on resources to manage exclusivity across
your distributed applications. The DaprDistributedLockClient provides a dedicated client for interacting with Dapr’s
distributed lock API, supporting lock acquisition with automatic release on disposal, configurable timeouts, and
cancellation.
Package guidance
While this capability is implemented in both the Dapr.Client and Dapr.DistributedLock packages, the approach differs
slightly between them. It’s recommended that new
implementations use the Dapr.DistributedLock package.
Be advised that .NET 8 and .NET 9 reach end of support in November 2026. Support for both will be dropped
in Dapr v1.20; only .NET 10 and .NET 11 will be supported through the end of 2027.
To get started with the Dapr Distributed lock .NET SDK client, install the Dapr.Distributed Lock package from NuGet:
dotnet add package Dapr.DistributedLock
The examples below target SDK 1.19.0 or later and require a configured distributed lock component,
such as a Redis lock store named lockstore. Import Dapr.DistributedLock and
Dapr.DistributedLock.Extensions. This API is experimental (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 Scoped
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 fromvaroptionsProvider=serviceProvider.GetRequiredService<DefaultOptionsProvider>();varstandardTimeout=optionsProvider.GetStandardTimeout();//Configure the value on the client builderclientBuilder.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().
Clone 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.
Acquire and automatically release a lock
TryLockAsync makes one acquisition attempt. It returns a LockResponse when acquired and null when
the resource could not be locked. It does not wait until another owner releases the lock.
varowner=Guid.NewGuid().ToString("N");awaitusingvarresourceLock=awaitdaprDistributedLockClient.TryLockAsync("lockstore","orders/42",owner,expiryInSeconds:60,cancellationToken:cancellationToken);if(resourceLockisnull){Console.WriteLine("The resource is already locked; work was not started.");}else{awaitProcessOrderAsync(cancellationToken);}
LockResponse contains StoreName, ResourceId, and LockOwner and implements IAsyncDisposable.
At the end of the scope, disposal attempts to unlock with that identity. It does not expose the unlock
status to the caller. Use explicit unlock when you need to inspect the result, rather than assuming
disposal confirms successful release.
Choose an owner unique to the acquisition attempt, not just a shared app ID. The expiry must be a positive
number of seconds. Ensure protected work finishes before expiry; this client does not renew leases or
provide fencing tokens. Cancellation of the caller does not extend a lease or undo completed work.
Explicit unlock and status
Pass the same component, resource, and owner values to TryUnlockAsync:
usingDapr.DistributedLock.Models;varresult=awaitdaprDistributedLockClient.TryUnlockAsync("lockstore","orders/42",owner,cancellationToken);if(result.Status!=LockStatus.Success){thrownewInvalidOperationException($"Unlock did not succeed: {result.Status}");}
LockStatus
Meaning
Success
The lock was released.
LockDoesNotExist
The lock no longer exists, for example because it expired.
LockBelongsToOthers
The supplied owner does not own this lock.
InternalError
The runtime/component reported an unlock error.
Invalid arguments raise standard argument exceptions. gRPC failures are wrapped in DaprException;
inspect InnerException. A normal acquisition conflict is null, not a transport exception.
Migration from DaprClient
The older DaprClient.Lock returns TryLockResponse with a Success property, whereas
DaprDistributedLockClient.TryLockAsync returns a nullable disposable handle. Likewise, use
TryUnlockAsync rather than DaprClient.Unlock and inspect the dedicated client’s LockStatus.
The two response models are not interchangeable.
16.2 - DaprDistributedLockClient usage
Essential tips and advice for using DaprDistributedLockClient
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 manually create a DaprDistributedLockClient, use DaprDistributedLockBuilder.
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().
vardaprDistributedLockClient=newDaprDistributedLockBuilder().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.
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.
Cancellation can surface as OperationCanceledException or a DaprException wrapping a gRPC cancellation,
depending on the channel configuration. Inspect the inner exception rather than treating cancellation as a lock conflict.
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.
varbuilder=WebApplication.CreateBuilder(args);builder.Services.AddDaprDistributedLock();//Registers the `DaprDistributedLockClient` to be injected as neededvarapp=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.
varbuilder=WebApplication.CreateBuilder(args);builder.Services.AddDaprDistributedLock((_,daprDistributedLockBuilder)=>{//Set the API tokendaprDistributedLockBuilder.UseDaprApiToken("abc123");//Specify a non-standard HTTP endpointdaprDistributedLockBuilder.UseHttpEndpoint("http://dapr.my-company.com");});varapp=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:
varbuilder=WebApplication.CreateBuilder(args);//Register a fictional service that retrieves secrets from somewherebuilder.Services.AddSingleton<SecretService>();builder.Services.AddDaprDistributedLock((serviceProvider,daprDistributedLockBuilder)=>{//Retrieve an instance of the `SecretService` from the service providervarsecretService=serviceProvider.GetRequiredService<SecretService>();vardaprApiToken=secretService.GetSecret("DaprApiToken").Value;//Configure the `DaprDistributedLockBuilder`daprDistributedLockBuilder.UseDaprApiToken(daprApiToken);});varapp=builder.Build();
17 - 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 approaches 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.
Developing with the Dapr CLI: run each service and its sidecar with dapr run, the lightest-weight approach for getting started.
Developing with .NET Aspire: integrate Dapr sidecars into an Aspire AppHost using the CommunityToolkit.Aspire.Hosting.Dapr package and WithDaprSidecar().
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.
💡 Ports
Since you need to configure a unique port for each service, you can use this command to pass that port value to both Dapr and the service. --urls http://localhost:<port> will configure ASP.NET Core to listen for traffic on the provided port. Using configuration at the commandline is a more flexible approach than hardcoding a listening port elsewhere.
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.
17.2 - Dapr .NET SDK Development with Docker-Compose
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.
💡 App Port
In a container, an ASP.NET Core app will listen on port 80 by default. Remember this for when you need to configure the --app-port later.
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
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.
17.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,
.NET 10, and
.NET 11. Use a .NET Aspire version that supports the runtime you choose.
Be advised that .NET 8 and .NET 9 reach end of support in November 2026. Support for both will be dropped
in Dapr v1.20; only .NET 10 and .NET 11 will be supported through the end of 2027.
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.
This package was previously called Aspire.Hosting.Dapr, which has been marked as deprecated.
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:
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:
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:
DaprSidecarOptionssidecarOptions=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);
As indicated in the example above, as of .NET Aspire 9.0, if you intend to use any functionality in which Dapr needs to
call into your application such as pubsub, actors or workflows, you will need to specify your AppPort as
a configured option as Aspire will not automatically pass it to Dapr at runtime. It’s expected that this behavior will
change in a future release as a fix has been merged and can be tracked here.
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.
18 - 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.Testcontainershere.
18.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 ExtendedErrorInfotry{// Perform some action with the Dapr client that throws a DaprException.}catch(DaprExceptiondaprEx){if(daprEx.TryGetExtendedErrorInfo(outDaprExtendedErrorInfoerrorInfo)){Console.WriteLine(errorInfo.Code);Console.WriteLine(errorInfo.Message);foreach(DaprExtendedErrorDetaildetailinerrorInfo.Details){Console.WriteLine(detail.ErrorType);switch(detail.ErrorType){caseExtendedErrorType.ErrorInfo:Console.WriteLine(detail.Reason);Console.WriteLine(detail.Domain);break;default:Console.WriteLine(detail.TypeUrl);break;}}}}
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.
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”).
18.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.
.NET framework support
Be advised that .NET 8 and .NET 9 reach end of support in November 2026. Support for both will be dropped
in Dapr v1.20; only .NET 10 and .NET 11 will be supported through the end of 2027.
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:
Experimental APIs - Features that are available but still evolving and have not yet been certified as stable
according to the Dapr Component Certification Lifecycle.
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
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_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 #pragmawarningdisableDAPR_CRYPTOGRAPHY// Your code using the experimental API varclient=newDaprEncryptionClient();// Re-enable the warning #pragmawarningrestoreDAPR_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.
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
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
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
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
18.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.
Important
Dapr.Testcontainers is an initial release. The API is still evolving and may change in future versions. We aim to
keep changes minimal, but you should expect potential breaking changes while the package matures.
Packages
Dapr.Testcontainers (core harnesses and infrastructure)
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:
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).
18.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.
Note
A future release of the Dapr .NET SDK will include these analyzers by default without requiring a separate package
install.
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.
Note
At this time, the first two digits of the diagnostic identifier map one-to-one to distinct Dapr packages, but this
is subject to change in the future as more analyzers are developed.
Install and configure analyzers
The following packages provide Roslyn analyzers:
Dapr.Actors.Analyzers
Dapr.Jobs.Analyzers
Dapr.Messaging.Analyzers (bundled automatically in Dapr.Messaging)
Dapr.Workflow.Analyzers
Install each NuGet package on every project where you want the analyzers to run. For meta-packages like Dapr.Messaging or Dapr.Actors.Next, the analyzers and source generators are included automatically with the package. 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 ID
Dapr Package
Category
Severity
Version Added
Description
Code Fix Available
DAPR1001
Dapr.Common
Compatibility
Warning
1.18
API requires a newer Dapr runtime than the configured target.
No
DAPR1002
Dapr.Common
Compatibility
Warning
1.18
Unable to parse the Configured Dapr runtime target version
No
DAPR1003
Dapr.Common
Compatibility
Warning
1.18
Unable to parse the minimum Dapr runtime version declared on an API
No
DAPR1301
Dapr.Workflow
Usage
Warning
1.16
The workflow type is not registered with the dependency injection provider. Note: Not applied when Dapr.Workflow.Versioning installed.
Yes
DAPR1302
Dapr.Workflow
Usage
Warning
1.16
The workflow activity type is not registered with the dependency injection provider
Yes
DAPR1401
Dapr.Actors
Usage
Warning
1.16
Actor timer method invocations require the named callback method to exist on type
No
DAPR1402
Dapr.Actors
Usage
Warning
1.16
The actor type is not registered with dependency injection
Yes
DAPR1403
Dapr.Actors
Interoperability
Info
1.16
Set options.UseJsonSerialization to true to support interoperability with non-.NET actors
Yes
DAPR1404
Dapr.Actors
Usage
Warning
1.16
Call app.MapActorsHandlers to map endpoints for Dapr actors
Yes
DAPR1410
Dapr.Actors.Next
Compatibility
Warning
1.18
Actor state shape change breaks shipped serialization
Yes
DAPR1411
Dapr.Actors.Next
Concurrency
Warning
1.18
Actor turn must not escape the scheduler
Yes
DAPR1412
Dapr.Actors.Next
Concurrency
Warning
1.18
Actor turn must not block
Yes
DAPR1413
Dapr.Actors.Next
Determinism
Warning
1.18
Actor turn must use TimeProvider
Yes
DAPR1414
Dapr.Actors.Next
Determinism
Warning
1.18
Actor turn must use a scheduler-aware seeded source
Yes
DAPR1415
Dapr.Actors.Next
Compatibility
Warning
1.18
Actor state migration target is unreachable
Yes
DAPR1416
Dapr.Actors.Next
Design
Info
1.18
Actor turn filter should stay cross-cutting
Yes
DAPR1417
Dapr.Actors.Next
Usage
Warning
1.18
Actor interface method must return an asynchronous type
No
DAPR1418
Dapr.Actors.Next
Compatibility
Warning
1.18
Actor interface change breaks shipped wire contract
Yes
DAPR1419
Dapr.Actors.Next
Concurrency
Warning
1.18
Actor field should not hold mutable shared state
No
DAPR1420
Dapr.Actors.Next
Usage
Warning
1.18
Actor type name must disambiguate shared actor contracts
No
DAPR1421
Dapr.Actors.Next
Usage
Warning
1.18
Actor implementation must expose a generated client contract
Yes
DAPR1423
Dapr.Actors.Next
Compatibility
Warning
1.18
Actor state type is not connected to its migration family
Yes
DAPR1424
Dapr.Actors.Next
Compatibility
Warning
1.18
Actor state migration chain has a gap
No
DAPR1425
Dapr.Actors.Next
Compatibility
Warning
1.18
Actor state migration step requires an upcaster
Yes
DAPR1426
Dapr.Actors.Next
Compatibility
Warning
1.18
Actor state migration fold path is ambiguous
No
DAPR1427
Dapr.Actors.Next
Usage
Warning
1.18
Actor state name maps to multiple migration families
No
DAPR1428
Dapr.Actors.Next
Usage
Info
1.18
Actor state usage should target the latest state version
No
DAPR1429
Dapr.Actors.Next
Usage
Error
1.18
Scheduled actor reminder/timer callback does not match a dispatchable actor method
Yes
DAPR1430
Dapr.Actors.Next
Usage
Warning
1.18
Scheduled actor reminder/timer targets an actor type not found in this application
No
DAPR1431
Dapr.Actors.Next
Usage
Error
1.18
Scheduled actor reminder/timer callback method is not exposed through a generated actor client
No
DAPR1501
Dapr.Jobs
Usage
Warning
1.16
Job invocations require the MapDaprScheduledJobHandler to be set and configured for each anticipated job on IEndpointRouteBuilder
No
DAPR1610
Dapr.Messaging
Usage
Warning
1.18
Topic registered for both Streaming and Programmatic delivery modes
No
DAPR1611
Dapr.Messaging
Usage
Error
1.18
[DaprTopic] applied to a class that does not implement ITopicHandler or ITopicHandler<T, TResult>
No
DAPR1612
Dapr.Messaging
Compatibility
Warning
1.18
Message type is not registered in a source-generated JsonSerializerContext for Native AOT compatibility
No
DAPR1613
Dapr.Messaging
Usage
Warning
1.18
Programmatic topic subscriptions require app.MapDaprAppCallback() or the unified app.MapDaprMessaging() endpoint mapping
Yes
DAPR1614
Dapr.Messaging
Usage
Warning
1.18
DaprMessagingRegistration is called directly instead of using generated services.AddDaprMessaging()
No
DAPR1615
Dapr.Messaging
Usage
Warning
1.18
Subscriber registration or endpoint mapping is present without matching topic subscribers
No
DAPR1616
Dapr.Messaging
Usage
Warning
1.18
[DaprTopic] opts into a feature without populating required companion properties
No
DAPR1617
Dapr.Messaging
Usage
Warning
1.18
[DaprTopic] sets a property that is ignored for the selected feature or delivery mode
No
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
19 - How to troubleshoot and debug with the Dapr .NET SDK
Tips, tricks, and guides for troubleshooting and debugging with the Dapr .NET SDKs
19.1 - Troubleshoot Pub/Sub with the .NET SDK
Troubleshoot Pub/Sub with the .NET SDK
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
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.
Wait for Dapr to finish starting
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
Start the application as you would normally (dapr run ...).
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.
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 4.
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 handlerendpoints.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")]publicasyncTask<ActionResult>Deposit(...)// Using Pub/Sub routing[Topic("pubsub", "transactions", "event.type == \"withdraw.v2\"", 1)][HttpPost("withdraw")]publicasyncTask<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:
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:
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.
We use cookies to analyze site usage and improve your experience. You can accept or reject analytics cookies.