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 | Client registration |
| Write deterministic orchestration, activities, retries, timers/events, fan-out, children, and continuation | Author workflows |
| Schedule, wait, signal, suspend/resume, terminate, purge, list, inspect history, or rerun | Management operations |
| Customize JSON or use another serializer | Serialization |
| Route activities, children, and detached workflows to another app | Multi-app workflows |
| Share ancestor history with child workflows | History propagation |
| Evolve deployed workflows using patches or versioned names | Versioning |
| Offload large payloads or pass explicit binary references (alpha) | Data references |
| Explore end-to-end examples | Examples |
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.
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.
using Dapr.Workflow;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDaprWorkflow();
var app = builder.Build();
await app.RunAsync();
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:
builder.Services.AddDaprWorkflow(options =>
{
options.RegisterWorkflow<OrderProcessingWorkflow>();
options.RegisterActivity<SquareNumberActivity>();
options.RegisterActivity<int, int>("Double", (_, input) => Task.FromResult(input * 2));
});
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:
<PropertyGroup>
<DaprWorkflowVersioningScanReferences>true</DaprWorkflowVersioningScanReferences>
</PropertyGroup>
<ItemGroup>
<CompilerVisibleProperty Include="DaprWorkflowVersioningScanReferences" />
</ItemGroup>
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:
builder.Services.AddDaprWorkflow(
options => { },
lifetime: ServiceLifetime.Scoped);
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:
app.MapPost("/start", async (DaprWorkflowClient client, Order order) =>
{
var instanceId = await client.ScheduleNewWorkflowAsync(
nameof(OrderProcessingWorkflow), input: order);
return Results.Accepted(value: new { instanceId });
});
In a Generic Host application, resolve it from the host’s services. Use asynchronous disposal for an async scope:
await using var scope = host.Services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<DaprWorkflowClient>();
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.
Change gRPC message size limits
Use the builder returned by AddDaprWorkflow:
builder.Services.AddDaprWorkflow()
.WithGrpcMessageSizeLimits(
maxReceiveMessageSize: 16 * 1024 * 1024,
maxSendMessageSize: 16 * 1024 * 1024);
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.
builder.Services.AddDaprWorkflow(options =>
{
options.HistoryCacheTtl = TimeSpan.FromMinutes(30);
options.HistoryCacheMaxInstances = 10_000;
options.HistoryCacheMaxBytes = 256L * 1024 * 1024;
});
| Option | Default and behavior |
|---|
DisableStatefulHistory | 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:
using Dapr.Workflow;
using Microsoft.Extensions.Logging;
public sealed class SquareNumberActivity(ILogger<SquareNumberActivity> logger) : WorkflowActivity<int, int>
{
public override Task<int> RunAsync(WorkflowActivityContext context, int input)
{
logger.LogInformation("Squaring {Number}; task key {TaskKey}", input, context.TaskExecutionKey);
return Task.FromResult(input * input);
}
}
Activity task execution identifiers
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.
Next steps
2 - Author workflows and activities in .NET
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.
using Dapr.Workflow;
public sealed class GreetingWorkflow : Workflow<string, string>
{
public override async Task<string> RunAsync(WorkflowContext context, string name)
{
context.SetCustomStatus(new { Stage = "Greeting" });
var greeting = await context.CallActivityAsync<string>(nameof(GreetActivity), name);
context.SetCustomStatus(new { Stage = "Done" });
return greeting;
}
}
public sealed class GreetActivity : WorkflowActivity<string, string>
{
public override Task<string> RunAsync(WorkflowActivityContext context, string name)
=> Task.FromResult($"Hello, {name}!");
}
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:
var retry = new WorkflowRetryPolicy(
maxNumberOfAttempts: 3,
firstRetryInterval: TimeSpan.FromSeconds(2),
backoffCoefficient: 2,
maxRetryInterval: TimeSpan.FromSeconds(30),
retryTimeout: TimeSpan.FromMinutes(2));
var greeting = await context.CallActivityAsync<string>(
nameof(GreetActivity), name, new WorkflowTaskOptions(RetryPolicy: retry));
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.
For an event with a durable timeout:
try
{
bool approved = await context.WaitForExternalEventAsync<bool>("Approval", TimeSpan.FromHours(24));
context.SetCustomStatus(new { Approved = approved });
}
catch (TaskCanceledException)
{
context.SetCustomStatus(new { Stage = "Approval expired" });
}
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:
context.SendEvent(targetInstanceId, "Approval", true);
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:
string[] greetings = await context.ProcessInParallelAsync(
names,
name => context.CallActivityAsync<string>(nameof(GreetActivity), name),
maxConcurrency: 3);
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:
var detachedId = await context.ScheduleNewDetachedWorkflowAsync(
workflowName: "GreetingWorkflow",
input: "background",
startTime: new DateTimeOffset(context.CurrentUtcDateTime).AddMinutes(5));
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.
See multi-app workflows for routing and
history propagation for child history sharing.
Continue long-running workflows
Use ContinueAsNew to restart the same instance with new input and truncate its accumulated history:
public sealed class CounterWorkflow : Workflow<int, int>
{
public override async Task<int> RunAsync(WorkflowContext context, int count)
{
await context.CreateTimer(TimeSpan.FromMinutes(1));
context.ContinueAsNew(newInput: count + 1, preserveUnprocessedEvents: true);
return count;
}
}
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.
Next steps
3 - Workflow serialization in the .NET SDK
Configure workflow serialization for the Dapr .NET SDK
Overview
Starting with Dapr .NET SDK v1.17.0, Dapr.Workflow supports pluggable serialization. The SDK continues to use
System.Text.Json by default, but you can now:
- Override the default
System.Text.Json settings. - Register a custom serializer (for example, MessagePack or BSON).
Serialization configuration is entirely client-side and does not require a specific Dapr runtime version.
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.
Default JSON serialization
By default, the .NET SDK uses System.Text.Json with JsonSerializerDefaults.Web (see the
JsonSerializerDefaults.Web reference).
This means:
- Property names are case-insensitive.
- “camelCase” formatting is used for property names.
- Quoted numbers (JSON strings for number properties) are allowed when reading.
This default convention is designed to be compatible with other Dapr language SDKs for multi-app workflows.
Override System.Text.Json defaults
To override the default JSON settings, register the workflow client using the workflow builder so you can provide custom JsonSerializerOptions:
builder.Services
.AddDaprWorkflowBuilder(options =>
{
// Explicit registration is optional.
options.RegisterWorkflow<MyWorkflow>();
options.RegisterActivity<MyActivity>();
})
.WithJsonSerializer(new JsonSerializerOptions { 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:
using Dapr.Workflow.Serialization;
using MessagePack;
public sealed class MessagePackWorkflowSerializer : IWorkflowSerializer
{
private readonly MessagePackSerializerOptions _options;
public MessagePackWorkflowSerializer(MessagePackSerializerOptions options)
{
_options = options;
}
public string Serialize<T>(T value) => Serialize(value, typeof(T));
/// <inheritdoc/>
public string Serialize(object? value, Type? inputType = null)
{
if (value == null)
{
return string.Empty;
}
var targetType = inputType ?? value.GetType();
var bytes = MessagePackSerializer.Serialize(targetType, value, _options);
return Convert.ToBase64String(bytes);
}
/// <inheritdoc/>
public T? Deserialize<T>(string? data)
{
return (T?)Deserialize(data, typeof(T));
}
/// <inheritdoc/>
public object? Deserialize(string? data, Type returnType)
{
if (returnType == null)
{
throw new ArgumentNullException(nameof(returnType));
}
if (string.IsNullOrEmpty(data))
{
return default;
}
try
{
var bytes = Convert.FromBase64String(data);
return MessagePackSerializer.Deserialize(returnType, bytes, _options);
}
catch (FormatException ex)
{
throw new InvalidOperationException(
"Failed to decode Base64 data. The input may not be valid MessagePack-serialized data.",
ex);
}
catch (MessagePackSerializationException ex)
{
throw new InvalidOperationException(
$"Failed to deserialize data to type {returnType.FullName}.",
ex);
}
}
}
Register a custom serializer
Register the serializer with the workflow builder:
builder.Services
.AddDaprWorkflowBuilder(options =>
{
// Explicit registration is optional.
options.RegisterWorkflow<MyWorkflow>();
options.RegisterActivity<MyActivity>();
})
.WithSerializer(new MessagePackWorkflowSerializer(MessagePackSerializerOptions.Standard));
If you need DI-provided configuration, use the overload that receives an IServiceProvider:
builder.Services
.AddDaprWorkflowBuilder(options =>
{
// Explicit registration is optional.
options.RegisterWorkflow<MyWorkflow>();
options.RegisterActivity<MyActivity>();
})
.WithSerializer(serviceProvider =>
{
var options = serviceProvider
.GetRequiredService<IOptions<MessagePackSerializerOptions>>()
.Value;
return new MessagePackWorkflowSerializer(options);
});
Next steps
4 - Workflow data references in the .NET SDK
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:
Choose how to handle payloads
| Approach | Application types | Behavior | Best for |
|---|
| Inline (default) | DocumentPayload | 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. |
- 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.
For a disposable local experiment, add this component to the sidecar’s resources directory:
apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
name: workflow-payloads
spec:
type: binarystore.in-memory
version: v1
metadata: []
In-memory storage is for experiments only
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:
using Dapr.Workflow;
using Dapr.Workflow.Payloads;
using Microsoft.Extensions.DependencyInjection;
builder.Services.AddDaprWorkflow().WithBinaryStorePayloads(options =>
{
options.StoreName = "workflow-payloads";
options.WritePreference = PayloadWritePreference.PreferReference;
options.OffloadThreshold = 64 * 1024;
});
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:
using Dapr.Workflow;
public sealed record DocumentPayload(string Content);
public sealed class DocumentWorkflow : Workflow<DocumentPayload, int>
{
public override Task<int> RunAsync(WorkflowContext context, DocumentPayload input)
=> context.CallActivityAsync<int>(nameof(MeasureDocumentActivity), input);
}
public sealed class MeasureDocumentActivity : WorkflowActivity<DocumentPayload, int>
{
public override Task<int> RunAsync(WorkflowActivityContext context, DocumentPayload input)
=> Task.FromResult(input.Content.Length);
}
From client code, resolve DaprWorkflowClient from the application’s service provider and schedule as usual:
var client = services.GetRequiredService<DaprWorkflowClient>();
var document = new DocumentPayload(new string('a', 128 * 1024));
var instanceId = await client.ScheduleNewWorkflowAsync(
nameof(DocumentWorkflow), input: document);
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.)
using Dapr.Workflow;
using Dapr.Workflow.Payloads;
public sealed class ReferenceWorkflow : Workflow<PayloadReference<DocumentPayload>, int>
{
public override Task<int> RunAsync(
WorkflowContext context, PayloadReference<DocumentPayload> input)
=> context.CallActivityAsync<int>(nameof(MeasureReferenceActivity), input);
}
public sealed class MeasureReferenceActivity(IWorkflowPayloadResolver resolver)
: WorkflowActivity<PayloadReference<DocumentPayload>, int>
{
public override async Task<int> RunAsync(
WorkflowActivityContext context, PayloadReference<DocumentPayload> input)
{
var document = await resolver.ResolveAsync<DocumentPayload>(input);
return document.Content.Length;
}
}
In client code, store the document before scheduling using the same instance ID for both calls:
var client = services.GetRequiredService<DaprWorkflowClient>();
var resolver = services.GetRequiredService<IWorkflowPayloadResolver>();
var instanceId = $"document-{Guid.NewGuid():N}";
var document = new DocumentPayload(new string('a', 128 * 1024));
var reference = await resolver.StoreAsync(document, instanceId);
await client.ScheduleNewWorkflowAsync(
nameof(ReferenceWorkflow), instanceId, reference);
Key rules for explicit references:
- 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 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:
options.OffloadSites = PayloadKind.WorkflowInput | PayloadKind.ActivityInput;
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
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):
var length = await context.CallActivityAsync<int>(
"MeasureDocumentActivity",
reference,
new WorkflowTaskOptions(TargetAppId: "document-service"));
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).
// Activity call
var length = await context.CallActivityAsync<int>(
"MeasureReferenceActivity",
reference,
new WorkflowTaskOptions(
TargetAppId: "document-service",
AllowPayloadReferences: true));
// Child workflow call
var childLength = await context.CallChildWorkflowAsync<int>(
"ReferenceWorkflow",
reference,
new ChildWorkflowTaskOptions(
TargetAppId: "document-service",
AllowPayloadReferences: true));
// Detached workflow call (uses inline default unless AllowPayloadReferences: true is set)
var detachedId = await context.ScheduleNewDetachedWorkflowAsync(
workflowName: "DocumentWorkflow",
instanceId: null,
input: reference,
startTime: null,
options: new WorkflowTaskOptions(TargetAppId: "document-service"));
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:builder.Services.AddDaprWorkflow().WithBinaryStorePayloads(options =>
{
options.StoreName = "workflow-payloads";
options.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
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):
var state = await client.WaitForWorkflowCompletionAsync(
instanceId, getInputsAndOutputs: false);
if (state.RuntimeStatus != WorkflowRuntimeStatus.Completed)
{
throw new InvalidOperationException($"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 after
WithBinaryStorePayloads 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.
5 - Multi-application workflows in the .NET SDK
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:
- Dapr runtime v1.16.0+
- Dapr .NET SDK v1.17.0+
Conceptual guidance and constraints are covered in
Multi Application Workflows.
Requirements
Multi-application workflow calls require:
- The target app ID must exist and must register the activity or workflow you invoke.
- All participating app IDs must be in the same namespace.
- All participating app IDs must use the same workflow (actor) state store.
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:
public sealed class BusinessWorkflow : Workflow<string, string>
{
public override async Task<string> RunAsync(WorkflowContext context, string input)
{
var options = new WorkflowTaskOptions { TargetAppId = "App2" };
var output = await context.CallActivityAsync<string>(nameof(ActivityA), input, options);
return output;
}
}
The parent workflow continues to orchestrate locally and receives the activity result.
Call a child workflow in another application
Set TargetAppId on ChildWorkflowTaskOptions when calling a child workflow to execute it in another app:
public sealed class BusinessWorkflow : Workflow<string, string>
{
public override async Task<string> RunAsync(WorkflowContext context, string input)
{
var options = new ChildWorkflowTaskOptions { TargetAppId = "App2" };
var output = await context.CallChildWorkflowAsync<string>(nameof(Workflow2), input, options);
return output;
}
}
Note
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:
var detachedId = await context.ScheduleNewDetachedWorkflowAsync(
workflowName: "DocumentWorkflow",
instanceId: null,
input: document,
startTime: null,
options: new WorkflowTaskOptions(TargetAppId: "document-service"));
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.
Next steps
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
Conceptual guidance is covered in Workflow history propagation.
Note
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:
public sealed class MerchantCheckoutWorkflow : Workflow<Order, CheckoutResult>
{
public override async Task<CheckoutResult> RunAsync(WorkflowContext context, Order order)
{
// Activity without propagation — default behavior, no opt-in
await context.CallActivityAsync(nameof(ValidateMerchantActivity), order.MerchantId);
// Child workflow with full lineage propagation
var options = new ChildWorkflowTaskOptions()
.WithHistoryPropagation(HistoryPropagationScope.Lineage);
var result = await context.CallChildWorkflowAsync<PaymentResult>(
nameof(ProcessPaymentWorkflow), order, options);
return new CheckoutResult(result);
}
}
Calls that do not specify a propagation scope receive no history — other calls in the same workflow are unaffected by opt-ins.
Propagate history to an activity
The same WithHistoryPropagation extension is available on WorkflowTaskOptions for activity calls:
var options = new WorkflowTaskOptions()
.WithHistoryPropagation(HistoryPropagationScope.OwnHistory);
var auditResult = await context.CallActivityAsync<AuditResult>(
nameof(WriteAuditTrailActivity), payload, options);
Activity reader limitation
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.
public sealed class ProcessPaymentWorkflow : Workflow<Order, PaymentResult>
{
public override async Task<PaymentResult> RunAsync(WorkflowContext context, Order order)
{
var history = context.GetPropagatedHistory();
if (history != null)
{
foreach (var evt in history.Events)
{
// evt.Name, evt.InstanceId, evt.AppId
// evt.Activities — activity results for this ancestor
// evt.Workflows — child workflow results for this ancestor
}
}
return await context.CallActivityAsync<PaymentResult>(
nameof(ChargeCardActivity), order);
}
}
PropagatedHistory type
GetPropagatedHistory() returns a PropagatedHistory object (or null). Its Events property is an ordered list of PropagatedHistoryEvent values — one per ancestor workflow, in execution order (oldest ancestor first, immediate parent last).
Each PropagatedHistoryEvent represents a single ancestor workflow’s contribution to the propagated history:
| 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
var history = context.GetPropagatedHistory();
if (history != null)
{
// By app ID — useful in multi-app workflows
var fromOrderApp = history.GetByAppId("order-app");
// By workflow instance ID
var fromSpecificRun = history.GetByInstanceId("checkout-abc123");
// By workflow name — returns all matches (e.g. recursion or ContinueAsNew)
var checkoutEvents = history.GetEventsByWorkflowName(nameof(MerchantCheckoutWorkflow));
// TryGet for a single match — avoids null ambiguity
if (history.TryGetLastWorkflowEventByName(nameof(MerchantCheckoutWorkflow), out var parentEvent))
{
// Inspect the parent's activities
var failedActivities = parentEvent.Activities
.Where(a => a.Status == PropagatedHistoryStatus.Failed)
.ToList();
// Or look up a specific activity by name
if (parentEvent.TryGetLastActivityByName(nameof(ValidateMerchantActivity), out var validation))
{
// validation.Status, validation.Output, validation.FailureDetails
}
}
}
Security considerations
By default, Dapr uses mutual TLS (mTLS) between sidecars for all cross-app communication, providing transport-layer protection for propagated history in multi-app workflow scenarios.
For stronger guarantees in production, enable WorkflowHistorySigning. This feature uses SPIFFE identity to cryptographically sign each history chunk, so the receiving workflow can verify the integrity and origin of the propagated history. Without signing enabled, Dapr emits a warning that propagated chunks lack cryptographic verification.
See Workflow history propagation for details on configuring WorkflowHistorySigning.
Next steps
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.
var instanceId = await client.ScheduleNewWorkflowAsync(
name: "OrderProcessingWorkflow",
instanceId: null,
input: new { Product = "Paperclips", Quantity = 1000 },
startTime: DateTimeOffset.UtcNow.AddMinutes(5),
cancellation: cancellationToken);
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:
var state = await client.GetWorkflowStateAsync(
instanceId, getInputsAndOutputs: true, cancellation: cancellationToken);
if (!state.Exists)
{
throw new InvalidOperationException($"Workflow {instanceId} does not exist.");
}
var status = 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. |
ReadInputAs<T>(), ReadOutputAs<T>(), ReadCustomStatusAs<T>() | 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.
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromMinutes(10));
var finalState = await client.WaitForWorkflowCompletionAsync(
instanceId, getInputsAndOutputs: true, cancellation: timeout.Token);
if (finalState.RuntimeStatus != WorkflowRuntimeStatus.Completed)
{
throw new InvalidOperationException(
$"Workflow ended with {finalState.RuntimeStatus}: {finalState.FailureDetails?.ErrorMessage}");
}
var result = 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.
Raise an event to a running workflow instance
await client.RaiseEventAsync(instanceId, "Approval", true, cancellation: cancellationToken);
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.
Suspend a running workflow instance
await client.SuspendWorkflowAsync(instanceId, reason: "Manual review", cancellation: cancellationToken);
Suspension pauses workflow progression until resumed; it is not cancellation of an activity’s external side effects.
Resume a suspended workflow instance
await client.ResumeWorkflowAsync(instanceId, reason: "Review complete", cancellation: cancellationToken);
Terminate a workflow instance
await client.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:
bool purged = await client.PurgeInstanceAsync(instanceId, cancellation: cancellationToken);
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.
ListInstanceIdsAsync returns a WorkflowInstancePage containing InstanceIds and an optional
ContinuationToken. Pass the token back unchanged to obtain the next page:
string? continuationToken = null;
do
{
var page = await client.ListInstanceIdsAsync(
continuationToken, pageSize: 100, cancellation: cancellationToken);
foreach (var id in page.InstanceIds)
{
Console.WriteLine(id);
}
continuationToken = page.ContinuationToken;
}
while (!string.IsNullOrEmpty(continuationToken));
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
var events = await client.GetInstanceHistoryAsync(instanceId, cancellation: cancellationToken);
foreach (var historyEvent in events)
{
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.
using Dapr.Workflow.Client;
// Pick a rerun point: the first scheduled activity in the source history.
var history = await client.GetInstanceHistoryAsync(instanceId, cancellationToken);
var scheduledEvent = history.First(e => e.EventType == WorkflowHistoryEventType.TaskScheduled);
var rerunId = await client.RerunWorkflowFromEventAsync(
sourceInstanceId: instanceId,
eventId: (uint)scheduledEvent.EventId,
options: new RerunWorkflowFromEventOptions
{
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.
Next steps
8 - Workflow versioning in the .NET SDK
Learn how to use patch-based and name-based workflow versioning in the Dapr .NET SDK
Overview
Dapr Workflow versioning lets you evolve workflows without breaking deterministic execution for in-flight instances. The .NET SDK supports two approaches:
- Patch-based versioning: introduce conditional branches guarded by
context.IsPatched("patch-name"). - Name-based versioning: create a new workflow type name and let a versioning strategy select the newest version.
Use patch-based versioning for small, in-place changes. Use name-based versioning for larger refactors where you want a clean new workflow type.
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:
public override async Task<bool> RunAsync(WorkflowContext context, OrderPayload input)
{
await context.CallActivityAsync(nameof(ReserveInventoryActivity), input);
if (context.IsPatched("v2"))
{
await context.CallActivityAsync(nameof(ChargePaymentActivityV2), input);
}
else
{
await context.CallActivityAsync(nameof(ChargePaymentActivity), input);
}
return true;
}
Patch rules
- 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)MyWorkflowV2MyWorkflowV10
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.
1. Install the versioning package
If you haven’t already added it, you need to add the Dapr.Workflow package to your project. No additional packages are required.
2. Register workflow versioning
Add versioning to DI during startup:
using Dapr.Workflow.Versioning;
builder.Services.AddDaprWorkflowVersioning();
3. Choose a strategy (optional)
You can select a strategy by registering it in DI after calling AddDaprWorkflowVersioning:
builder.Services.UseDefaultWorkflowStrategy<NumericVersionStrategy>("workflow-versioning-options");
The optional string key is used to locate strategy options.
Register strategy options using the same key:
builder.Services.ConfigureStrategyOptions<NumericVersionStrategyOptions>("workflow-versioning-options", o =>
{
o.SuffixPrefix = "V";
});
Note
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:
builder.Services.AddDaprWorkflow(w =>
{
w.RegisterActivity<SendEmailActivity>();
});
Once configured, named workflow versioning is applied automatically at runtime.
Cross-assembly workflow discovery
By default, the workflow versioning source generator only scans the executing assembly. If you keep workflows in a separate referenced assembly, those implementations are not discovered unless you opt in to reference scanning.
Reference scanning is disabled by default because it can increase build times (the generator must inspect all referenced assemblies for workflow and activity implementations). To enable it, add the following to the executing application’s .csproj file:
<PropertyGroup>
<DaprWorkflowVersioningScanReferences>true</DaprWorkflowVersioningScanReferences>
</PropertyGroup>
<ItemGroup>
<CompilerVisibleProperty Include="DaprWorkflowVersioningScanReferences" />
</ItemGroup>
When enabled, the source generator adds any discovered workflow and activity types from all referenced assemblies and registers them automatically. This applies regardless of whether you used named workflow versioning - it is a general-purpose discovery mechanism.
Override name and version
If you need to override the canonical name or version detected from the workflow type, apply the [WorkflowVersion]
attribute to the class implementing your workflow and specify values explicitly:
using Dapr.Workflow;
using Dapr.Workflow.Versioning;
[WorkflowVersion(
CanonicalName = "OrderWorkflow",
Version = "2",
StrategyType = typeof(NumericVersionStrategy))]
public sealed class RevisedOrderWorkflow : Workflow<string, string>
{
public override Task<string> RunAsync(WorkflowContext context, string input)
=> 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.
Example project
An end-to-end example is available in the Dapr .NET SDK repository at examples/Workflow/WorkflowVersioning.
Next steps
9 - .NET Workflow Examples
Explore Dapr Workflow code examples on GitHub
Workflow tutorials in the Dapr Quickstarts repository
The Dapr Quickstarts repository on GitHub includes many workflow tutorials that showcase the various workflow patterns and how to use the workflow management operations. You can find these tutorials in the quickstarts/tutorials/workflow/csharp folder.
Workflow examples in the .NET SDK repository
The Dapr .NET SDK repository on GitHub contains several examples demonstrating how to use Dapr Workflows with .NET. You can find these examples in the examples/Workflow folder.
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.
Next steps