How to Build Reliable Long-Running Workflows with Azure Durable Functions

How to Build Reliable Long-Running Workflows with Azure Durable Functions

Imagine an order-processing API that must validate an order, reserve inventory, and send a confirmation email. Each step is simple on its own, but the complete workflow gets harder when a downstream service is temporarily unavailable, when a step must wait minutes or hours for an external event, or when the process must survive a host restart without losing progress.

A normal Azure Function is ideal for one short, stateless operation. Azure Durable Functions is the extension you reach for when you need reliable state and coordination across multiple steps — without building your own orchestration database, retry logic, or polling mechanism.

What Are Azure Durable Functions?

Durable Functions is an extension of Azure Functions for writing stateful workflows in regular C#. The Durable Task runtime persists the workflow’s progress and reconstructs its in-memory state after restarts, so a workflow can safely pause, wait, and resume.

  • Client (starter) function — starts, queries, terminates, or raises events for an orchestration instance.
  • Orchestrator function — defines the order of steps, branching, waiting, and parallel execution. It must be deterministic.
  • Activity function — performs one unit of work, such as calling an API, writing to a database, or sending an email.
  • Durable state and checkpoints — the orchestration history, inputs, outputs, timers, and completed tasks are persisted by the Durable Task framework.

When an orchestrator awaits a durable task, the runtime can stop running it. When work can continue, the runtime replays the saved history to rebuild the orchestrator’s local variables and reach the next instruction. That is why a workflow can survive restarts without keeping a single invocation alive for hours.

Why Use a Durable Function Instead of a Normal Azure Function?

Normal Functions are perfect for short, stateless operations. Durable Functions are the right choice when the workflow has multiple dependent steps, runs for a long time, needs retries, must wait for an external event, requires parallel execution with a joined result, or must preserve workflow state reliably.

Requirement Normal Function Durable Function
One short, stateless operationGood fitUsually unnecessary
Multiple dependent stepsManual coordinationOrchestrator coordinates them
Runs for minutes, hours, or daysNot suitableCheckpoints and resumes
Waits for external event or approvalNeeds custom stateDurable timers and external events
Parallel work with a combined resultCustom trackingFan-out / fan-in
Queryable workflow statusBuild it yourselfBuilt-in instance status APIs

Do not reach for Durable Functions when a single database query or quick transformation is all you need. The extra history, storage, and replay behavior would add complexity without solving a real problem.

How Durable Functions Work

The relationship between the components is straightforward:

HTTP request → Durable client → Orchestrator → Activities → Status/result

  1. An HTTP starter receives the request.
  2. The DurableTaskClient schedules a new orchestration instance.
  3. The orchestrator calls activities in the required order — sequentially, in parallel, or waiting for events.
  4. The runtime checkpoints the orchestration history between durable tasks.
  5. The caller polls the returned status URL (or receives a webhook) to track progress and retrieve the final result.

Implementing a Durable Function in C# and .NET Isolated

Use the .NET isolated worker model on Azure Functions v4 and target the latest supported .NET version for Azure Functions at the time you build your project. Verify the current version matrix in the official supported languages matrix before deploying.

Project setup

Create an isolated-worker Azure Functions project and add the Durable Task extension:

func init DurableOrders --worker-runtime dotnet-isolated
cd DurableOrders
dotnet add package Microsoft.Azure.Functions.Worker.Extensions.DurableTask

The Azure Functions project template provides the worker packages (Microsoft.Azure.Functions.Worker and the SDK). The Durable extension exposes the triggers and bindings, while Microsoft.DurableTask contains the shared types such as TaskOrchestrationContext, TaskOptions, and RetryPolicy.

Models

namespace DurableOrders;

public record OrderRequest(string OrderId, string Email, int Quantity);
public record ConfirmationRequest(OrderRequest Order, string ReservationId);
public record OrderResult(string OrderId, bool Accepted, string Message);

Small, well-named input and output types make the workflow easier to read and reduce the amount of data stored in the orchestration history.

HTTP starter

using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.DurableTask.Client;
using System.Net;

namespace DurableOrders;

