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

Return to the regular view of this page.

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
TaskGuide
Register the worker/client, discover types, and configure transport or history cachingClient registration
Write deterministic orchestration, activities, retries, timers/events, fan-out, children, and continuationAuthor workflows
Schedule, wait, signal, suspend/resume, terminate, purge, list, inspect history, or rerunManagement operations
Customize JSON or use another serializerSerialization
Route activities, children, and detached workflows to another appMulti-app workflows
Share ancestor history with child workflowsHistory propagation
Evolve deployed workflows using patches or versioned namesVersioning
Offload large payloads or pass explicit binary references (alpha)Data references
Explore end-to-end examplesExamples

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;
});
OptionDefault and behavior
DisableStatefulHistoryfalse; set true to always receive full history and ignore cache settings.
HistoryCacheTtlnull uses a one-hour sliding TTL. Explicit values must be positive.
HistoryCacheMaxInstances0 uses the built-in limit of 100,000 instances per work-item stream.
HistoryCacheMaxBytes0 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:

NeedWorkflow API
Current timecontext.CurrentUtcDateTime, not DateTime.UtcNow or DateTime.Now.
Unique identifiercontext.NewGuid(), not Guid.NewGuid() or random values.
Delaycontext.CreateTimer(...), not Task.Delay or thread sleeps.
I/O, remote APIs, mutable configuration, or nondeterministic computationPut it in an activity and call CallActivityAsync.
Loggingcontext.CreateReplaySafeLogger<T>() to suppress replay duplicates.
Progress visible to clientscontext.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.

Compatibility and breaking changes

Default JSON serialization

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

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

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

Override System.Text.Json defaults

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

