Home β€Ί .NET on AWS β€Ί Using Lambda with .NET

Using Lambda with .NET

Building AWS Lambda functions in .NET: handlers, DI, Native AoT, cold starts, and deployment patterns.

.NET on Lambda: The Options

Lambda is the natural compute choice for .NET on AWS. No servers, per-invocation billing, automatic scaling. But .NET has historically been Lambda's awkward child. Cold starts of 2-3 seconds made it impractical for synchronous APIs. The runtime needed more memory than Node or Python. AWS documentation assumes you're using TypeScript.

That changed with .NET 8+ and Native AoT. Cold starts drop to 200-400ms. Memory usage drops 50-70%. Execution speed matches or beats Node.js. The trade-off: you lose reflection, which breaks traditional DI containers, JSON serializers, and most ORMs. This page covers both approaches. Managed runtime (easier, slower cold starts) and AoT (fast, requires more discipline).

Handler Patterns

Managed runtime (class-based handler)

using Amazon.Lambda.Core;
using Amazon.Lambda.APIGatewayEvents;

[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))]

public class Function
{
    private readonly IOrderService _orderService;

    public Function()
    {
        // Constructor runs once per cold start
        var services = new ServiceCollection();
        services.AddSingleton<IOrderService, OrderService>();
        services.AddSingleton<IAmazonDynamoDB, AmazonDynamoDBClient>();
        
        var provider = services.BuildServiceProvider();
        _orderService = provider.GetRequiredService<IOrderService>();
    }

    public async Task<APIGatewayProxyResponse> Handler(
        APIGatewayProxyRequest request, 
        ILambdaContext context)
    {
        var orderId = request.PathParameters["id"];
        var order = await _orderService.GetOrderAsync(orderId);
        
        return new APIGatewayProxyResponse
        {
            StatusCode = 200,
            Body = JsonSerializer.Serialize(order),
            Headers = new Dictionary<string, string>
            {
                ["Content-Type"] = "application/json"
            }
        };
    }
}

Native AoT (top-level handler)

using Amazon.Lambda.Core;
using Amazon.Lambda.APIGatewayEvents;
using Amazon.Lambda.RuntimeSupport;
using Amazon.Lambda.Serialization.SystemTextJson;
using System.Text.Json.Serialization;

// Source-generated serializer β€” no reflection
[JsonSerializable(typeof(APIGatewayProxyRequest))]
[JsonSerializable(typeof(APIGatewayProxyResponse))]
[JsonSerializable(typeof(Order))]
internal partial class AppJsonContext : JsonSerializerContext { }

// Bootstrap
var handler = async (APIGatewayProxyRequest request, ILambdaContext context) =>
{
    var orderId = request.PathParameters["id"];
    var order = await orderService.GetOrderAsync(orderId);
    
    return new APIGatewayProxyResponse
    {
        StatusCode = 200,
        Body = JsonSerializer.Serialize(order, AppJsonContext.Default.Order),
    };
};

await LambdaBootstrapBuilder
    .Create(handler, new SourceGeneratorLambdaJsonSerializer<AppJsonContext>())
    .Build()
    .RunAsync();

Lambda Annotations (simplified handlers)

using Amazon.Lambda.Annotations;
using Amazon.Lambda.Annotations.APIGateway;

public class OrderFunctions
{
    private readonly IOrderService _orderService;

    public OrderFunctions(IOrderService orderService)
    {
        _orderService = orderService;
    }

    [LambdaFunction]
    [HttpApi(LambdaHttpMethod.Get, "/orders/{id}")]
    public async Task<Order> GetOrder(string id)
    {
        return await _orderService.GetOrderAsync(id);
    }
}

Lambda Annotations use source generators to create the boilerplate handler code and a serverless application template at build time. Good developer experience, though the generated code may not be AoT-compatible depending on your dependencies.

Dependency Injection

Managed runtime: Microsoft.Extensions.DependencyInjection

public class Function
{
    private static readonly ServiceProvider _provider;

    static Function()
    {
        var services = new ServiceCollection();
        services.AddSingleton<IAmazonDynamoDB, AmazonDynamoDBClient>();
        services.AddSingleton<IOrderRepository, OrderRepository>();
        services.AddSingleton<IOrderService, OrderService>();
        _provider = services.BuildServiceProvider();
    }

    private readonly IOrderService _service = _provider.GetRequiredService<IOrderService>();