public static class OrderStarter
{
    [Function(nameof(StartOrder))]
    public static async Task<HttpResponseData> StartOrder(
        [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestData request,
        [DurableClient] DurableTaskClient client)
    {
        OrderRequest? order = await request.ReadFromJsonAsync<OrderRequest>();

        if (order is null)
        {
            HttpResponseData badRequest =
                request.CreateResponse(HttpStatusCode.BadRequest);
            await badRequest.WriteStringAsync("An order is required.");
            return badRequest;
        }

        string instanceId =
            await client.ScheduleNewOrchestrationInstanceAsync(
                nameof(OrderOrchestrator.Run), order);

        return await client.CreateCheckStatusResponseAsync(request, instanceId);
    }
}

The starter does not process the order itself. It schedules the workflow and returns an asynchronous HTTP response containing management URLs the client can use to check status, raise events, or terminate the instance.

Orchestrator

using Microsoft.Azure.Functions.Worker;
using Microsoft.DurableTask;

namespace DurableOrders;

public static class OrderOrchestrator
{
    [Function(nameof(Run))]
    public static async Task<OrderResult> Run(
        [OrchestrationTrigger] TaskOrchestrationContext context)
    {
        OrderRequest order = context.GetInput<OrderRequest>()
            ?? throw new InvalidOperationException("Order input is missing.");

        bool valid = await context.CallActivityAsync<bool>(
            nameof(OrderActivities.ValidateOrder), order);

        if (!valid)
            return new(order.OrderId, false, "Order validation failed.");

        string reservationId = await context.CallActivityAsync<string>(
            nameof(OrderActivities.ReserveInventory), order);

        await context.CallActivityAsync(
            nameof(OrderActivities.SendConfirmation),
            new ConfirmationRequest(order, reservationId));

        return new(order.OrderId, true, "Order processed successfully.");
    }
}

The orchestrator contains only workflow logic. Each CallActivityAsync becomes a checkpoint in the orchestration history, which is why the orchestrator can safely be replayed from the beginning after a restart.

Activities

using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;

namespace DurableOrders;

public static class OrderActivities
{
    [Function(nameof(ValidateOrder))]
    public static bool ValidateOrder([ActivityTrigger] OrderRequest order) =>
        !string.IsNullOrWhiteSpace(order.OrderId)
        && !string.IsNullOrWhiteSpace(order.Email)
        && order.Quantity > 0;

    [Function(nameof(ReserveInventory))]
    public static string ReserveInventory(
        [ActivityTrigger] OrderRequest order,
        FunctionContext context)
    {
        context.GetLogger(nameof(ReserveInventory))
            .LogInformation("Reserving inventory for {OrderId}", order.OrderId);

        return $"reservation-{order.OrderId}";
    }

    [Function(nameof(SendConfirmation))]
    public static void SendConfirmation(
        [ActivityTrigger] ConfirmationRequest input,
        FunctionContext context)
    {
        context.GetLogger(nameof(SendConfirmation))
            .LogInformation(
                "Sending confirmation for {OrderId} with {ReservationId}",
                input.Order.OrderId,
                input.ReservationId);
    }
}

The sample activities log their work instead of calling real systems, which keeps the example reproducible. In production code, these activities are the right place to call inventory services, payment providers, databases, or email APIs.

Important Orchestrator Rules

An orchestrator can be replayed many times. For replay to produce the same result, the orchestrator code must be deterministic — identical history must lead to identical decisions. Microsoft documents these restrictions in the orchestrator code constraints.