builder.Services
    .AddDaprWorkflowBuilder(options =>
    {
        // Explicit registration is 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.

Use this guide in the order that fits your application:

Choose how to handle payloads

ApproachApplication typesBehaviorBest for
Inline (default)DocumentPayloadFull serialized values stay in history. No binary-store registration or access.Small payloads that fit comfortably in workflow history.
Automatic offloading (PreferReference)DocumentPayloadEligible 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 referencesPayloadReference<DocumentPayload>The workflow can forward a reference without downloading the document. An activity or client explicitly resolves it to an object or opens a stream.Workflows that coordinate steps without inspecting the payload themselves (deferring data access to the specific activities that need it), especially in single-app workflows or multi-app topologies where all participating applications use .NET and can be updated to support binary-store payloads.

Performance and memory tradeoffs

  • Both offloading modes shrink workflow history traffic: in the SDK benchmark (a 2 MB document across nine activities), both modes reduced workflow protobuf traffic by ~99.9%.
  • Automatic offloading does not eliminate repeated materialization: because the workflow signature still takes DocumentPayload, the SDK downloads and deserializes the payload on every replay. Automatic offloading allocated 1.39x as many managed bytes as inline processing in this benchmark. Enable automatic offloading primarily when you are migrating toward explicit data references or when you need cross-app compatibility with another .NET application that already uses data references, even if this application only uses automatic offloading for now.
  • Explicit references defer materialization: passing PayloadReference<T> avoids downloading or deserializing the payload until an activity explicitly resolves or streams it, reducing both execution time and managed allocations. This is the best fit when you do not use multi-app workflows, or when all participating applications use .NET and can be updated to support binary-store payloads.

How references work

Offloading happens after serialization, based on the UTF-8 byte count of the serialized value. The reference envelope contains the store name, key, size, SHA-256 hash, and format version. Stored keys are content-addressed and scoped to a workflow instance. Existing references pass through under Preserve and PreferReference, except when a cross-app input requires inlining. PreferInline resolves outgoing references instead.

Set up payload support

Prerequisites

  • Dapr.Workflow 1.19.0 or later. Payload support is included in Dapr.Workflow.
  • Dapr runtime 1.19.0 or later.
  • A configured binary-store component, in addition to the workflow’s usual actor state store, placement, and scheduler.
  • Payload support registered on every client and worker that writes or reads these workflows, with access to the referenced component and compatible serialization settings.

The SDK manages the references; the workflow runtime protocol does not need native data-reference support. Adding a component alone does not enable offloading.

Configure a binary-store component

For a disposable local experiment, add this component to the sidecar’s resources directory:

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: workflow-payloads
spec:
  type: binarystore.in-memory
  version: v1
  metadata: []

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.

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 offloading and read limits

Configure BinaryStorePayloadOptions in WithBinaryStorePayloads(...):

OptionDefaultPurpose
StoreNameRequiredBinary-store component name.
KeyPrefix"workflow-payloads"Prefix for instance-scoped, content-addressed keys.
WritePreferencePayloadWritePreference.PreserveChoose Preserve, PreferReference, or PreferInline for future writes, including events. Cross-app activity and workflow call inputs require inline data unless explicitly allowed per call.
OffloadThreshold64 * 1024 bytesUnder PreferReference, offload serialized values at or above this size. null offloads every non-empty payload at enabled boundaries; numeric values must be positive.
OffloadSitesPayloadKind.AllSelect which boundaries can automatically create references under PreferReference. Does not restrict inlining or reads.
MaxInMemoryRehydrationSize16 * 1024 * 1024 bytesMaximum 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.
VerifyIntegrityOnReadtrueVerify SHA-256 for buffered reads. Does not verify streams returned by OpenAsync.
ResolutionCacheSize128Maximum resolved entries cached by the workflow context resolver per execution. 0 disables this cache.
SkipWriteIfExiststrueTreat 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:

  1. Ordinary T inputs and outputs to throw PayloadRehydrationLimitException during materialization.
  2. Cross-app boundary inlining to fail before the call is sent.
  3. PreferInline conversion of existing references to fail.
  4. 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:
    1. Identify the largest referenced payloads used by active workflow instances.
    2. Change large-payload consumers to accept PayloadReference<T> and stream content via OpenAsync.
    3. Version workflow and activity contracts when changing ordinary T inputs to PayloadReference<T> inputs.
    4. Keep old workflow versions and their previous limit available until existing instances complete or are retired.
    5. 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.

PreferenceOrdinary outgoing valueOutgoing reference
Preserve (default)Serialize inline; no automatic upload.Keep the reference.
PreferReferenceUpload if the serialized value meets OffloadThreshold at an enabled OffloadSites boundary. Otherwise keep inline.Keep the reference; do not upload it again.
PreferInlineSerialize 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 payloadReceiving contractBehavior
Inline dataOrdinary TDeserialize normally.
Reference envelopeOrdinary TResolve and deserialize using registered payload support.
Reference envelopePayloadReference<T>Pass the reference through without automatically materializing it.
Inline dataPayloadReference<T>Report a contract mismatch; do not upload data to manufacture a reference.

Resolving a reference for application code does not rewrite workflow history or mutate retained wire metadata. Received data also does not establish an outbound preference or prove the sender can consume reference-valued responses. Unsupported envelope versions, missing data, integrity failures, and buffering-limit violations surface as errors rather than falling back to ordinary application data.

Cross-app and external boundaries

Cross-app input handling

An automatic boundary switch makes binary-store payloads compatible with multi-app workflow calls by default for top-level inputs. When an activity, child workflow, or detached workflow targets another app (TargetAppId differs from the current app), the SDK marks the boundary as requiring inline data, overriding a host’s PreferReference setting:

  • An ordinary input is kept inline.
  • Any top-level PayloadReference<T> is resolved by the source app (after replay filters out already-recorded calls, so storage I/O does not run directly inside the deterministic workflow body) and its original serialized payload is sent inline before Dapr routes the call, without another deserialize/serialize cycle.

Key scope of the cross-app boundary switch:

  • Top-level cross-app inputs only: Nested references inside DTO properties, return values/outputs, and propagated history are not automatically converted. Because those paths can still carry references, every .NET application participating in a multi-app workflow should register WithBinaryStorePayloads(...) (and never unregister it once enabled) to preserve compatibility.
  • Source app read access required: The source app must have payload support registered and access to the referenced object in the binary store.
  • Transport limits still apply: Normal Dapr, gRPC, scheduler, and state-store message-size limits apply after the payload is materialized inline.
  • Per-call opt-out: Set AllowPayloadReferences: true when both apps support references and share access to the underlying binary store.

Configure the source app ID correctly: if it is unavailable, any non-empty TargetAppId is treated as cross-app and inlined by default. Explicit same-app targets follow the normal host preference.

Use the inline default

For a top-level cross-app input, the sender resolves reference and sends ordinary T data inline (the sender still needs WithBinaryStorePayloads(...) and storage access to resolve reference; if the destination is another .NET application, register WithBinaryStorePayloads(...) there as well to preserve compatibility across return values and propagated history):

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:

  1. 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.
  2. The destination registers WithBinaryStorePayloads(...) in .NET, or a payload resolver in JavaScript (upgrading the SDK alone is not enough).
  3. 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).
  4. 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 pathOutgoing policy
context.SendEventUses the workflow host’s WritePreference. The target instance is in the current app; local events are not forced inline.
Client RaiseEventAsyncUses 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.

ApproachCompatibility with existing workflows
InlineUnchanged by default. AddDaprWorkflow(...) alone does not enable payload offloading.
Automatic offloadingSafe 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 referencesChanging 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):

  1. 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.
  2. 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

Reading workflow metadata can download payloads

Client methods that fetch inputs and outputs attempt to resolve references before returning, while ReadInputAs<T>, ReadOutputAs<T>, and ReadCustomStatusAs<T> remain synchronous:

  • state.ReadOutputAs<DocumentPayload>() returns the deserialized document.
  • state.ReadOutputAs<PayloadReference<DocumentPayload>>() exposes the retained reference (if the recorded value was offloaded), though requesting the reference after fetching metadata does not undo the download already performed by the fetch.
  • If a payload exceeds MaxInMemoryRehydrationSize during metadata fetch, the state retains the reference so you can still read PayloadReference<T> and stream it via OpenAsync, whereas calling ReadOutputAs<T>() throws PayloadRehydrationLimitException. Other read errors (missing objects, integrity failures) fail the metadata fetch immediately.

