Skip to content
Back to Blog
1 min read

Building Reliable Workflows with Durable Functions Orchestrations

I wrote “Building Reliable Workflows with Durable Functions Orchestrations” to share practical, production-minded guidance on this topic.

What are Durable Functions?

Durable Functions extend Azure Functions with:

  • Orchestrator functions - Define workflows
  • Activity functions - Perform work
  • Entity functions - Manage state
  • Client functions - Start and interact with orchestrations

Setting Up

# Create a function app with Durable Functions
func init DurableFunctionsApp --dotnet
cd DurableFunctionsApp

# Add Durable Functions package
dotnet add package Microsoft.Azure.WebJobs.Extensions.DurableTask

Basic Orchestration Pattern

using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.DurableTask;
using Microsoft.Azure.WebJobs.Extensions.Http;

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

        // Step 1: Validate order
        var isValid = await context.CallActivityAsync<bool>("ValidateOrder", order);
        if (!isValid)
        {
            return new OrderResult { Status = "Invalid", OrderId = order.Id };
        }

        // Step 2: Reserve inventory
        var inventoryReserved = await context.CallActivityAsync<bool>("ReserveInventory", order);
        if (!inventoryReserved)
        {
            return new OrderResult { Status = "OutOfStock", OrderId = order.Id };
        }

        // Step 3: Process payment
        var paymentResult = await context.CallActivityAsync<PaymentResult>("ProcessPayment", order);
        if (!paymentResult.Success)
        {
            // Compensate - release inventory
            await context.CallActivityAsync("ReleaseInventory", order);
            return new OrderResult { Status = "PaymentFailed", OrderId = order.Id };
        }

        // Step 4: Create shipment
        var shipment = await context.CallActivityAsync<Shipment>("CreateShipment", order);

        // Step 5: Send confirmation
        await context.CallActivityAsync("SendConfirmation", new ConfirmationRequest
        {
            Order = order,
            Shipment = shipment,
            PaymentId = paymentResult.TransactionId
        });

        return new OrderResult
        {
            Status = "Completed",
            OrderId = order.Id,
            ShipmentId = shipment.Id
        };
    }

    [FunctionName("ValidateOrder")]
    public static bool ValidateOrder([ActivityTrigger] Order order, ILogger log)
    {
        log.LogInformation($"Validating order {order.Id}");
        return order.Items?.Any() == true && !string.IsNullOrEmpty(order.CustomerId);
    }

    [FunctionName("ReserveInventory")]
    public static async Task<bool> ReserveInventory(
        [ActivityTrigger] Order order,
        [CosmosDB("inventory", "items", ConnectionStringSetting = "CosmosDB")] IAsyncCollector<InventoryReservation> reservations,
        ILogger log)
    {
        log.LogInformation($"Reserving inventory for order {order.Id}");

        foreach (var item in order.Items)
        {
            await reservations.AddAsync(new InventoryReservation
            {
                OrderId = order.Id,
                ProductId = item.ProductId,
                Quantity = item.Quantity,
                ReservedAt = DateTime.UtcNow
            });
        }

        return true;
    }

    [FunctionName("ProcessPayment")]
    public static async Task<PaymentResult> ProcessPayment(
        [ActivityTrigger] Order order,
        ILogger log)
    {
        log.LogInformation($"Processing payment for order {order.Id}");

        // Call payment gateway
        await Task.Delay(1000); // Simulate payment processing

        return new PaymentResult
        {
            Success = true,
            TransactionId = Guid.NewGuid().ToString()
        };
    }
}

HTTP Client to Start Orchestration

