Orchestrating Workflows with Azure Durable Functions

TQ
Tran Quang
June 11, 2026 · 7 min read
#Azure#Durable Functions#Serverless#.NET

Plain Azure Functions are great for short, stateless work: handle an HTTP request, process a queue message, done. Real business processes are rarely that simple. An order workflow might reserve stock, charge a card, wait for a warehouse confirmation that takes hours, retry a flaky shipping API, and compensate if anything fails. Stitching that together with queues and status tables works, but the workflow logic ends up scattered across handlers and database columns.

Durable Functions lets you write that workflow as ordinary C# code. The framework persists progress, survives restarts, and resumes exactly where it left off. This post covers how it works in the .NET isolated worker model, the core patterns, and the rules you must follow to avoid subtle production bugs.

How Durable Functions works

There are three main function types:

  • Client functions start, query, and signal orchestrations. They are regular triggered functions with a DurableTaskClient injected.
  • Orchestrator functions describe the workflow. They call activities, wait for timers and events, and make decisions.
  • Activity functions do the actual work: I/O, database calls, HTTP requests.

The key mechanism is event sourcing with replay. When an orchestrator awaits an activity, the framework records the call in history and unloads the orchestrator. When the activity completes, the orchestrator runs again from the beginning, but already-completed calls return their recorded results immediately instead of executing again. This is why orchestrator code has strict rules, covered later.

Project setup for the isolated worker

The in-process model is being retired, so new projects should use the isolated worker. Add the Durable Task extension package:

bash
func init OrderWorkflow --worker-runtime dotnet-isolated --target-framework net8.0
cd OrderWorkflow
dotnet add package Microsoft.Azure.Functions.Worker.Extensions.DurableTask
dotnet add package Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore

A minimal Program.cs:

csharp
using Microsoft.Azure.Functions.Worker.Builder;
using Microsoft.Extensions.Hosting;

var builder = FunctionsApplication.CreateBuilder(args);
builder.ConfigureFunctionsWebApplication();
builder.Build().Run();

And give the task hub an explicit name in host.json, which matters when several apps share a storage account or when you deploy side-by-side versions:

json
{
  "version": "2.0",
  "extensions": {
    "durableTask": {
      "hubName": "OrdersHub"
    }
  }
}

A first orchestration

Here is an order workflow with a client, an orchestrator, and activities:

csharp
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.DurableTask;
using Microsoft.DurableTask.Client;

public static class OrderWorkflow
{
    [Function(nameof(StartOrder))]
    public static async Task<HttpResponseData> StartOrder(
        [HttpTrigger(AuthorizationLevel.Function, "post", Route = "orders")] HttpRequestData req,
        [DurableClient] DurableTaskClient client)
    {
        var order = await req.ReadFromJsonAsync<OrderRequest>();
        string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(
            nameof(ProcessOrder), order,
            new StartOrchestrationOptions { InstanceId = $"order-{order!.OrderId}" });

        return await client.CreateCheckStatusResponseAsync(req, instanceId);
    }

    [Function(nameof(ProcessOrder))]
    public static async Task<OrderResult> ProcessOrder(
        [OrchestrationTrigger] TaskOrchestrationContext context)
    {
        var logger = context.CreateReplaySafeLogger(nameof(ProcessOrder));
        var order = context.GetInput<OrderRequest>()!;

        await context.CallActivityAsync(nameof(ReserveStock), order);
        var paymentId = await context.CallActivityAsync<string>(nameof(ChargePayment), order);
        await context.CallActivityAsync(nameof(CreateShipment), order);

        logger.LogInformation("Order {OrderId} completed", order.OrderId);
        return new OrderResult(order.OrderId, paymentId);
    }

    [Function(nameof(ReserveStock))]
    public static Task ReserveStock([ActivityTrigger] OrderRequest order, FunctionContext ctx)
    {
        // Call inventory service
        return Task.CompletedTask;
    }
}

Using a deterministic instance ID like order-{OrderId} gives you a natural deduplication key and makes it easy to look up a workflow by business identifier. CreateCheckStatusResponseAsync returns a 202 with URLs the caller can poll for status.

Core orchestration patterns

Function chaining

The example above is chaining: sequential steps where each depends on the previous one. The code reads top to bottom, and the framework checkpoints after every await.

Fan-out and fan-in

To process items in parallel, start all activity tasks and await them together:

csharp
[Function(nameof(GenerateReports))]
public static async Task<int> GenerateReports([OrchestrationTrigger] TaskOrchestrationContext context)
{
    var customerIds = await context.CallActivityAsync<List<string>>(nameof(GetActiveCustomers));

    var tasks = customerIds
        .Select(id => context.CallActivityAsync<int>(nameof(BuildCustomerReport), id))
        .ToList();

    int[] pageCounts = await Task.WhenAll(tasks);
    return pageCounts.Sum();
}

Each activity is scheduled as a separate work item, so they spread across instances as the app scales out. For very large fan-outs, batch the work or split it into sub-orchestrations with CallSubOrchestratorAsync; a single orchestration with an enormous history becomes slow to replay.

Human interaction with external events and timers

Durable timers and external events make approval workflows simple, including a timeout:

