Durable Functions — Stateful Workflows in Azure Functions
Implement stateful workflows with Azure Durable Functions: orchestration patterns (fan-out/fan-in, human approval, monitor), activity functions, durable entities, and clinical workflow examples.
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 stepsOrchestrator Function
// 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
// 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
// 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 workflowsHuman Interaction Pattern (Approval Workflow)
// 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
// 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), outputProduction issue I've seen: A Durable Functions orchestrator included a call to
DateTime.UtcNowdirectly 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 checkif (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 usecontext.CurrentUtcDateTimeinside 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 usecontext.CurrentUtcDateTimeand pass IDs from outside. Orchestrators must be deterministic: replay will re-execute the entire function body.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.