[FunctionName("StartOrderProcessing")]
public static async Task<IActionResult> StartOrderProcessing(
    [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequest req,
    [DurableClient] IDurableOrchestrationClient client,
    ILogger log)
{
    var order = await JsonSerializer.DeserializeAsync<Order>(req.Body);

    string instanceId = await client.StartNewAsync("ProcessOrder", order);

    log.LogInformation($"Started orchestration with ID = '{instanceId}'.");

    return client.CreateCheckStatusResponse(req, instanceId);
}

Fan-Out/Fan-In Pattern

Process multiple items in parallel:

[FunctionName("ProcessBatchOrchestrator")]
public static async Task<BatchResult> ProcessBatch(
    [OrchestrationTrigger] IDurableOrchestrationContext context)
{
    var batch = context.GetInput<Batch>();

    // Fan-out: Start all tasks in parallel
    var tasks = batch.Items.Select(item =>
        context.CallActivityAsync<ItemResult>("ProcessItem", item));

    // Fan-in: Wait for all to complete
    var results = await Task.WhenAll(tasks);

    // Aggregate results
    return new BatchResult
    {
        BatchId = batch.Id,
        TotalProcessed = results.Length,
        SuccessCount = results.Count(r => r.Success),
        FailedCount = results.Count(r => !r.Success),
        Results = results.ToList()
    };
}

[FunctionName("ProcessItem")]
public static async Task<ItemResult> ProcessItem(
    [ActivityTrigger] BatchItem item,
    ILogger log)
{
    log.LogInformation($"Processing item {item.Id}");

    try
    {
        // Process the item
        await Task.Delay(500);

        return new ItemResult { ItemId = item.Id, Success = true };
    }
    catch (Exception ex)
    {
        return new ItemResult { ItemId = item.Id, Success = false, Error = ex.Message };
    }
}

Human Interaction Pattern

Wait for external events:

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

    // Send approval request notification
    await context.CallActivityAsync("SendApprovalRequest", request);

    // Wait for approval event with timeout
    using var cts = new CancellationTokenSource();
    var approvalTask = context.WaitForExternalEvent<ApprovalResponse>("ApprovalResponse");
    var timeoutTask = context.CreateTimer(
        context.CurrentUtcDateTime.AddHours(24),
        cts.Token);

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

    if (winner == approvalTask)
    {
        cts.Cancel();
        var response = approvalTask.Result;

        if (response.Approved)
        {
            await context.CallActivityAsync("ProcessApprovedRequest", request);
            return "Approved";
        }
        else
        {
            await context.CallActivityAsync("NotifyRejection", request);
            return "Rejected";
        }
    }
    else
    {
        await context.CallActivityAsync("NotifyTimeout", request);
        return "Timeout";
    }
}

// Raise the event from HTTP
[FunctionName("SubmitApproval")]
public static async Task<IActionResult> SubmitApproval(
    [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequest req,
    [DurableClient] IDurableOrchestrationClient client)
{
    var instanceId = req.Query["instanceId"];
    var response = await JsonSerializer.DeserializeAsync<ApprovalResponse>(req.Body);

    await client.RaiseEventAsync(instanceId, "ApprovalResponse", response);

    return new OkResult();
}

Error Handling and Retry

[FunctionName("ResilientOrchestrator")]
public static async Task<string> ResilientOrchestrator(
    [OrchestrationTrigger] IDurableOrchestrationContext context)
{
    var retryOptions = new RetryOptions(
        firstRetryInterval: TimeSpan.FromSeconds(5),
        maxNumberOfAttempts: 3)
    {
        BackoffCoefficient = 2.0,
        MaxRetryInterval = TimeSpan.FromMinutes(1),
        RetryTimeout = TimeSpan.FromMinutes(5)
    };

    try
    {
        await context.CallActivityWithRetryAsync("UnreliableActivity", retryOptions, "input");
        return "Success";
    }
    catch (FunctionFailedException ex)
    {
        // All retries failed
        await context.CallActivityAsync("HandleFailure", ex.Message);
        return "Failed";
    }
}

Monitoring Orchestrations

[FunctionName("GetOrchestrationStatus")]
public static async Task<IActionResult> GetStatus(
    [HttpTrigger(AuthorizationLevel.Function, "get")] HttpRequest req,
    [DurableClient] IDurableOrchestrationClient client)
{
    var instanceId = req.Query["instanceId"];

    var status = await client.GetStatusAsync(instanceId, showHistory: true);

    if (status == null)
    {
        return new NotFoundResult();
    }

    return new OkObjectResult(new
    {
        status.InstanceId,
        status.RuntimeStatus,
        status.CreatedTime,
        status.LastUpdatedTime,
        status.Output
    });
}

Durable Functions provide the reliability and coordination capabilities needed for complex serverless workflows, while maintaining the cost benefits of serverless computing.

The constraint people miss until they trip over it: orchestrator code must be deterministic. No DateTime.Now, no random numbers, no direct HTTP calls — those go in activity functions. The orchestrator is replayed from history on every step, so any non-determinism breaks the replay model. Read the constraint list once, internalise it, and you’ll save yourself a confusing afternoon.\n\n## Takeaways\n\nAdd a concise, personal takeaway and recommended next steps here.\n

Michael John Peña

Michael John Peña

Senior Data Engineer based in Sydney. Writing about data, cloud, and technology.