csharp
[Function(nameof(ApproveExpense))]
public static async Task<string> ApproveExpense([OrchestrationTrigger] TaskOrchestrationContext context)
{
    var expense = context.GetInput<Expense>()!;
    await context.CallActivityAsync(nameof(NotifyApprover), expense);

    using var cts = new CancellationTokenSource();
    Task<bool> approval = context.WaitForExternalEvent<bool>("ApprovalDecision");
    Task timeout = context.CreateTimer(context.CurrentUtcDateTime.AddDays(3), cts.Token);

    if (await Task.WhenAny(approval, timeout) == approval)
    {
        cts.Cancel(); // release the timer
        return approval.Result ? "Approved" : "Rejected";
    }

    await context.CallActivityAsync(nameof(EscalateExpense), expense);
    return "Escalated";
}

Another function raises the event, for example when the approver clicks a link:

csharp
await client.RaiseEventAsync(instanceId, "ApprovalDecision", true);

While waiting, the orchestrator is not running and consumes no compute. That is the main economic advantage over a polling loop.

Monitors and eternal orchestrations

For recurring checks, use a loop with a timer and restart the orchestration with context.ContinueAsNew(state) periodically. ContinueAsNew resets the history, which keeps replay cheap for workflows that would otherwise run forever.

Retries and error handling

Activity failures surface in the orchestrator as TaskFailedException. You can add declarative retries per call:

csharp
var retry = TaskOptions.FromRetryPolicy(new RetryPolicy(
    maxNumberOfAttempts: 5,
    firstRetryInterval: TimeSpan.FromSeconds(5),
    backoffCoefficient: 2.0));

try
{
    await context.CallActivityAsync(nameof(CreateShipment), order, retry);
}
catch (TaskFailedException ex)
{
    logger.LogError(ex, "Shipment failed for {OrderId}; compensating", order.OrderId);
    await context.CallActivityAsync(nameof(RefundPayment), order);
    await context.CallActivityAsync(nameof(ReleaseStock), order);
    throw;
}

This is the saga pattern expressed in plain C#: try the forward steps, and on failure run compensating activities in reverse order. Keep activities idempotent, because an activity can execute more than once if a worker crashes after doing the work but before the result is recorded.

Orchestrator code constraints

Because orchestrators replay, their code must be deterministic. Violating this causes errors or, worse, silently wrong behavior.

Avoid in orchestrators Use instead
DateTime.UtcNow context.CurrentUtcDateTime
Guid.NewGuid() context.NewGuid()
Task.Delay / Thread.Sleep context.CreateTimer(...)
HTTP calls, database queries, file I/O An activity function
Reading environment or config that can change Pass values as input, or read in an activity
ILogger directly context.CreateReplaySafeLogger(...)
Random numbers Generate in an activity

Also avoid ConfigureAwait(false) and custom thread scheduling inside orchestrators; await only tasks produced by the context. Put anything non-deterministic in an activity and let the framework record its result.

Versioning running workflows

Changing orchestrator code while instances are in flight is the most common source of production incidents. If you reorder, add, or remove awaited calls, replaying old histories against the new code fails with non-determinism errors.

Safe strategies:

  • Deploy breaking changes under a new orchestrator name and route new instances to it, letting old ones drain.
  • Deploy side-by-side using a different task hub name for the new version.
  • Keep workflows short-lived where possible, so draining is fast.

Changes inside activity implementations are safe, because activities are not replayed.

Storage backends and hosting

Durable Functions persists state through a storage provider. Azure Storage is the default and needs no extra infrastructure. The MSSQL provider suits teams who want state in SQL Server or Azure SQL. The Durable Task Scheduler is a managed backend designed for higher throughput and comes with a built-in monitoring dashboard. Each has different performance and operational trade-offs, so check the current comparison in the docs before choosing.

Durable Functions runs on the Consumption, Flex Consumption, Premium, and Dedicated plans, but plan support can vary by storage provider; verify the combination you want.

Operating Durable Functions in production

  • Query instances by status to find stuck or failed workflows:
csharp
AsyncPageable<OrchestrationMetadata> failed = client.GetAllInstancesAsync(new OrchestrationQuery
{
    Statuses = [OrchestrationRuntimeStatus.Failed],
    CreatedFrom = DateTimeOffset.UtcNow.AddDays(-1)
});
  • Purge old history on a schedule with PurgeAllInstancesAsync so storage does not grow indefinitely.
  • Keep inputs and outputs small. Pass IDs and fetch data in activities rather than passing large payloads through orchestration history.
  • Use Application Insights with correlation so a workflow can be traced across its activities.
  • Terminate failed or stuck instances deliberately, and document which workflows can be safely restarted.

Conclusion

Durable Functions turns long-running, stateful processes into readable C# while the framework handles checkpoints, retries, timers, and waiting for humans. The price is discipline: orchestrators must be deterministic, activities must be idempotent, and code changes must account for instances already in flight. Respect those rules, keep payloads small, and treat purging and monitoring as part of the design, and you get durable workflows without building your own state machine on top of queues and tables.

Found this useful?

Share it with someone who might need it.

Related articles All articles