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

Last modified October 22, 2026: .NET review for 1.19 (69db68e36)