Learnixo

Serverless with Azure Functions · Lesson 3 of 5

Durable Functions — Orchestrations and Fan-Out

What Durable Functions Solve

Problem: multi-step workflows that outlast a single function invocation.

Example: Patient admission workflow
  Step 1: Check bed availability (5ms)
  Step 2: Reserve bed (50ms)
  Step 3: Notify ward staff (async — may take minutes to confirm)
  Step 4: Create billing record
  Step 5: Send confirmation to patient

Without Durable Functions:
  → Implement as a state machine in a database
  → Poll for step completion
  → Handle retries and failures manually
  → Timeout and restart logic is custom code

With Durable Functions:
  → Define the workflow in C# as an orchestrator function
  → Each step is an activity function (stateless, retriable)
  → Durable Functions persist state between steps automatically
  → Human approval steps: pause and wait for an external event
  → Works in a Consumption plan — no VM needed between steps

Orchestrator Function

C#
// NuGet: Microsoft.Azure.Functions.Worker.Extensions.DurableTask

// The orchestrator defines the workflow — must be deterministic (no random, no DateTime.Now)
[Function("PatientAdmissionOrchestrator")]
public static async Task<string> OrchestratorAsync(
    [OrchestrationTrigger] TaskOrchestrationContext context)
{
    var admission = context.GetInput<StartAdmissionInput>()!;
    var logger    = context.CreateReplaySafeLogger<PatientAdmissionOrchestrator>();

    // Step 1: Check bed availability
    var bed = await context.CallActivityAsync<BedAllocation?>(
        "CheckBedAvailability",
        new CheckBedInput(admission.WardId, admission.PatientId));

    if (bed is null)
    {
        logger.LogWarning("No bed available for patient {PatientId}", admission.PatientId);
        return "NoBedAvailable";
    }

    // Step 2: Reserve the bed
    await context.CallActivityAsync("ReserveBed", new ReserveBedInput(bed.BedId, admission.PatientId));

    // Step 3: Wait for ward nurse confirmation (human approval pattern)
    using var cts = new CancellationTokenSource();
    var approvalTask = context.WaitForExternalEvent<bool>("WardConfirmation");
    var timeoutTask  = context.CreateTimer(context.CurrentUtcDateTime.AddHours(4), cts.Token);

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

    if (winner == timeoutTask || !approvalTask.Result)
    {
        // Release bed reservation if not confirmed within 4 hours
        await context.CallActivityAsync("ReleaseReservation", bed.BedId);
        return "ConfirmationTimeout";
    }

    // Step 4: Create billing record
    await context.CallActivityAsync("CreateBillingRecord",
        new BillingInput(admission.PatientId, bed.BedId, context.CurrentUtcDateTime));

    return "Admitted";
}

Activity Functions

C#
// Activity functions: the actual work — stateless, can be retried

[Function("CheckBedAvailability")]
public async Task<BedAllocation?> CheckBedAvailabilityAsync(
    [ActivityTrigger] CheckBedInput input,
    CancellationToken ct)
{
    var bed = await _wardService.FindAvailableBedAsync(input.WardId, ct);
    return bed is null ? null : new BedAllocation(bed.Id, bed.WardId);
}

[Function("ReserveBed")]
public async Task ReserveBedAsync(
    [ActivityTrigger] ReserveBedInput input,
    CancellationToken ct)
{
    await _wardService.ReserveBedAsync(input.BedId, input.PatientId, ct);
}

[Function("CreateBillingRecord")]
public async Task CreateBillingRecordAsync(
    [ActivityTrigger] BillingInput input,
    CancellationToken ct)
{
    await _billingService.CreateAdmissionBillingAsync(
        input.PatientId, input.BedId, input.AdmittedAt, ct);
}

Fan-Out / Fan-In Pattern

C#
// Run multiple activities in parallel, wait for all to complete
// Clinical use: send confirmation notifications to multiple ward staff