When you only need completion status, avoid downloading inputs and outputs (and keep custom status small, as it is resolved unconditionally):

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:

  1. 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>().
  2. 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.

CaseWorkflow inputStorage and materialization
InlineBenchmarkDocumentNo payload registration. Full document in workflow history; deserialize at each workflow execution.
AutomaticBenchmarkDocumentData in the binary store, references in history. Automatically retrieve and deserialize for each execution and the processing activity.
ReferencesPayloadReference<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:

ModeMedian total msMedian execution msMin..max total msWorkflow protobuf bytesBinary download bytesDocument deserializationsAllocated bytesTotal speedup
Inline302.9302.9269.0..370.212,010,50501174,661,4481.00x
Automatic224.9202.9175.2..769.212,25422,000,00011103,614,8801.35x
References122.7113.4109.6..204.612,2882,000,000114,869,5442.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.

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;
    }
}

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.

Propagation scopes

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

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

Propagate history to a child workflow

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

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

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

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

        return new CheckoutResult(result);
    }
}

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

Propagate history to an activity

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

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

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

Read propagated history

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

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

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

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

PropagatedHistory type

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

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

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

PropagatedHistoryActivityResult type

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

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

PropagatedHistoryWorkflowResult type

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

MemberTypeDescription
NamestringThe scheduled name of the child workflow
StatusPropagatedHistoryStatusLifecycle status — Pending, Completed, or Failed
Outputstring?Serialized output payload, or null when the workflow has not completed
FailureDetailsWorkflowTaskFailureDetails?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:

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

Query propagated history

PropagatedHistory query methods

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

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

PropagatedHistoryEvent query methods

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

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

Example

var history = context.GetPropagatedHistory();

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

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

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

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

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

Security considerations

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

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

See Workflow history propagation for details on configuring WorkflowHistorySigning.

Next steps

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;
MemberMeaning
Exists, WorkflowNameWhether an instance was found and its workflow name.
RuntimeStatusCurrent WorkflowRuntimeStatus, including pending, running, suspended, terminal, or unknown state.
IsWorkflowRunningTrue only for Running, not for pending or suspended instances.
IsWorkflowCompletedTrue for any terminal state: completed, failed, or terminated. It does not imply success.
CreatedAt, LastUpdatedAtUTC timestamps exposed as DateTimeOffset.
FailureDetailsFailure 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.

List instance IDs with pagination

ListInstanceIdsAsync returns a WorkflowInstancePage containing InstanceIds and an optional ContinuationToken. Pass the token back unchanged to obtain the next page:

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.

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.

Name-based versioning

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

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

Default naming behavior

By default, name-based versioning uses NumericVersionStrategy with the case-sensitive prefix V:

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

Higher numeric values are newer (MyWorkflowV10 is newer than MyWorkflowV2). A raw suffix such as MyWorkflow2 requires NumericVersionStrategyOptions.SuffixPrefix = ""; it is not accepted by the default configuration. The SDK also includes Date and SemVer strategies and supports custom strategies/selectors.

Built-in strategies and options

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

  • DateVersionStrategy: Derives a date-based version from a trailing suffix (for example, MyWorkflow20220611). Options include:
    • Date format: Uses standard C# date formatting rules; defaults to yyyyMMdd.
    • Default version: Used when no suffix is provided; defaults to 0.
    • Prefix: Optional prefix to match before the date suffix, with optional case-sensitivity.
  • SemVerVersionStrategy: Parses SemVer versions, including explicit [WorkflowVersion(Version = "1.2.3")] metadata. Use the attribute for dotted versions, prerelease identifiers, and build metadata that cannot appear in a C# class name. Options include:
    • Prefix: Optional prefix to match before the SemVer suffix, with optional case-sensitivity.
    • Prerelease/build support: Can parse prerelease annotations and build metadata.
    • Default version: Optional default when no suffix is provided, if configured to allow missing suffixes.
  • NumericVersionStrategy: Derives a numeric version from a trailing suffix (for example, MyWorkflow42 or MyWorkflowV42). Options include:
    • Prefix: Optional prefix to match before the numeric suffix, with optional case-sensitivity.
    • Zero-padding width: Optional width to allow fixed-width numbers with leading zeroes.
    • Default version: Used when no suffix is provided.

Configure name-based versioning

1. Install the versioning package

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

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.

4. Configure strategy options (optional)

Register strategy options using the same key:

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

5. Register workflows and activities

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

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

builder.Services.AddDaprWorkflow();

Explicit registrations remain supported if you prefer them:

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

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

Cross-assembly workflow discovery

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

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

<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