Native AOT for .NET AWS Lambda functions
When I switch a Lambda function to Native AOT, I don’t want to rewrite the application.
I still want the same handler, the same dependency injection setup and the same programming model I would use in a regular .NET Lambda function.
What has to change is everything around that code.
A Native AOT Lambda needs to be published as an executable, start the Lambda runtime loop explicitly and make its serialization requirements known at build time. For functions that decode nested payloads, such as messages inside SQS or SNS records, there may even be more than one serialization boundary to consider.
Those are hosting and serialization concerns. They should not leak into the business logic.
Starting with v6.0.0-beta.3, Kralizek.Lambda.Templates exposes Native AOT as a template-time choice:
dotnet new lambda-template-sqs --aot
The important part is what this command does not do.
It does not introduce another runtime package. It does not introduce another base class. It does not create an alternative handler model.
The generated function uses the same programming model as its non-AOT counterpart.
Let’s look at what actually changes.
Start with an SQS function
Consider a typed SQS function processing OrderCreated messages:
public sealed class Function
: SqsFunction<OrderCreated, OrderCreatedHandler>
{
protected override void ConfigureFrameworkServices(IServiceCollection services) =>
services.AddSingleton(
PayloadJsonSerializerContext.Default.OrderCreated);
}
The handler itself looks exactly like it would in a regular function:
public sealed class OrderCreatedHandler(IOrderRepository orders)
: ISqsMessageHandler<OrderCreated>
{
public async ValueTask<SqsRecordResult> HandleAsync(
OrderCreated message,
SqsMessageContext context,
CancellationToken cancellationToken)
{
await orders.CreateAsync(message.OrderId, cancellationToken);
return SqsRecordResult.Success;
}
}
There is nothing AOT-specific in that handler.
That is intentional.
SqsFunction, record processing, dependency injection and the handler contract belong to the programming model. Native AOT changes how the function reaches AWS Lambda, not how the handler is written.
What --aot actually changes
A regular .NET Lambda generated by the templates is a class library.
AWS invokes a handler identified by assembly, type and method:
LambdaFunctionProject::LambdaFunctionProject.Function::FunctionHandlerAsync
and the assembly-level LambdaSerializer attribute selects the serializer.
With Native AOT, the generated project instead becomes an executable and gets a Program.cs that starts the Lambda runtime loop explicitly:
internal static class Program
{
private static async Task Main()
{
var function = new Function();
var serializer =
new SourceGeneratorLambdaJsonSerializer<
LambdaJsonSerializerContext>();
var bootstrap =
LambdaBootstrapBuilder.Create<
SQSEvent,
SQSBatchResponse>(
function.FunctionHandlerAsync,
serializer);
await bootstrap
.Build()
.RunAsync()
.ConfigureAwait(false);
}
}
The interesting line is still:
function.FunctionHandlerAsync
The bootstrap invokes the same inherited handler used by the rest of the framework.
Native AOT changes the host around the function rather than introducing an AOT version of the function model.
Keep the hosting code out of Function.cs
I wanted the generated Function.cs to remain focused on the application-facing part of the function.
The executable bootstrap, Lambda serializer and serialization metadata live in Program.cs.
That separation matters because most application changes should still happen in the same places whether AOT is enabled or not.
Function.cs configures the function.
The handler implements the application behavior.
Program.cs describes how AWS Lambda hosts it.
For most generated functions, there should be very little reason to touch that hosting code.
The Lambda boundary needs serialization metadata
Native AOT removes one convenience we normally take for granted with System.Text.Json: discovering serialization metadata through reflection at runtime.
The generated host therefore contains a source-generated JSON context for the types crossing the Lambda boundary.
For our SQS function that means:
[JsonSerializable(typeof(SQSEvent))]
[JsonSerializable(typeof(SQSBatchResponse))]
internal partial class LambdaJsonSerializerContext
: JsonSerializerContext;
The template can generate this because the selected function type already determines the Lambda contract.
An SQS function receives SQSEvent and returns SQSBatchResponse.
A request function knows its request and response types.
An EventBridge function knows its outer event type.
This is infrastructure metadata implied by the selected Lambda model, so the generated host can own it.
Typed record functions introduce another boundary.
An SQS message contains another payload
When a typed SQS function is processed, two separate deserialization steps happen.
First, the Lambda host turns the incoming JSON document into an SQSEvent.
Then the framework takes the body of each SQS record and decodes it into the application type:
Lambda JSON
↓
SQSEvent
↓
SQS record body
↓
OrderCreated
↓
OrderCreatedHandler
The first boundary belongs to the Lambda host.
The second belongs to the application.
In a normal JIT-compiled application, the JSON payload decoder can fall back to reflection when no explicit metadata is available.
Native AOT removes that fallback.
The application therefore needs serialization metadata for OrderCreated as well.
The typed template generates another context next to the handler:
[JsonSerializable(typeof(OrderCreated))]
internal partial class PayloadJsonSerializerContext
: JsonSerializerContext;
and registers its JsonTypeInfo<OrderCreated> with the framework:
protected override void ConfigureFrameworkServices(
IServiceCollection services) =>
services.AddSingleton(
PayloadJsonSerializerContext.Default.OrderCreated);
This creates a boundary I particularly like:
the generated host owns the Lambda contract; application code owns the application payload contract.
If I replace OrderCreated with my actual application message, I update the payload context next to that code.
I don’t need to know which AWS envelope types belong in the Lambda serializer.
Raw functions avoid the second boundary
This also makes the --raw option interesting.
A raw SQS function does not ask the framework to deserialize the message body into an application type:
dotnet new lambda-template-sqs --aot --raw
The handler receives the AWS record directly.
There is therefore no nested application payload for the framework to deserialize and no application JsonSerializerContext to maintain.
The Lambda host still needs source-generated metadata for SQSEvent and SQSBatchResponse, but that metadata is generated by the template.
--raw and --aot solve two different problems.
One decides which handler contract I want.
The other decides how the application is hosted.
Because those concerns are separate, the options compose naturally.
AOT does not need another runtime package
It would have been possible to make Native AOT part of the runtime surface by introducing a separate package or another family of base classes.
But very little of what changes actually belongs there.
The runtime still processes the same function, creates the same scopes, resolves the same handlers, decodes the same records and interprets the same result types.
What changes is mostly generated project infrastructure:
- the project publishes as an executable;
PublishAotis enabled;Program.cshosts the Lambda runtime loop;- source-generated serialization metadata is used at the Lambda boundary;
- the deployment handler becomes the executable assembly name;
- publishing is self-contained for the Lambda target platform.
That makes AOT a much better fit for a template option than for another programming model.
OpenTelemetry still fits around the same handler
The same separation becomes useful when Native AOT and OpenTelemetry are enabled together:
dotnet new lambda-template-sqs --aot --otel
I covered the OpenTelemetry model in OpenTelemetry for .NET AWS Lambda functions.
The interesting part here is that neither feature needs a special integration with the other.
With --otel, FunctionHandlerAsync wraps the inherited implementation using AWSLambdaWrapper.TraceAsync.
With --aot, Program.cs gives that same FunctionHandlerAsync method to LambdaBootstrapBuilder.
The resulting call chain is conceptually:
LambdaBootstrap
↓
Function.FunctionHandlerAsync
↓
AWSLambdaWrapper
↓
base FunctionHandlerAsync
↓
record processing
↓
application handler
AOT changed the outer host.
OpenTelemetry wrapped the invocation.
The processing model underneath remained the same.
That is also why combinations such as these do not require separate implementations:
dotnet new lambda-template-event --aot --otel
dotnet new lambda-template-sns --aot --raw
dotnet new lambda-template-kinesis-stream --aot --otel --raw
The options describe orthogonal choices rather than alternative versions of the framework.
What the application developer has to change
For me, this was the most important constraint while adding Native AOT support.
A developer opting into AOT should not suddenly have to understand the internals of the library.
For most functions, enabling it is simply:
dotnet new lambda-template-sqs --aot
and then writing the same handler they would have written otherwise.
The generated project takes care of the executable host, Lambda bootstrap, boundary serializer and build configuration.
Typed SQS, SNS and Kinesis functions have one additional responsibility because they contain nested application payloads: the application-owned JSON context must contain the actual payload types being decoded.
That is visible code rather than hidden framework magic, and I think that is the right trade-off.
AOT requires the application to be explicit about serialization metadata anyway. The template can remove the AWS-specific plumbing, but it cannot know every application contract that will eventually exist.
Publishing is platform-sensitive
There is one part the template cannot abstract away: Native AOT produces native code.
The resulting binary targets a specific operating system and architecture.
The generated projects currently target Linux x64 and configure the AWS Lambda tooling for a self-contained publish.
That means the environment producing the deployment artifact matters in a way it does not for a normal IL-based Lambda package.
The application needs to be published in an environment compatible with the Lambda target, whether that is the build agent itself or an appropriate container-based build.
This is a consequence of choosing Native AOT, but it is worth keeping visible because dotnet publish is no longer producing a platform-neutral assembly.
The programming model stays where it was
The part I like most about the implementation is probably the least visible one.
An AOT-enabled function is not an AotFunction.
The handler does not implement an AOT-specific interface.
The runtime libraries do not need an AOT mode.
From the application’s point of view, this is still an SQS function:
public sealed class Function
: SqsFunction<OrderCreated, OrderCreatedHandler>;
and this is still its handler:
public sealed class OrderCreatedHandler
: ISqsMessageHandler<OrderCreated>;
What changed is the machinery needed to get that code into a Native AOT Lambda executable.
That is the boundary I wanted.
The framework owns the Lambda programming model.
The template owns the hosting shape it can derive from that model.
The application owns the contracts only the application can know.
With those responsibilities separated, switching to Native AOT becomes a change to how the function is built, hosted and serialized around the application rather than a migration to another way of writing Lambda functions.
Recap
Switching to Native AOT should not require switching programming models. The function and its business logic can stay the same while the generated project changes the hosting model, build configuration and serialization strategy around them. The template can own what it knows about the Lambda boundary, while application code remains responsible for its own payload contracts. That keeps AOT where I think it belongs: mostly a build and hosting choice, not something that reshapes how the function itself is written.
Support this blog
If you liked this article, consider supporting this blog by buying me a pizza!