Azure Durable Functions: modeling a multi-step approval flow without losing state
An approval flow looks simple until you actually have to build it. Someone submits a request, a reviewer has to look at it, that can take minutes or it can take three days, and in the meantime the system has to remember which step the process is on, what data the original request carried, and what to do once a response arrives. If approval doesn't come within a reasonable window, it has to escalate. None of this is unusual — it's the standard shape of any business process with a human wait built into it. What's unusual is that implementing it correctly, without losing state across a restart or a scale-out event, ends up being more infrastructure work than business logic.
The obvious answer is a table: request_status, current_step, deadline, plus a cron job or a trigger that checks that table to decide what happens next. It works, but the developer ends up hand-writing a state machine, with everything that implies: what happens if two triggers fire almost simultaneously against the same row, how the process recovers if the worker running it dies halfway through, how to express a wait of "up to 3 days or until the response arrives, whichever comes first" without constant polling. Durable Functions, the Azure Functions extension for orchestrating long-running workflows, exists to take that work off the table.
The mechanism: replay over an event history
What makes an orchestrator function different from every other Azure Function is how it runs. The runtime does not keep the method alive in memory for the three days an approval might take. Instead, every time there's something new to process — an activity that finished, an external event that arrived, a timer that expired — the runtime re-executes the orchestrator method from the first line. The difference from running it from scratch is that during that replay, every await on a task that already completed in a previous run does not re-execute that task: the runtime recognizes it, checks the orchestration's persisted event history, and returns the already-recorded result immediately. The code only advances up to the first point where it finds something that genuinely hasn't completed yet, and pauses there again.
That event history is exactly what replaces the state table you'd otherwise write by hand. Every activity function call, every external event received, every timer created gets recorded as an event in that history, and the runtime persists it to the storage configured for the extension. The developer doesn't design that persistence schema or write the code that reads and writes it — they write a function that looks sequential and ordinary, and the replay mechanism is what gives it continuity across restarts, horizontal scaling, or process failures.
The approval flow
Modeling a timed wait for human approval combines two Durable Functions primitives: WaitForExternalEvent, which suspends the orchestration until an event with a given name arrives, and CreateTimer, which creates a durable pause until a specific point in time. Wrapping both in a Task.WhenAny gives exactly the semantics needed: resume as soon as whichever comes first — the approval or the deadline — happens.
[Function(nameof(ApprovalFlow))]
public static async Task<string> ApprovalFlow(
[OrchestrationTrigger] TaskOrchestrationContext context)
{
var request = context.GetInput<RequestInput>();
await context.CallActivityAsync(nameof(NotifyReviewer), request);
using var cts = new CancellationTokenSource();
Task<ApprovalResult> approvalTask =
context.WaitForExternalEvent<ApprovalResult>("ApprovalReceived");
Task timeoutTask = context.CreateTimer(
context.CurrentUtcDateTime.AddDays(3), cts.Token);
Task completed = await Task.WhenAny(approvalTask, timeoutTask);
if (completed == approvalTask)
{
cts.Cancel();
var result = approvalTask.Result;
await context.CallActivityAsync(
result.Approved ? nameof(ProcessApproval) : nameof(ProcessRejection),
request);
return result.Approved ? "approved" : "rejected";
}
await context.CallActivityAsync(nameof(EscalateRequest), request);
return "escalated_by_timeout";
}
The line that tends to catch people off guard the first time is context.CurrentUtcDateTime. It's not a cosmetic detail: the orchestration re-executes from the top on every replay, and if that line called DateTime.UtcNow directly, every replay would compute a different deadline — three days from the moment of the replay, not from the moment the timer was originally created — breaking the exact guarantee we're after. CurrentUtcDateTime is the timestamp the runtime recorded the first time that point in the code ran, and it returns that same value unchanged on every subsequent replay. This is the general rule of the Durable Functions programming model: orchestrator code has to be deterministic. No DateTime.Now, no Guid.NewGuid() outside the utilities the context itself provides, no HTTP calls or direct database access inside the orchestrator body. Any side effect, or any source of non-determinism, has to live in an activity function — never in the orchestrator itself.
The external event is raised from the outside using the orchestration client, typically from the HTTP endpoint that receives the reviewer's response:
[Function("ReceiveApproval")]
public static async Task<HttpResponseData> ReceiveApproval(
[HttpTrigger(AuthorizationLevel.Function, "post", Route = "approvals/{instanceId}")]
HttpRequestData req,
string instanceId,
[DurableClient] DurableTaskClient client)
{
var body = await req.ReadFromJsonAsync<ApprovalResult>();
await client.RaiseEventAsync(instanceId, "ApprovalReceived", body);
return req.CreateResponse(System.Net.HttpStatusCode.Accepted);
}
RaiseEventAsync doesn't look up or reconstruct any state by hand: it only needs the orchestration's instanceId, which was generated when the instance was started and is normally stored alongside the request so it can be referenced later.
What didn't work: notifying from inside the orchestrator
The first version of this flow called an email service directly from inside the orchestrator method, skipping an activity function, on the reasoning that "it's just a notification, it doesn't change any state." The result was duplicate notifications in production, and not at random: it happened every time the orchestration had to replay for any reason — a host restart, an external event arriving while the instance was still waiting on the timer — because every replay re-executed that line of code from the top, and since it wasn't an activity function, the runtime had no way of knowing that send had already happened and skipping it. An activity function isn't just a way of organizing code: it's the unit the runtime records in the event history and whose result it reuses on later replays instead of running it again. Any side-effecting call left outside an activity runs once per replay, not once per real occurrence.
The fix was to move the send into an activity (NotifyReviewer in the example above) and leave no loose side effects in the orchestrator body. It's worth being precise about what this does and doesn't guarantee: it doesn't fully eliminate the possibility of a duplicate notification. If the activity itself fails after sending the email but before the runtime records that it finished, Durable Functions can retry it, and the send can repeat there too. That's an "at least once" guarantee at the activity level, a different problem from the orchestrator issue, and if the side effect genuinely can't tolerate duplicates, the activity needs to be idempotent on its own — for instance, checking whether a notification record already exists for that request before sending — rather than assuming the framework handles that for you.
When this model is worth it
Durable Functions doesn't replace a message queue or a complex business rules engine, and forcing every asynchronous flow into this pattern adds a layer of constraints — orchestrator determinism, every side effect pushed into an activity — that isn't always worth paying for. Where it fits well is exactly the case that motivated this article: a multi-step process, possibly with a human wait in the middle, that can take anywhere from minutes to days, where losing track of exactly where the process was if the host restarts is not acceptable. In that scenario, the alternative — hand-writing the state table, the timeout-aware wait mechanism, and recovery from process failures — is exactly the work the replay-based orchestration model handles underneath, leaving the code you actually write reading as what it really is: a sequence of steps with a wait in the middle.