[Function("NotifyWardStaffOrchestrator")]
public static async Task OrchestratorAsync(
    [OrchestrationTrigger] TaskOrchestrationContext context)
{
    var wardStaff = context.GetInput<List<WardStaffMember>>()!;

    // Fan-out: send notifications to all staff simultaneously
    var notificationTasks = wardStaff
        .Select(staff => context.CallActivityAsync(
            "SendNotification",
            new NotificationInput(staff.StaffId, staff.ContactMethod)))
        .ToList();

    // Fan-in: wait for all notifications to be sent
    await Task.WhenAll(notificationTasks);
}

// Use for: parallel lab result processing, multi-approver workflows

Human Interaction Pattern (Approval Workflow)

C#
// Orchestrator waits for a clinical pharmacist to approve a high-risk prescription

[Function("HighRiskPrescriptionApprovalOrchestrator")]
public static async Task<string> OrchestratorAsync(
    [OrchestrationTrigger] TaskOrchestrationContext context)
{
    var prescription = context.GetInput<PrescriptionApprovalInput>()!;

    // Notify pharmacist
    await context.CallActivityAsync("NotifyPharmacistForReview", prescription);

    // Wait up to 2 hours for the pharmacist to approve or reject
    using var cts = new CancellationTokenSource();
    var approvalEvent = context.WaitForExternalEvent<PharmacistDecision>("PharmacistDecision");
    var timeout       = context.CreateTimer(context.CurrentUtcDateTime.AddHours(2), cts.Token);

    var winner = await Task.WhenAny(approvalEvent, timeout);
    cts.Cancel();

    if (winner == timeout)
    {
        await context.CallActivityAsync("EscalatePrescriptionReview", prescription.PrescriptionId);
        return "EscalatedDueToTimeout";
    }

    var decision = approvalEvent.Result;
    await context.CallActivityAsync(
        decision.Approved ? "ApprovePrescription" : "RejectPrescription",
        new PrescriptionDecisionInput(prescription.PrescriptionId, decision.ReviewedBy));

    return decision.Approved ? "Approved" : "Rejected";
}

// Raise the external event from the pharmacist's UI:
await _durableClient.RaiseEventAsync(
    instanceId:  orchestrationInstanceId,
    eventName:   "PharmacistDecision",
    eventData:   new PharmacistDecision(Approved: true, ReviewedBy: pharmacistId));

Starting and Monitoring Orchestrations

C#
// HTTP trigger to start the workflow
[Function("StartPatientAdmission")]
public async Task<HttpResponseData> StartAsync(
    [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestData req,
    [DurableClient] DurableTaskClient durableClient,
    CancellationToken ct)
{
    var input = await req.ReadFromJsonAsync<StartAdmissionInput>(ct);

    var instanceId = await durableClient.ScheduleNewOrchestrationInstanceAsync(
        "PatientAdmissionOrchestrator",
        input,
        cancellationToken: ct);

    // Return status check URL to caller
    return durableClient.CreateCheckStatusResponse(req, instanceId);
}
// The check status URL allows polling: GET /runtime/webhooks/durabletask/instances/{id}
// Response includes: runtimeStatus (Running, Completed, Failed), output

Production issue I've seen: A Durable Functions orchestrator included a call to DateTime.UtcNow directly inside the orchestrator body (not inside an activity). Durable Functions replays the orchestrator from history on every resumption — with a different "now" each time. The expiry check if (DateTime.UtcNow > prescription.CreatedAt.AddHours(24)) returned different results on replay vs. original execution, causing the orchestrator to take a different branch on replay. The prescription was both approved and rejected simultaneously in two parallel replay branches. The fix: always use context.CurrentUtcDateTime inside orchestrators — it is deterministic and replay-safe.


Key Takeaway

Durable Functions orchestrate long-running stateful workflows: each step is a retriable activity function, the orchestrator persists state between steps. Use the human interaction pattern to pause workflows waiting for external approval. Use fan-out/fan-in for parallel activity execution. Never use non-deterministic operations (DateTime.UtcNow, Guid.NewGuid(), random) inside orchestrators — always use context.CurrentUtcDateTime and pass IDs from outside. Orchestrators must be deterministic: replay will re-execute the entire function body.