AWS Developer Tools Blog
AWS Durable Execution SDK for .NET now Generally Available
The AWS Lambda Durable Execution SDK for .NET is now generally available. AWS Lambda now supports durable executions for .NET, joining the existing Python, TypeScript, and Java SDKs. You install the SDK from NuGet, write your workflows in familiar C#, and deploy them with the AWS Extensions for .NET CLI – the same tools you already use for Lambda functions today.
Your multi-step processes that charge cards, wait for approvals, or call external APIs need to survive interruptions. If your Lambda function times out or crashes between steps, you lose the work it already completed unless you build something to preserve it. On Lambda today, you build that yourself. You persist which steps completed and what each one returned. You retry failed calls, re-trigger the function after a delay, and determine where to resume when the next invocation starts. Every new workflow means more of this plumbing, more edge cases in the resume logic, and more code unrelated to your actual goal.
The AWS Durable Execution SDK handles all that coordination for you. You write your workflow in regular C# syntax. The SDK checkpoints progress after each step, retries failures with configurable backoff, and suspends execution during waits for up to a year without billing you for compute while paused. If something interrupts your function, Lambda re-invokes it. The SDK replays from the last checkpoint and returns the cached result for every step that already completed. Your code reads like straightforward sequential logic. The checkpointing, replay, and recovery happen underneath.
In this post, you build an order processing workflow with the SDK. You use steps, retries, waits, and child contexts, then deploy it to the managed dotnet10 runtime.
How Lambda durable functions work
Lambda durable functions extend the familiar Lambda programming model you already use. Your handler receives an IDurableContext parameter. Its methods provide the durable operations: run checkpointed steps, wait, and receive external callbacks. The runtime uses a checkpoint-and-replay mechanism: after each operation completes, Lambda checkpoints the result. The AWS Durable Execution SDK makes your long-running workflows resilient automatically, so you do not have to write your own state management code.
The core operations on IDurableContext are:
- Steps:
context.StepAsyncruns a unit of work, checkpoints its result, and retries with configurable strategies. On replay, the step returns its cached value instead of running again. - Waits:
context.WaitAsyncsuspends the workflow for a duration, from one second up to a year. - Child contexts:
context.RunInChildContextAsyncgroups related steps into a single logical operation that checkpoints them together. - Callbacks:
context.WaitForCallbackAsyncsuspends the workflow until an external system, such as a human approver, a webhook, or another service, delivers a result. - Parallel and map:
context.ParallelAsyncandcontext.MapAsyncdistribute independent branches concurrently and aggregate their results.
Because the workflow code re-runs from the top on every invocation, it must be deterministic: the same operations, in the same order, on each replay.
Prerequisites
Before you begin, make sure you have the following:
- Install the .NET 10 SDK or later.
- Configure AWS credentials by running
aws configureor set up credentials using your preferred method. For more information, see Configuring the AWS SDK for .NET. - Use credentials with permission to deploy and invoke Lambda functions and to create the function’s execution role. For more information, see Durable functions security.
Getting started
The Durable Function blueprint ships with the Amazon.Lambda.Templates package and scaffolds a complete, deployable workflow. The following .NET CLI commands install the lambda tooling and create a new project from the Durable Function blueprint:
dotnet tool install -g Amazon.Lambda.Tools
dotnet new install Amazon.Lambda.Templates
dotnet new lambda.DurableFunction -n OrderProcessor
cd OrderProcessor
To deploy with a CloudFormation serverless.template using Lambda Annotations, use the serverless.DurableFunction blueprint instead. To add the SDK to an existing project, run the following command:
dotnet add package Amazon.Lambda.DurableExecution
Building a production-ready order processing workflow
The blueprint scaffolds a complete order processing workflow in Function.cs. The function validates an order, charges payment, waits out a settlement period, and ships the order, all as one method.
Setup and entry point
Handler is the Lambda entry point the managed runtime invokes directly (via the Assembly::Type::Method handler string in aws-lambda-tools-defaults.json). DurableFunction.WrapAsync bridges the durable invocation envelope to your strongly-typed ProcessOrder workflow, so the workflow itself works with OrderRequest and OrderResult instead of the raw envelope.
using Amazon.Lambda.Core;
using Amazon.Lambda.DurableExecution;
using Amazon.Lambda.Serialization.SystemTextJson;
using Microsoft.Extensions.Logging;
// The durable runtime reads this serializer off ILambdaContext.Serializer to (de)serialize the
// invocation envelope and every checkpointed step input/output.
[assembly: LambdaSerializer(typeof(DefaultLambdaJsonSerializer))]
namespace OrderProcessor;
public class Function
{
public Task<DurableExecutionInvocationOutput> Handler(
DurableExecutionInvocationInput input, ILambdaContext context)
=> DurableFunction.WrapAsync<OrderRequest, OrderResult>(ProcessOrder, input, context);
The workflow method
ProcessOrder receives the order and an IDurableContext, and the following snippets show the method body. Because the workflow re-runs from the top on every invocation, always log through context.Logger. This logger suppresses log lines during replay, so a line appears once even if a 30-step workflow replays 30 times.
public async Task<OrderResult> ProcessOrder(OrderRequest order, IDurableContext context)
{
// The durable logger is replay-aware: this line is emitted once, not once per replay.
context.Logger.LogInformation("Processing order {OrderId}", order.OrderId);
Step 1: Validate the order
This is a standard step. StepAsync runs the body, checkpoints the result, and on replay returns the cached value instead of running the body again. The body receives an IStepContext (its own replay-aware logger, the 1-based attempt number, and an operation ID) and a CancellationToken linked to the workflow-shutdown signal. Each StepAsync call is a checkpoint boundary.
var itemCount = await context.StepAsync(
async (step, _) =>
{
await Task.CompletedTask;
step.Logger.LogInformation("Validating order with {Count} item(s)", order.Items?.Length ?? 0);
if (order.Items is null || order.Items.Length == 0)
throw new InvalidOperationException("Order has no items.");
return order.Items.Length;
},
name: "validate_order");
Step 2: Charge payment with retries
Payment gateways can return transient errors, so this step carries a retry policy. RetryStrategy.Exponential retries transient failures with backoff and jitter, and the SDK checkpoints only the successful attempt. The built-in RetryStrategy.Default, RetryStrategy.Transient, and RetryStrategy.None presets cover common cases.
StepSemantics.AtMostOncePerRetry keeps the SDK from silently re-running an attempt’s body if the function is interrupted mid-execution. Use it for non-idempotent side effects like charging a card or sending email, and use the default AtLeastOncePerRetry for idempotent operations.
Note: AtMostOncePerRetry limits re-execution within a single attempt, not across the whole step. A step with retries configured can still run its body again on a later attempt. When an attempt is interrupted, it surfaces as a StepInterruptedException and the retry strategy decides whether to start a new one. If you need the charge to run exactly once, combine AtMostOncePerRetry with RetryStrategy.None.
var transactionId = await context.StepAsync(
async (step, ct) =>
{
// step.AttemptNumber is 1-based and increments on each retry, so log it so retries
// are visible in the history. Forward ct to any cancellation-aware call (HttpClient,
// the AWS SDK, Task.Delay) so the body unwinds cleanly if the workflow is torn down.
step.Logger.LogInformation("Charging payment, attempt {Attempt}", step.AttemptNumber);
await Task.Delay(TimeSpan.FromMilliseconds(50), ct);
return $"txn-{order.OrderId}";
},
name: "charge_payment",
config: new StepConfig
{
RetryStrategy = RetryStrategy.Exponential(
maxAttempts: 5,
initialDelay: TimeSpan.FromSeconds(2),
maxDelay: TimeSpan.FromSeconds(30),
backoffRate: 2.0),
Semantics = StepSemantics.AtMostOncePerRetry,
});
Step 3: Wait out the settlement period
WaitAsync suspends the workflow for a fixed delay, anywhere from one second up to a year, and the runtime re-invokes the function when the timer fires. While the workflow is suspended, you are not billed for compute.
await context.WaitAsync(TimeSpan.FromSeconds(5), name: "settlement_delay");
Step 4: Ship the order in a child context
RunInChildContextAsync groups related steps into a single logical operation that checkpoints together. The pack and label steps run inside a nested IDurableContext with its own operation-ID space. Checkpoints are what make the crash story work: if the function crashes after charge_payment succeeds but before shipping, replay returns the cached transaction ID and resumes at the wait. The SDK never re-runs a charge that has already succeeded.
var trackingId = await context.RunInChildContextAsync(
async (childContext, _) =>
{
await childContext.StepAsync(
async (step, _) =>
{
await Task.CompletedTask;
step.Logger.LogInformation("Packing order {OrderId}", order.OrderId);
return "packed";
},
name: "pack");
return await childContext.StepAsync(
async (_, _) => { await Task.CompletedTask; return $"trk-{order.OrderId}"; },
name: "label");
},
name: "ship_order");
Returning the result
The workflow finishes by logging completion and returning an OrderResult built from the values each step produced.
context.Logger.LogInformation("Order {OrderId} shipped: {TrackingId}", order.OrderId, trackingId);
return new OrderResult
{
OrderId = order.OrderId,
Status = "shipped",
ItemCount = itemCount,
TransactionId = transactionId,
TrackingId = trackingId,
};
The workflow returns a plain OrderResult. The input and output are simple POCOs:
/// <summary>Input payload for the workflow.</summary>
public class OrderRequest
{
public string? OrderId { get; set; }
public string[]? Items { get; set; }
}
/// <summary>Output payload returned when the workflow completes.</summary>
public class OrderResult
{
public string? OrderId { get; set; }
public string? Status { get; set; }
public int ItemCount { get; set; }
public string? TransactionId { get; set; }
public string? TrackingId { get; set; }
}
Adding a human approval step
The scaffolded workflow runs start to finish on its own. Real order pipelines often need a person in the loop: a manager signs off on a high-value order before it ships. Let’s edit the workflow to pause for that approval.
The following C# snippet adds a callback between the payment and settlement steps in ProcessOrder:
// Pause until a manager approves. The submitter runs once, handing the callback ID
// to whatever will resolve it later: a queue, a webhook, an approval UI.
var approval = await context.WaitForCallbackAsync<ApprovalResult>(
submitter: async (callbackId, cbContext, ct) =>
{
await notificationService.RequestApprovalAsync(order.OrderId, callbackId, ct);
},
name: "manager_approval");
notificationService and ApprovalResult here are placeholders for your own integration: the way you notify an approver that a decision is needed, and the shape of the result they send back. This snippet is an example showing where that integration plugs in. Replace these with the notification and response mechanisms your system uses.
When the workflow reaches this point it suspends and waits. The approval system resolves the callback by calling SendDurableExecutionCallbackSuccess (or SendDurableExecutionCallbackFailure) with that ID, and the workflow resumes from exactly where it was suspended. The approval can arrive seconds later or days later.
Deploying the workflow
Durable functions run on the managed dotnet10 runtime and deploy as a standard .zip package. A durable execution always runs against a published function version, so pass --function-publish to publish a numbered version when you deploy. The following .NET CLI command deploys and publishes the function:
dotnet lambda deploy-function --function-publish True
You deploy a durable function with the same command as a standard Lambda function. The only difference is two configuration settings that Amazon.Lambda.Tools exposes:
--durable-execution-timeout(durable-execution-timeoutinaws-lambda-tools-defaults.json) – the maximum time in seconds a single durable execution may run before it times out. The blueprint sets86400(one day).--durable-retention-period(durable-retention-period) – optional; the number of days to retain execution history after an execution completes.
An execution timeout is required for a durable function, so always set --durable-execution-timeout.
When the tool creates the function’s execution role for you, it automatically attaches the AWSLambdaBasicDurableExecutionRolePolicy managed policy, which grants the checkpoint permissions a durable function needs at runtime. If you supply your own role with --function-role, attach that policy to it.
Then invoke the function with a sample order. You invoke durable functions asynchronously, so pass --invoke-mode DurableExecution. The following .NET CLI command deploys and invokes the function, streaming the operation history as it goes:
dotnet lambda invoke-function OrderProcessor --payload '{"OrderId":"order-123","Items":["sku-1","sku-2"]}' --invoke-mode DurableExecution
The InvocationCompleted event immediately following settlement_delay: WaitStarted indicates that the function has suspended during the wait. A second invocation then resumes at ship_order and does not repeat the earlier steps. The following output shows the complete execution history for a sample order:
Resolved latest version 1 for function OrderProcessor to ARN: arn:aws:lambda:us-west-2:123456789012:function:OrderProcessor:1
Durable execution ARN: arn:aws:lambda:us-west-2:123456789012:function:OrderProcessor:1/durable-execution/ad36ac33-2bb4-4d90-841a-c5491cb6e6ea/5c4aac8e-26c1-3403-9a1b-18c1a2cd0389
Monitoring durable execution progress:
[2026-07-06 18:18:34Z] ad36ac33-2bb4-4d90-841a-c5491cb6e6ea: ExecutionStarted
Execution Timeout: 86400
Input: {"OrderId":"order-123","Items":["sku-1","sku-2"]}
[2026-07-06 18:18:36Z] validate_order: StepStarted
[2026-07-06 18:18:36Z] validate_order: StepSucceeded
Result: 2
Current Attempt: 1
[2026-07-06 18:18:36Z] charge_payment: StepStarted
[2026-07-06 18:18:36Z] charge_payment: StepSucceeded
Result: "txn-order-123"
Current Attempt: 1
[2026-07-06 18:18:36Z] settlement_delay: WaitStarted
Duration: 5
Scheduled End Timestamp: 7/6/2026 6:18:41 PM
[2026-07-06 18:18:36Z] : InvocationCompleted
Request Id: 851f7662-5f0f-4381-a2eb-0390668411c9
Start Timestamp: 7/6/2026 6:18:34 PM
End Timestamp: 7/6/2026 6:18:36 PM
[2026-07-06 18:18:41Z] settlement_delay: WaitSucceeded
Duration: 5
[2026-07-06 18:18:41Z] ship_order: ContextStarted
[2026-07-06 18:18:41Z] pack: StepStarted
[2026-07-06 18:18:41Z] pack: StepSucceeded
Result: "packed"
Current Attempt: 1
[2026-07-06 18:18:41Z] label: StepStarted
[2026-07-06 18:18:41Z] label: StepSucceeded
Result: "trk-order-123"
Current Attempt: 1
[2026-07-06 18:18:42Z] ship_order: ContextSucceeded
Result: "trk-order-123"
[2026-07-06 18:18:42Z] : InvocationCompleted
Request Id: 764c6852-b351-4385-aac6-86e08d5de914
Start Timestamp: 7/6/2026 6:18:41 PM
End Timestamp: 7/6/2026 6:18:42 PM
[2026-07-06 18:18:42Z] ad36ac33-2bb4-4d90-841a-c5491cb6e6ea: ExecutionSucceeded
Result: {"OrderId":"order-123","Status":"shipped","ItemCount":2,"TransactionId":"txn-order-123","TrackingId":"trk-order-123"}
Durable execution finished with status: SUCCEEDED
Result:
{"OrderId":"order-123","Status":"shipped","ItemCount":2,"TransactionId":"txn-order-123","TrackingId":"trk-order-123"}
The execution history captures every step, wait, and child context with its result. You can retrieve this history at any time through the durable execution APIs or the Lambda console.
Testing locally
You can test your durable workflows locally with the Amazon.Lambda.DurableExecution. Testing package, no AWS resources required. DurableTestRunner runs your handler in-process against the real durable runtime with an in-memory backend. You can assert on the result and on individual checkpointed steps. This approach enables fast iteration and is especially valuable for agentic workflows with complex execution graphs. The following .NET CLI commands add a test project with the required packages:
dotnet add package Amazon.Lambda.DurableExecution.Testing
dotnet add package xunit.v3
dotnet add package xunit.runner.visualstudio
The following C# test class covers the scaffolded workflow end-to-end, including the happy path and the empty-order failure:
using Amazon.Lambda.DurableExecution.Testing;
using Xunit;
public class FunctionTest
{
[Fact]
public async Task ProcessOrder_ShipsOrder()
{
var function = new Function();
// The runner drives the workflow to completion in-process using the real durable runtime
// with an in-memory backend. SkipTime collapses the settlement WaitAsync delay so the test
// does not actually block for 5 seconds.
await using var runner = new DurableTestRunner<OrderRequest, OrderResult>(
handler: function.ProcessOrder,
options: new TestRunnerOptions { SkipTime = true });
var input = new OrderRequest { OrderId = "order-123", Items = new[] { "sku-1", "sku-2" } };
// TestContext.Current.CancellationToken is xunit.v3's per-test token; the runner honors it.
var result = await runner.RunAsync(input, cancellationToken: TestContext.Current.CancellationToken);
result.EnsureSucceeded();
Assert.Equal("order-123", result.Result!.OrderId);
Assert.Equal("shipped", result.Result.Status);
Assert.Equal(2, result.Result.ItemCount);
Assert.Equal("txn-order-123", result.Result.TransactionId);
Assert.Equal("trk-order-123", result.Result.TrackingId);
// Each named operation is checkpointed and inspectable.
Assert.Equal(OperationStatus.Succeeded, result.GetStep("validate_order").Status);
Assert.Equal(OperationStatus.Succeeded, result.GetStep("charge_payment").Status);
}
[Fact]
public async Task ProcessOrder_EmptyOrder_Fails()
{
var function = new Function();
await using var runner = new DurableTestRunner<OrderRequest, OrderResult>(
handler: function.ProcessOrder,
options: new TestRunnerOptions { SkipTime = true });
var input = new OrderRequest { OrderId = "order-456", Items = System.Array.Empty<string>() };
var result = await runner.RunAsync(input, cancellationToken: TestContext.Current.CancellationToken);
Assert.True(result.IsFailed);
}
}
Run the tests with the following .NET CLI command:
dotnet test
If you added the approval step, the workflow suspends on the callback instead of completing, so RunAsync no longer applies. The following C# example starts the workflow, waits for it to reach the callback, resolves it, and then waits for the result:
var arn = await runner.StartAsync(input);
var callbackId = await runner.WaitForCallbackAsync(arn, name: "manager_approval");
await runner.SendCallbackSuccessAsync(callbackId, new ApprovalResult("approved", "manager-1"));
var result = await runner.WaitForResultAsync(arn);
result.EnsureSucceeded();
Clean up
If you deployed the function, delete it when you finish to avoid incurring charges:
dotnet lambda delete-function OrderProcessor
If the tooling created an execution role for you and you no longer need it, delete the role and its attached policy as well. The local tests run entirely in-memory and create no AWS resources, so there is nothing to clean up for those.
Conclusion
This order workflow is a starting point. You can use these building blocks to compose much more complex workflows and sub-workflows. Checkpoints mean a crash resumes from the last completed step instead of the top. Configurable retries with at-most-once semantics mean an unreliable payment call never charges a card twice. A suspended workflow can pause for minutes or days without incurring compute charges. You get durable orchestration in the language and tools you already use, instead of custom state management and failure-handling code.
Next steps:
- Create a project from the
lambda.DurableFunctionblueprint and deploy your first workflow to the manageddotnet10runtime. - Explore the SDK on GitHub for advanced patterns like
InvokeAsyncandWaitForConditionAsync, and refer to the README for more documentation. - Read the AWS Durable Execution SDK Developer Guide.
- Check the AWS Lambda Pricing page to see how paused executions are billed.
- Create an issue or a pull request if you have ideas for improvements.
Happy building!
– Garrett