    public async Task<APIGatewayProxyResponse> Handler(
        APIGatewayProxyRequest request, ILambdaContext context)
    {
        // use _service
    }
}

AoT: Manual composition

Under AoT, avoid Microsoft.Extensions.DependencyInjection if it uses reflection for service resolution. Instead, compose manually:

// Compose at startup β€” runs once per cold start
var dynamoClient = new AmazonDynamoDBClient();
var repository = new OrderRepository(dynamoClient);
var orderService = new OrderService(repository);

var handler = async (APIGatewayProxyRequest request, ILambdaContext context) =>
{
    // use orderService directly
};

This feels less elegant but it's predictable, fast, and compiles under AoT without surprises.

Cold Start Optimization

Approach Cold Start Trade-off
Managed runtime (.NET 8) 1.5-3s Full .NET feature set
Native AoT 200-400ms No reflection, limited libraries
Provisioned Concurrency ~0ms Paying for always-warm instances
SnapStart (not yet for .NET) . Java/Python only currently

Tips for reducing cold starts

  1. Use AoT for API-facing functions where latency matters
  2. Minimize dependencies. Each NuGet package adds init time
  3. Use Graviton (arm64): 20% cheaper and slightly faster cold starts
  4. Avoid loading configuration from remote sources at startup. Use environment variables
  5. Keep deployment packages small. Trim unused assemblies

Project File for AoT Lambda

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <OutputType>Exe</OutputType>
    <PublishAot>true</PublishAot>
    <StripSymbols>true</StripSymbols>
    <!-- Lambda runs on Linux -->
    <RuntimeIdentifier>linux-arm64</RuntimeIdentifier>
    <!-- Trim aggressively -->
    <TrimMode>link</TrimMode>
    <InvariantGlobalization>true</InvariantGlobalization>
  </PropertyGroup>
  
  <ItemGroup>
    <PackageReference Include="Amazon.Lambda.RuntimeSupport" Version="1.11.0" />
    <PackageReference Include="Amazon.Lambda.Serialization.SystemTextJson" Version="2.4.4" />
    <PackageReference Include="Amazon.Lambda.APIGatewayEvents" Version="2.7.1" />
  </ItemGroup>
</Project>

CDK Setup (C#)

using Amazon.CDK;
using Amazon.CDK.AWS.Lambda;

var orderFunction = new Function(this, "GetOrder", new FunctionProps
{
    Runtime = Runtime.DOTNET_8,
    Architecture = Architecture.ARM_64,
    Handler = "MyApp::MyApp.Function::Handler",
    Code = Code.FromAsset("./src/OrderApi/publish"),
    MemorySize = 256,
    Timeout = Duration.Seconds(30),
    Environment = new Dictionary<string, string>
    {
        ["TABLE_NAME"] = table.TableName,
    },
    Tracing = Tracing.ACTIVE,
});

// For AoT functions, use a custom runtime
var aotFunction = new Function(this, "GetOrderAot", new FunctionProps
{
    Runtime = Runtime.PROVIDED_AL2023,
    Architecture = Architecture.ARM_64,
    Handler = "bootstrap", // AoT produces a native binary
    Code = Code.FromAsset("./src/OrderApi/publish"),
    MemorySize = 128, // AoT needs less memory
    Timeout = Duration.Seconds(10),
});

// Grant permissions
table.GrantReadData(orderFunction);

Deployment

Publishing for managed runtime

dotnet publish -c Release -o publish

Publishing for AoT

dotnet publish -c Release -r linux-arm64 --self-contained

This produces a native Linux binary. You deploy it as a custom runtime (provided.al2023) and the handler is just bootstrap.

Common Pitfalls

  • Don't use IHostBuilder patterns: Lambda isn't a long-running host. The function is invoked, runs, and goes idle.
  • Watch your memory setting: Lambda allocates CPU proportionally to memory. At 128MB you get a fraction of a vCPU. For .NET, 256-512MB is usually the sweet spot for cost/performance.
  • Avoid synchronous SDK calls: always use Async methods. Lambda's concurrency model is one invocation per instance, but async I/O still matters for throughput.
  • Test locally with the Lambda Test Tool: dotnet lambda-test-tool-8.0 gives you a local execution environment without deploying.

Further Reading

Looking for hands-on help? View my .NET on AWS services β†’

Lambda cold starts killing your APIs?

Drop me a message β€” I typically respond within one business day.