Azure Durable Functions (.NET Isolated) with Durable Task Scheduler
Build fault-tolerant, stateful serverless workflows using Azure Durable Functions connected to Azure Durable Task Scheduler.
Quick Start
Required NuGet Packages
<ItemGroup> <PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2." /> <PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2." /> <PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3." /> <PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="1." /> <PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" Version="1." /> <PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" Version="" /> <PackageReference Include="Azure.Identity" Version="1.*" /> </ItemGroup>
host.json Configuration (Durable Task Scheduler)
{ "version": "2.0", "extensions": { "durableTask": { "storageProvider": { "type": "azureManaged", "connectionStringName": "DTS_CONNECTION_STRING" }, "hubName": "%TASKHUB_NAME%" } } }
local.settings.json
{ "IsEncrypted": false, "Values": { "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated", "AzureWebJobsStorage": "UseDevelopmentStorage=true", "DTS_CONNECTION_STRING": "Endpoint=http://localhost:8080;Authentication=None", "TASKHUB_NAME": "default" } }
Minimal Example (Function-Based)
using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.DurableTask; using Microsoft.DurableTask.Client; using Microsoft.Extensions.Logging;
public static class DurableFunctionsApp { // HTTP Starter - triggers orchestration [Function("HttpStart")] public static async Task<HttpResponseData> HttpStart( [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "orchestrators/{functionName}")] HttpRequestData req, [DurableClient] DurableTaskClient client, string functionName, FunctionContext executionContext) { string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(functionName);
var logger = executionContext.GetLogger("HttpStart");
logger.LogInformation("Started orchestration with ID = '{instanceId}'", instanceId);
return await client.CreateCheckStatusResponseAsync(req, instanceId);
}
// Orchestrator function
[Function(nameof(MyOrchestration))]
public static async Task<string> MyOrchestration(
[OrchestrationTrigger] TaskOrchestrationContext context)
{
ILogger logger = context.CreateReplaySafeLogger(nameof(MyOrchestration));
logger.LogInformation("Starting orchestration");
var result1 = await context.CallActivityAsync<string>(nameof(SayHello), "Tokyo");
var result2 = await context.CallActivityAsync<string>(nameof(SayHello), "Seattle");
var result3 = await context.CallActivityAsync<string>(nameof(SayHello), "London");
return $"{result1}, {result2}, {result3}";
}
// Activity function
[Function(nameof(SayHello))]
public static string SayHello([ActivityTrigger] string name, FunctionContext executionContext)
{
var logger = executionContext.GetLogger(nameof(SayHello));
logger.LogInformation("Saying hello to {name}", name);
return $"Hello {name}!";
}
}
Program.cs Setup
using Microsoft.Extensions.Hosting;
var host = new HostBuilder() .ConfigureFunctionsWorkerDefaults() .Build();
await host.RunAsync();
Pattern Selection Guide
Pattern Use When
Function Chaining Sequential steps where each depends on the previous
Fan-Out/Fan-In Parallel processing with aggregated results
Async HTTP APIs Long-running operations with HTTP status polling
Monitor Periodic polling with configurable timeouts
Human Interaction Workflow pauses for external input/approval
Aggregator (Entities) Stateful objects with operations (counters, accounts)
See references/patterns.md for detailed implementations.
Two Approaches: Function-Based vs Class-Based
Function-Based (Default)
[Function(nameof(MyOrchestration))] public static async Task<string> MyOrchestration( [OrchestrationTrigger] TaskOrchestrationContext context) { string input = context.GetInput<string>()!; return await context.CallActivityAsync<string>(nameof(MyActivity), input); }
[Function(nameof(MyActivity))] public static string MyActivity([ActivityTrigger] string input) { return $"Processed: {input}"; }
Class-Based (With Source Generator)
Requires Microsoft.DurableTask.Generators package:
[DurableTask(nameof(MyOrchestration))] public class MyOrchestration : TaskOrchestrator<string, string> { public override async Task<string> RunAsync(TaskOrchestrationContext context, string input) { ILogger logger = context.CreateReplaySafeLogger<MyOrchestration>(); return await context.CallActivityAsync<string>(nameof(MyActivity), input); } }
[DurableTask(nameof(MyActivity))] public class MyActivity : TaskActivity<string, string> { private readonly ILogger<MyActivity> _logger;
// Activities support DI - orchestrations do NOT
public MyActivity(ILogger<MyActivity> logger)
{
_logger = logger;
}
public override Task<string> RunAsync(TaskActivityContext context, string input)
{
_logger.LogInformation("Processing: {Input}", input);
return Task.FromResult($"Processed: {input}");
}
}
Critical Rules
Orchestration Determinism
Orchestrations replay from history - all code MUST be deterministic. When an orchestration resumes, it replays all previous code to rebuild state. Non-deterministic code produces different results on replay, causing NonDeterministicOrchestrationException .
NEVER do inside orchestrations:
-
DateTime.Now , DateTime.UtcNow → Use context.CurrentUtcDateTime
-
Guid.NewGuid() → Use context.NewGuid()
-
Random → Pass random values from activities
-
Direct I/O, HTTP calls, database access → Move to activities
-
Thread.Sleep() , Task.Delay() → Use context.CreateTimer()
-
Non-deterministic LINQ (parallel, unordered)
-
Task.Run() , ConfigureAwait(false)
-
Static mutable variables
-
Environment variables that may change → Pass as input or use activities
ALWAYS safe:
-
context.CallActivityAsync<T>()
-
context.CallSubOrchestrationAsync<T>()
-
context.CallHttpAsync()
-
context.CreateTimer()
-
context.WaitForExternalEvent<T>()
-
context.CurrentUtcDateTime
-
context.NewGuid()
-
context.SetCustomStatus()
-
context.CreateReplaySafeLogger()
Non-Determinism Patterns (WRONG vs CORRECT)
Getting Current Time
// WRONG - DateTime.UtcNow returns different value on replay [Function(nameof(BadOrchestration))] public static async Task BadOrchestration([OrchestrationTrigger] TaskOrchestrationContext context) { DateTime currentTime = DateTime.UtcNow; // Non-deterministic! if (currentTime.Hour < 12) { await context.CallActivityAsync(nameof(MorningActivity), null); } }
// CORRECT - context.CurrentUtcDateTime replays consistently [Function(nameof(GoodOrchestration))] public static async Task GoodOrchestration([OrchestrationTrigger] TaskOrchestrationContext context) { DateTime currentTime = context.CurrentUtcDateTime; // Deterministic if (currentTime.Hour < 12) { await context.CallActivityAsync(nameof(MorningActivity), null); } }
Generating GUIDs
// WRONG - Guid.NewGuid() generates different value on replay [Function(nameof(BadOrchestration))] public static async Task<string> BadOrchestration([OrchestrationTrigger] TaskOrchestrationContext context) { string orderId = Guid.NewGuid().ToString(); // Non-deterministic! await context.CallActivityAsync(nameof(CreateOrder), orderId); return orderId; }
// CORRECT - context.NewGuid() replays the same value [Function(nameof(GoodOrchestration))] public static async Task<string> GoodOrchestration([OrchestrationTrigger] TaskOrchestrationContext context) { string orderId = context.NewGuid().ToString(); // Deterministic await context.CallActivityAsync(nameof(CreateOrder), orderId); return orderId; }
Random Numbers
// WRONG - Random produces different values on replay [Function(nameof(BadOrchestration))] public static async Task BadOrchestration([OrchestrationTrigger] TaskOrchestrationContext context) { int delay = new Random().Next(1, 10); // Non-deterministic! await context.CreateTimer(context.CurrentUtcDateTime.AddSeconds(delay), CancellationToken.None); }
// CORRECT - generate random in activity, pass to orchestrator [Function(nameof(GetRandomDelay))] public static int GetRandomDelay([ActivityTrigger] object? input) { return new Random().Next(1, 10); // OK in activity }
[Function(nameof(GoodOrchestration))] public static async Task GoodOrchestration([OrchestrationTrigger] TaskOrchestrationContext context) { int delay = await context.CallActivityAsync<int>(nameof(GetRandomDelay), null); await context.CreateTimer(context.CurrentUtcDateTime.AddSeconds(delay), CancellationToken.None); }
Sleeping/Delays
// WRONG - Thread.Sleep/Task.Delay don't persist and block [Function(nameof(BadOrchestration))] public static async Task BadOrchestration([OrchestrationTrigger] TaskOrchestrationContext context) { await context.CallActivityAsync(nameof(Step1), null); await Task.Delay(60000); // Non-durable! Lost on restart, wastes resources await context.CallActivityAsync(nameof(Step2), null); }
// CORRECT - context.CreateTimer is durable [Function(nameof(GoodOrchestration))] public static async Task GoodOrchestration([OrchestrationTrigger] TaskOrchestrationContext context) { await context.CallActivityAsync(nameof(Step1), null); await context.CreateTimer(context.CurrentUtcDateTime.AddMinutes(1), CancellationToken.None); // Durable await context.CallActivityAsync(nameof(Step2), null); }
HTTP Calls and I/O
// WRONG - HttpClient in orchestrator is non-deterministic [Function(nameof(BadOrchestration))] public static async Task<string> BadOrchestration([OrchestrationTrigger] TaskOrchestrationContext context) { using var client = new HttpClient(); var response = await client.GetStringAsync("https://api.example.com/data"); // Non-deterministic! return response; }
// CORRECT Option 1 - use CallHttpAsync (built-in durable HTTP) [Function(nameof(GoodOrchestration1))] public static async Task<string> GoodOrchestration1([OrchestrationTrigger] TaskOrchestrationContext context) { DurableHttpResponse response = await context.CallHttpAsync( HttpMethod.Get, new Uri("https://api.example.com/data")); // Deterministic return response.Content; }
// CORRECT Option 2 - move I/O to activity [Function(nameof(FetchData))] public static async Task<string> FetchData([ActivityTrigger] string url) { using var client = new HttpClient(); return await client.GetStringAsync(url); // OK in activity }
[Function(nameof(GoodOrchestration2))] public static async Task<string> GoodOrchestration2([OrchestrationTrigger] TaskOrchestrationContext context) { return await context.CallActivityAsync<string>(nameof(FetchData), "https://api.example.com/data"); }
Database Access
// WRONG - database query in orchestrator [Function(nameof(BadOrchestration))] public static async Task BadOrchestration([OrchestrationTrigger] TaskOrchestrationContext context) { using var conn = new SqlConnection(connectionString); // Non-deterministic! await conn.OpenAsync(); // ... }
// CORRECT - database access in activity [Function(nameof(GetUser))] public static async Task<User> GetUser([ActivityTrigger] string userId) { using var conn = new SqlConnection(connectionString); // OK in activity await conn.OpenAsync(); // ... return user; }
[Function(nameof(GoodOrchestration))] public static async Task<User> GoodOrchestration([OrchestrationTrigger] TaskOrchestrationContext context) { string userId = context.GetInput<string>()!; return await context.CallActivityAsync<User>(nameof(GetUser), userId); }
Environment Variables
// WRONG - env var might change between replays [Function(nameof(BadOrchestration))] public static async Task BadOrchestration([OrchestrationTrigger] TaskOrchestrationContext context) { string apiEndpoint = Environment.GetEnvironmentVariable("API_ENDPOINT")!; // Could change! await context.CallActivityAsync(nameof(CallApi), apiEndpoint); }
// CORRECT - pass config as input [Function(nameof(GoodOrchestration))] public static async Task GoodOrchestration([OrchestrationTrigger] TaskOrchestrationContext context) { var config = context.GetInput<WorkflowConfig>()!; string apiEndpoint = config.ApiEndpoint; // From input, deterministic await context.CallActivityAsync(nameof(CallApi), apiEndpoint); }
// ALSO CORRECT - read env var in activity [Function(nameof(CallApi))] public static async Task CallApi([ActivityTrigger] object? input) { string apiEndpoint = Environment.GetEnvironmentVariable("API_ENDPOINT")!; // OK in activity // make the call... }
Collection Iteration Order
// WRONG - Dictionary iteration order may vary [Function(nameof(BadOrchestration))] public static async Task BadOrchestration([OrchestrationTrigger] TaskOrchestrationContext context) { var items = context.GetInput<Dictionary<string, object>>()!; foreach (var key in items.Keys) // Order not guaranteed! { await context.CallActivityAsync(nameof(Process), key); } }
// CORRECT - use sorted keys for deterministic order [Function(nameof(GoodOrchestration))] public static async Task GoodOrchestration([OrchestrationTrigger] TaskOrchestrationContext context) { var items = context.GetInput<Dictionary<string, object>>()!; foreach (var key in items.Keys.OrderBy(k => k)) // Guaranteed order { await context.CallActivityAsync(nameof(Process), key); } }
Logging in Orchestrations
Use CreateReplaySafeLogger to avoid duplicate log entries during replay:
[Function(nameof(MyOrchestration))] public static async Task<string> MyOrchestration([OrchestrationTrigger] TaskOrchestrationContext context) { ILogger logger = context.CreateReplaySafeLogger(nameof(MyOrchestration)); logger.LogInformation("Orchestration started"); // Only logs once, not on each replay
var result = await context.CallActivityAsync<string>(nameof(MyActivity), "input");
logger.LogInformation("Activity completed with result: {Result}", result);
return result;
}
Error Handling
[Function(nameof(OrchestrationWithErrorHandling))] public static async Task<string> OrchestrationWithErrorHandling( [OrchestrationTrigger] TaskOrchestrationContext context) { string input = context.GetInput<string>()!; try { return await context.CallActivityAsync<string>(nameof(RiskyActivity), input); } catch (TaskFailedException ex) { // Activity failed - implement compensation context.SetCustomStatus(new { Error = ex.Message }); return await context.CallActivityAsync<string>(nameof(CompensationActivity), input); } }
Retry Policies
var options = new TaskOptions { Retry = new RetryPolicy( maxNumberOfAttempts: 3, firstRetryInterval: TimeSpan.FromSeconds(5), backoffCoefficient: 2.0, maxRetryInterval: TimeSpan.FromMinutes(1)) };
await context.CallActivityAsync<string>(nameof(UnreliableActivity), input, options);
HTTP Management APIs
Durable Functions exposes built-in HTTP APIs for orchestration management:
CreateCheckStatusResponse
[Function("HttpStart")] public static async Task<HttpResponseData> HttpStart( [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "orchestrators/{functionName}")] HttpRequestData req, [DurableClient] DurableTaskClient client, string functionName) { // Parse input from request body string? input = await new StreamReader(req.Body).ReadToEndAsync();
string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(functionName, input);
// Returns 202 Accepted with management URLs in response
return await client.CreateCheckStatusResponseAsync(req, instanceId);
}
Response includes:
-
statusQueryGetUri
-
GET endpoint to check status
-
sendEventPostUri
-
POST endpoint to raise events
-
terminatePostUri
-
POST endpoint to terminate
-
purgeHistoryDeleteUri
-
DELETE endpoint to purge history
Client Operations
[DurableClient] DurableTaskClient client
// Schedule new orchestration string instanceId = await client.ScheduleNewOrchestrationInstanceAsync("MyOrchestration", input);
// Schedule with custom instance ID string instanceId = await client.ScheduleNewOrchestrationInstanceAsync( "MyOrchestration", input, new StartOrchestrationOptions { InstanceId = "my-custom-id" });
// Get status OrchestrationMetadata? state = await client.GetInstanceAsync(instanceId, getInputsAndOutputs: true);
// Wait for completion OrchestrationMetadata? result = await client.WaitForInstanceCompletionAsync( instanceId, getInputsAndOutputs: true, cancellationToken);
// Raise external event await client.RaiseEventAsync(instanceId, "ApprovalEvent", approvalData);
// Terminate await client.TerminateInstanceAsync(instanceId, "User cancelled");
// Suspend/Resume await client.SuspendInstanceAsync(instanceId, "Pausing for maintenance"); await client.ResumeInstanceAsync(instanceId, "Resuming operation");
// Purge history await client.PurgeInstanceAsync(instanceId);
Connection & Authentication
Connection String Formats
// Local emulator (no auth) "Endpoint=http://localhost:8080;Authentication=None"
// Azure with Managed Identity (recommended for production) "Endpoint=https://my-scheduler.region.durabletask.io;Authentication=ManagedIdentity"
// Azure with specific client ID (user-assigned managed identity) "Endpoint=https://my-scheduler.region.durabletask.io;Authentication=ManagedIdentity;ClientId=<client-id>"
Note: Durable Task Scheduler supports identity-based authentication only - no connection strings with keys.
Local Development with Emulator
Start Azurite (required for Azure Functions)
azurite start
Pull and run the Durable Task Scheduler emulator
docker pull mcr.microsoft.com/dts/dts-emulator:latest docker run -d -p 8080:8080 -p 8082:8082 --name dts-emulator mcr.microsoft.com/dts/dts-emulator:latest
Dashboard available at http://localhost:8082
Start the function app
func start
Durable HTTP Calls
Make HTTP calls directly from orchestrations (persisted and replay-safe):
[Function(nameof(CallExternalApi))] public static async Task<string> CallExternalApi([OrchestrationTrigger] TaskOrchestrationContext context) { // Simple GET DurableHttpResponse response = await context.CallHttpAsync(HttpMethod.Get, new Uri("https://api.example.com/data"));
if (response.StatusCode != HttpStatusCode.OK)
{
throw new Exception($"API call failed: {response.StatusCode}");
}
return response.Content;
}
// With headers and body var request = new DurableHttpRequest( HttpMethod.Post, new Uri("https://api.example.com/data")) { Headers = { ["Content-Type"] = "application/json" }, Content = JsonSerializer.Serialize(payload) };
DurableHttpResponse response = await context.CallHttpAsync(request);
// With managed identity authentication var request = new DurableHttpRequest( HttpMethod.Get, new Uri("https://management.azure.com/...")) { TokenSource = new ManagedIdentityTokenSource("https://management.azure.com/.default") };
References
-
patterns.md - Detailed pattern implementations (Fan-Out/Fan-In, Human Interaction, Entities, Sub-Orchestrations, Monitor)
-
setup.md - Azure Durable Task Scheduler provisioning, deployment, and project templates