  • Use context.CurrentUtcDateTime instead of DateTime.UtcNow.
  • Use context.NewGuid() instead of Guid.NewGuid().
  • Do not call HTTP endpoints, databases, file systems, or message queues directly from the orchestrator.
  • Move random values, environment lookups, and all other side effects into activity functions.
  • Use context.CreateReplaySafeLogger(...) when logging from an orchestrator so replay does not produce duplicate log entries.
// Wrong — non-deterministic
DateTime createdAt = DateTime.UtcNow;
Guid correlationId = Guid.NewGuid();

// Correct — replay-safe
DateTime createdAt = context.CurrentUtcDateTime;
Guid correlationId = context.NewGuid();

Activities are not replayed the same way as orchestrators, so you can use ordinary logging, HTTP clients, and random values freely inside them.

Retry Example

TaskOptions options = TaskOptions.FromRetryPolicy(
    new RetryPolicy(
        maxNumberOfAttempts: 3,
        firstRetryInterval: TimeSpan.FromSeconds(5)));

string reservationId = await context.CallActivityAsync<string>(
    nameof(OrderActivities.ReserveInventory),
    order,
    options);

Retrying is appropriate for transient failures such as timeouts, throttling responses, or short-lived network interruptions. It is not appropriate for permanent business errors — a failed validation or a rejected payment will fail again on every retry. Because an activity may run more than once after an uncertain failure, external operations should be idempotent. For an order, using the OrderId as an idempotency key on the inventory reservation call ensures that repeated attempts do not reserve stock twice.

Common Durable Function Patterns

  • Function chaining — run dependent steps in sequence, passing output from one activity to the next.
  • Fan-out / fan-in — start many activities in parallel and combine their results once they all complete.
  • Async HTTP APIs — expose long-running work behind the standard status-URL pattern instead of holding an HTTP connection open.
  • Human interaction — use WaitForExternalEvent together with a durable timer to pause until an approval arrives or a timeout expires.
  • Monitoring — poll an external resource on a durable timer until a condition is met.
  • Durable entities — model small addressable units of state with serialized operations, useful for counters, device state, or shopping carts.

A good real-life example is a document onboarding workflow. When a user uploads a file, an orchestrator can run virus scanning and text extraction in parallel, wait for a reviewer’s approval event, publish the approved document to the catalog, and notify the requester. The whole process can span hours while the system uses almost no compute between steps.

Best Practices

  • Keep activities small and focused on one external operation. Smaller activities are easier to retry safely and easier to test.
  • Make side-effecting activities idempotent, using business keys such as OrderId.
  • Keep orchestration inputs and outputs small. Pass identifiers or storage references instead of large payloads to keep the persisted history compact.
  • Separate expected business failures from transient technical failures and handle them differently.
  • Use bounded retry policies with a sensible maximum attempt count rather than retrying every exception indefinitely.
  • Emit structured logs and send telemetry to Application Insights for end-to-end tracing across starter, orchestrator, and activities.
  • Version orchestrators carefully. Changing the shape of an orchestrator while instances are still running can break replay — introduce new orchestrator names or versioned branches for breaking changes.
  • Never persist secrets, access tokens, or unnecessary personal data in orchestration inputs, outputs, or custom status payloads. Keep sensitive values in Key Vault and fetch them inside activities when needed.

When Not to Use Durable Functions

Durable Functions is not always the right tool. Consider the alternatives:

  • A normal Azure Function for a single short stateless operation.
  • Azure Service Bus when the primary need is durable, decoupled messaging between services rather than centralized workflow coordination.
  • Azure Logic Apps for connector-heavy, low-code integration workflows where visual design and prebuilt connectors matter more than code-level control.
  • Azure Container Apps Jobs or WebJobs for background and batch workloads that run to completion but don’t need orchestration semantics.

None of these is universally better. The right choice depends on how much orchestration logic you need in code, how long the process runs, and how much state must survive restarts.

Conclusion

Durable Functions gives you a code-first way to coordinate reliable workflows while Azure takes care of checkpointing, replay, timers, and instance status. The mental model is simple: starters begin workflows, orchestrators make deterministic decisions, and activities perform real-world work.

Reach for Durable Functions when your business process has multiple dependent steps, must survive restarts, needs retries or external waits, or requires observable progress. Keep a normal Azure Function when the operation is short, stateless, and done in one shot.


Official Microsoft Documentation

Comments

Popular posts from this blog

How to Implement Advanced Filtering in Azure Event Grid for More Granular Event Processing

Cancel Ongoing Flow Runs in Microsoft Dynamics 365 Dataverse: Complete Guide

How to Cancel Dynamics 365 Dataverse Order in 2024: Step-by-Step Guide