Durable Functions: Orchestrating Complex Workflows in Azure

How Durable Functions handles long-running processes, fan-out/fan-in patterns and human approval flows — with working C# examples.

Jean-Pierre Broeders

Freelance DevOps Engineer

March 3, 20265 min. read

Durable Functions: Orchestrating Complex Workflows in Azure

Regular Azure Functions are stateless. Trigger goes in, result comes out, done. But what happens when a process has ten steps, some take hours, and halfway through someone needs to click a button? Plain functions fall apart.

Durable Functions fixes that. It's an extension on top of Azure Functions that adds state management, retry logic and orchestration. No external queue needed, no database to track progress. The runtime handles it.

The Orchestrator Pattern

An orchestrator function is the conductor. It calls other functions in a specific order and waits for results. Sounds simple enough, but the difference with a regular controller is that the orchestrator can sleep. Days, weeks even. At zero cost.

[FunctionName("OrderProcessing")]
public static async Task<string> RunOrchestrator(
    [OrchestrationTrigger] IDurableOrchestrationContext context)
{
    var order = context.GetInput<Order>();

    // Step 1: Validate stock
    var stockOk = await context.CallActivityAsync<bool>(
        "CheckStock", order.Items);

    if (!stockOk)
        return "Out of stock";

    // Step 2: Process payment
    var paymentRef = await context.CallActivityAsync<string>(
        "ProcessPayment", order.PaymentDetails);

    // Step 3: Schedule shipping
    await context.CallActivityAsync("ScheduleShipping", new ShippingRequest
    {
        OrderId = order.Id,
        PaymentRef = paymentRef
    });

    return $"Order {order.Id} completed";
}

Each CallActivityAsync is a separate function running independently. If the orchestrator crashes midway, it picks up right where it left off. This works through event sourcing under the hood — every step gets persisted to Azure Storage.

Fan-Out/Fan-In: Distributing Work in Parallel

A common scenario: a hundred images need processing after an upload. One by one takes too long. With fan-out, they all start at once.

[FunctionName("BatchImageProcessor")]
public static async Task RunBatch(
    [OrchestrationTrigger] IDurableOrchestrationContext context)
{
    var images = await context.CallActivityAsync<List<string>>(
        "GetPendingImages", null);

    // Fan-out: process all images in parallel
    var tasks = images.Select(img =>
        context.CallActivityAsync<ProcessResult>("ResizeImage", img));

    // Fan-in: wait for everything to finish
    var results = await Task.WhenAll(tasks);

    var failed = results.Where(r => !r.Success).ToList();
    if (failed.Any())
    {
        await context.CallActivityAsync("NotifyFailures", failed);
    }
}

This scales automatically. Azure Functions spins up workers as needed. With fifty images, maybe fifty instances run simultaneously — and afterwards the cost drops back to zero.

Worth noting: there's a limit on parallel executions per function app. On the consumption plan, the default sits at 200. For larger batches, chunking is the sensible approach.

Human Approval in a Workflow

Sometimes a process needs to wait for a person. A manager approving a purchase order, a customer confirming an email address. Durable Functions has external events for that.

[FunctionName("ApprovalWorkflow")]
public static async Task<string> RunApproval(
    [OrchestrationTrigger] IDurableOrchestrationContext context)
{
    var request = context.GetInput<ApprovalRequest>();

    await context.CallActivityAsync("SendApprovalEmail", request);

    // Wait up to 72 hours for approval
    using var cts = new CancellationTokenSource();
    var approvalTask = context.WaitForExternalEvent<bool>("ApprovalResult");
    var timeoutTask = context.CreateTimer(
        context.CurrentUtcDateTime.AddHours(72), cts.Token);

    var winner = await Task.WhenAny(approvalTask, timeoutTask);

    if (winner == approvalTask)
    {
        cts.Cancel();
        return approvalTask.Result ? "Approved" : "Rejected";
    }

    return "Expired — no response within 72 hours";
}

The function literally sleeps until that event arrives. No polling, no cron job checking every minute. The external system (an API endpoint, a webhook) sends an event to the orchestrator and it continues.

Retry Policies: Not Everything Works the First Time

External APIs fail. That's not a question of if, but when. Durable Functions has built-in retry options.

var retryOptions = new RetryOptions(
    firstRetryInterval: TimeSpan.FromSeconds(5),
    maxNumberOfAttempts: 3)
{
    BackoffCoefficient = 2.0,
    MaxRetryInterval = TimeSpan.FromMinutes(1)
};

await context.CallActivityWithRetryAsync(
    "CallExternalApi", retryOptions, requestData);

Exponential backoff with a ceiling. No custom code needed, no Polly wrapper around it. Built right in.

Pitfalls

A few things that aren't immediately obvious from the docs:

Orchestrators must be deterministic. No DateTime.Now, no Guid.NewGuid(), no random calls. Always use context.CurrentUtcDateTime and context.NewGuid(). The replay mechanism requires the code to follow the same path every time.

Avoid large payloads. Everything passed between activities and orchestrators gets serialized to Azure Storage. Passing a 50MB object as a parameter is a recipe for slow execution and high storage costs. Pass references, not data.

Don't skip monitoring. Application Insights integration exists, but needs explicit configuration. Without monitoring, debugging a long-running orchestration is pure misery.

When to Use It, When Not To

Durable Functions fits well for order processing, approval flows, batch processing and ETL pipelines. It's a poor fit for simple request-response APIs or real-time processing where latency is critical. The orchestration layer overhead adds milliseconds that a simple HTTP trigger doesn't need.

It's a tool, not a silver bullet. But for the right use case, it saves weeks of hand-rolled state machines and queue consumers.

Want to stay updated?

Subscribe to my newsletter or get in touch for freelance projects.

Get in Touch