Azure Durable Functions: Complete Orchestration Patterns Guide

Azure Durable Functions extends Azure Functions with stateful orchestration capabilities. Unlike Step Functions (JSON-based ASL), Durable Functions uses code—C#, JavaScript, Python, or PowerShell—to define workflows. This code-first approach enables IDE support, unit testing, and complex control flow. This comprehensive guide covers the core patterns: Function Chaining, Fan-Out/Fan-In, Human Interaction, and the Actor Pattern with Durable Entities.

Orchestrator Function Fundamentals

Durable Functions introduces three function types:

  • Client Function: Starts orchestrations (HTTP trigger, queue trigger)
  • Orchestrator Function: Coordinates activities and sub-orchestrations
  • Activity Function: Performs actual work (idempotent, stateless)
flowchart TB
    Client["Client Function (HTTP Trigger)"] --> Orchestrator["Orchestrator Function"]
    Orchestrator --> Activity1["Activity: Validate"]
    Orchestrator --> Activity2["Activity: Process"]
    Orchestrator --> Activity3["Activity: Notify"]
    
    subgraph StateManagement ["State Management (Automatic)"]
        History["Execution History"]
        Checkpoints["Checkpoints"]
    end
    
    Orchestrator --> StateManagement
    
    style Orchestrator fill:#E1F5FE,stroke:#0277BD

Pattern 1: Function Chaining

Sequential execution with output piped to next activity:

[FunctionName("OrderProcessingOrchestrator")]
public static async Task<OrderResult> RunOrchestrator(
    [OrchestrationTrigger] IDurableOrchestrationContext context)
{
    var order = context.GetInput<Order>();
    
    // Activities run sequentially, each awaited
    var validationResult = await context.CallActivityAsync<ValidationResult>(
        "ValidateOrder", order);
    
    if (!validationResult.IsValid)
    {
        return new OrderResult { Status = "Rejected", Reason = validationResult.Errors };
    }
    
    var paymentResult = await context.CallActivityAsync<PaymentResult>(
        "ProcessPayment", order);
    
    var shipmentResult = await context.CallActivityAsync<ShipmentResult>(
        "CreateShipment", new { order, paymentResult.TransactionId });
    
    await context.CallActivityAsync("SendConfirmation", new { order, shipmentResult });
    
    return new OrderResult { Status = "Completed", TrackingNumber = shipmentResult.TrackingNumber };
}

[FunctionName("ValidateOrder")]
public static ValidationResult ValidateOrder([ActivityTrigger] Order order, ILogger log)
{
    // Validation logic - idempotent, no orchestration context
    return new ValidationResult { IsValid = order.Items.Any() && order.Total > 0 };
}

Pattern 2: Fan-Out/Fan-In

Parallel execution of multiple activities, waiting for all to complete:

[FunctionName("ParallelProcessingOrchestrator")]
public static async Task<List<ProcessResult>> RunParallel(
    [OrchestrationTrigger] IDurableOrchestrationContext context)
{
    var items = context.GetInput<List<WorkItem>>();
    
    // Fan-out: Start all activities in parallel
    var tasks = items.Select(item => 
        context.CallActivityAsync<ProcessResult>("ProcessItem", item));
    
    // Fan-in: Wait for all to complete
    var results = await Task.WhenAll(tasks);
    
    // Aggregate results
    var summary = await context.CallActivityAsync<Summary>(
        "AggregateResults", results.ToList());
    
    return results.ToList();
}

Pattern 3: Human Interaction (Approval)

Wait for external event (human approval) with timeout:

[FunctionName("ApprovalOrchestrator")]
public static async Task<ApprovalResult> RunApproval(
    [OrchestrationTrigger] IDurableOrchestrationContext context)
{
    var request = context.GetInput<ApprovalRequest>();
    
    // Send approval request email
    await context.CallActivityAsync("SendApprovalEmail", request);
    
    // Wait for external event OR timeout (72 hours)
    using var cts = new CancellationTokenSource();
    var approvalEvent = context.WaitForExternalEvent<ApprovalResponse>("Approved");
    var rejectionEvent = context.WaitForExternalEvent<ApprovalResponse>("Rejected");
    var timeout = context.CreateTimer(
        context.CurrentUtcDateTime.AddHours(72), cts.Token);
    
    var winner = await Task.WhenAny(approvalEvent, rejectionEvent, timeout);
    cts.Cancel(); // Cancel the timer
    
    if (winner == timeout)
    {
        return new ApprovalResult { Status = "Timeout" };
    }
    
    var response = winner == approvalEvent 
        ? await approvalEvent 
        : await rejectionEvent;
    
    return new ApprovalResult { 
        Status = response.Approved ? "Approved" : "Rejected",
        ApprovedBy = response.ApproverEmail
    };
}

// External API to submit approval
[FunctionName("SubmitApproval")]
public static async Task<IActionResult> SubmitApproval(
    [HttpTrigger] HttpRequest req,
    [DurableClient] IDurableOrchestrationClient client)
{
    var body = await req.ReadAsStringAsync();
    var approval = JsonSerializer.Deserialize<ApprovalSubmission>(body);
    
    await client.RaiseEventAsync(
        approval.OrchestrationId, 
        approval.Approved ? "Approved" : "Rejected",
        new ApprovalResponse { 
            Approved = approval.Approved, 
            ApproverEmail = approval.Email 
        });
    
    return new OkResult();
}

Pattern 4: Durable Entities (Actor Model)

Stateful actors that handle operations serially:

[JsonObject(MemberSerialization.OptIn)]
public class Counter : ICounter
{
    [JsonProperty("value")]
    public int Value { get; set; }
    
    public void Add(int amount) => Value += amount;
    public void Reset() => Value = 0;
    public int Get() => Value;
    
    [FunctionName(nameof(Counter))]
    public static Task Run([EntityTrigger] IDurableEntityContext ctx)
        => ctx.DispatchAsync<Counter>();
}

// Using the entity from an orchestrator
[FunctionName("EntityOrchestrator")]
public static async Task UseCounter(
    [OrchestrationTrigger] IDurableOrchestrationContext context)
{
    var entityId = new EntityId(nameof(Counter), "myCounter");
    
    // Signal (fire-and-forget)
    context.SignalEntity(entityId, "Add", 5);
    
    // Call (request-response)
    var currentValue = await context.CallEntityAsync<int>(entityId, "Get");
}

Error Handling and Retries

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

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

Key Takeaways

  • Durable Functions provides code-first workflow orchestration
  • Orchestrator functions must be deterministic (no I/O, no random)
  • Activities are idempotent work units
  • Fan-Out/Fan-In enables parallel processing
  • External events enable human-in-the-loop workflows
  • Durable Entities provide actor-model state management

Discover more from C4: Container, Code, Cloud & Context

Subscribe to get the latest posts sent to your email.

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.