Skip to content

Durable Functions: Advanced Patterns

This recipe covers advanced Durable Functions patterns for .NET (isolated worker) beyond the basic chaining and fan-out/fan-in flows: sub-orchestrations, eternal orchestrations, activity retries, and safe versioning. For the fundamentals, see Durable Orchestration.

Architecture

flowchart TD
    PARENT[Parent orchestrator] -->|CallSubOrchestratorAsync| SUB1[Sub-orchestrator A]
    PARENT -->|CallSubOrchestratorAsync| SUB2[Sub-orchestrator B]
    SUB1 --> ACT1[Activities]
    SUB2 --> ACT2[Activities]
    SUB1 --> PARENT
    SUB2 --> PARENT

Sub-Orchestrations

Break a large workflow into reusable orchestrators. A parent calls a sub-orchestrator with CallSubOrchestratorAsync, and can fan out over several the same way it fans out over activities.

[Function("ParentOrchestrator")]
public static async Task<int> ParentOrchestrator(
    [OrchestrationTrigger] TaskOrchestrationContext context)
{
    string[] regions = context.GetInput<string[]>() ?? Array.Empty<string>();

    // Fan out over sub-orchestrations, one per region.
    var tasks = regions
        .Select(region => context.CallSubOrchestratorAsync<string>("ProcessRegion", region))
        .ToList();

    string[] results = await Task.WhenAll(tasks);
    return results.Length;
}

[Function("ProcessRegion")]
public static async Task<string> ProcessRegion(
    [OrchestrationTrigger] TaskOrchestrationContext context)
{
    string region = context.GetInput<string>()!;
    string validated = await context.CallActivityAsync<string>("ValidateRegion", region);
    return await context.CallActivityAsync<string>("LoadRegion", validated);
}

Eternal Orchestrations

For a workflow that runs indefinitely (aggregators, periodic jobs), do not use an unbounded loop — the history would grow forever. Call context.ContinueAsNew to restart the orchestration with fresh state and a clean history.

[Function("PeriodicCleanup")]
public static async Task PeriodicCleanup(
    [OrchestrationTrigger] TaskOrchestrationContext context)
{
    var state = context.GetInput<CleanupState>() ?? new CleanupState();

    await context.CallActivityAsync("RunCleanup", state);
    state.Runs += 1;

    // Durable sleep, then restart with new state and empty history.
    DateTime nextRun = context.CurrentUtcDateTime.AddHours(1);
    await context.CreateTimer(nextRun, CancellationToken.None);
    context.ContinueAsNew(state);
}

Activity Retries

Wrap flaky activities with a retry policy instead of hand-coding retry loops. The orchestration replays cleanly because retries are recorded in history.

[Function("ResilientOrchestrator")]
public static async Task<string> ResilientOrchestrator(
    [OrchestrationTrigger] TaskOrchestrationContext context)
{
    var order = context.GetInput<Order>()!;

    var options = TaskOptions.FromRetryPolicy(new RetryPolicy(
        maxNumberOfAttempts: 3,
        firstRetryInterval: TimeSpan.FromSeconds(5)));

    return await context.CallActivityAsync<string>("ChargeCustomer", order, options);
}
Element Explanation
CallSubOrchestratorAsync Invokes another orchestrator as a child; compose and fan out like activities.
ContinueAsNew Restarts the orchestration with new input and a trimmed history for eternal loops.
TaskOptions.FromRetryPolicy Declarative retry policy passed to CallActivityAsync.

Versioning

Orchestrations replay from history, so changing an orchestrator's code while instances are in flight can break replay (non-determinism). Safe strategies:

  • Deploy side by side: give the changed orchestrator a new name and route new instances to it, letting existing instances drain on the old version.
  • Do not reorder or remove existing activity calls in a deployed orchestrator.
  • Terminate and restart in-flight instances if a breaking change is unavoidable.

Determinism still applies

Advanced patterns do not relax the determinism rule. Never call DateTime.Now, generate random values, or do direct I/O inside an orchestrator — use activities and context.CurrentUtcDateTime.

See Also

Sources