A programming model for .NET AWS Lambda functions
I’ve been working on version 6 of AWSLambdaSharpTemplate, a library and set of project templates I’ve maintained for .NET AWS Lambda functions since 2017.
V6 started as an overdue refresh from .NET 6 to .NET 10.
It didn’t stay that way for very long.
Once I started looking at the existing API again, I realized that the interesting question wasn’t how to make the old programming model work on a newer runtime. It was what programming model I would want if I were designing the library today.
The result is a substantial redesign around three types of Lambda function:
EventFunctionfor one-way invocations;RequestFunctionfor request/response invocations;RecordFunctionfor sources that deliver multiple independent records in one invocation.
That distinction is the foundation for most of what V6 can now do.
But before looking at it, there’s a more fundamental question worth answering.
Why use a library at all?
AWS already has good .NET support for Lambda.
The official Amazon.Lambda.Templates package creates projects for the supported Lambda scenarios, and a conventional C# Lambda can be little more than a class with a handler method:
public class Function
{
public async Task<Response> FunctionHandler(
Request request,
ILambdaContext context)
{
// ...
}
}
There is nothing inherently wrong with this model.
AWS also provides Lambda Annotations when you want source-generated infrastructure and dependency injection rather than constructing everything manually, while Powertools for AWS Lambda (.NET) contains utilities for common Lambda concerns such as batch processing.
What those options deliberately don’t try to provide is one opinionated application model across different Lambda event sources.
That’s the gap this library is intended to fill.
I don’t want every Lambda I write to decide independently:
- how configuration is built;
- how logging is configured;
- how dependency injection scopes behave;
- how cancellation relates to the Lambda timeout;
- how a batched event source dispatches individual records;
- how message payloads are decoded;
- which AWS metadata reaches application handlers;
- how failures become source-specific Lambda responses.
Those aren’t particularly difficult problems individually.
I just don’t think they belong in every function.
This is the same motivation that led me to write about maintainable Lambda functions using custom templates almost four years ago. V6 keeps that goal, but changes the abstraction considerably.
Functions describe semantics, handlers contain application code
The smallest generic V6 function can look like this:
public sealed class Function
: EventFunction<OrderCreated, OrderCreatedHandler>;
The handler contains the application code:
public sealed class OrderCreatedHandler(
IOrderRepository orders,
ILogger<OrderCreatedHandler> logger)
: IEventHandler<OrderCreated>
{
public async ValueTask HandleAsync(
OrderCreated input,
EventContext context,
CancellationToken cancellationToken)
{
await orders.CreateAsync(input.OrderId, cancellationToken);
logger.LogInformation(
"Created order {OrderId}",
input.OrderId);
}
}
OrderCreatedHandler is a normal scoped dependency.
The framework builds configuration, logging and the service provider, creates an invocation scope, resolves the handler from it and disposes that scope when processing completes.
The function still has explicit hooks when application-specific setup is needed:
public sealed class Function
: EventFunction<OrderCreated, OrderCreatedHandler>
{
protected override void ConfigureServices(
IServiceCollection services)
{
base.ConfigureServices(services);
services.AddScoped<IOrderRepository, OrderRepository>();
}
}
The important part isn’t saving a few lines of bootstrap code.
It’s that the lifecycle is defined once.
Every EventFunction follows the same lifecycle, and a source-specific integration can build on it without inventing another one.
Request functions have different semantics
Some Lambda invocations aren’t fire-and-forget.
For those, V6 has RequestFunction:
public sealed class Function
: RequestFunction<CreateOrder, OrderCreated, CreateOrderHandler>;
and:
public sealed class CreateOrderHandler
: IRequestHandler<CreateOrder, OrderCreated>
{
public async ValueTask<OrderCreated> HandleAsync(
CreateOrder request,
RequestContext context,
CancellationToken cancellationToken)
{
// ...
}
}
There is intentionally no generic Function<TInput, TOutput> abstraction trying to cover everything.
An event that completes successfully and a request that must produce a response have different semantics, even though AWS Lambda could technically represent both as handler methods with different return types.
Making that distinction explicit becomes more useful when building source-specific integrations.
Record sources are where things get more interesting
SQS is a good example.
AWS invokes the Lambda with an SQSEvent, which contains a batch of messages.
A straightforward Lambda handler therefore starts at the batch level:
public async Task<SQSBatchResponse> FunctionHandler(
SQSEvent input,
ILambdaContext context)
{
// iterate input.Records
// process each message
// collect failed identifiers
// return SQSBatchResponse
}
When partial batch responses are enabled, the function reports the identifiers of failed messages so that successfully processed messages aren’t retried. Powertools’ Batch Processing utility can handle that bookkeeping too.
In V6, SQS is built on RecordFunction, so the application model starts one level lower.
A typed handler receives one decoded message:
public sealed class OrderCreatedHandler
: ISqsMessageHandler<OrderCreated>
{
public async ValueTask HandleAsync(
OrderCreated message,
SqsMessageContext context,
CancellationToken cancellationToken)
{
// process one message
}
}
and the function declaration selects that model:
public sealed class Function
: SqsFunction<OrderCreated, OrderCreatedHandler>;
The SQS integration owns the rest:
- iterating the batch;
- creating an independent DI scope per record;
- decoding the message body;
- associating failures with the correct SQS message;
- producing
SQSBatchResponse; - keeping Lambda invocation cancellation separate from an individual message failure.
The handler only deals with one message.
That is the level at which most of my application code naturally wants to operate.
Raw AWS records are still available
Abstractions become annoying when getting back to the underlying platform becomes difficult.
So V6 doesn’t try to hide AWS.
If the body isn’t the abstraction you want, an SQS function can process the raw SQSEvent.SQSMessage instead.
And when using a decoded handler, SqsMessageContext still contains source-specific metadata and provides access to the original AWS message when needed.
The same principle applies to the generic contexts.
EventContext, RequestContext and RecordContext expose common Lambda invocation metadata without making every application handler depend directly on ILambdaContext, while the original AWS context remains available as an escape hatch.
The goal is not to pretend Lambda isn’t AWS.
The goal is to make the common path pleasant without making the uncommon path impossible.
Sequential by default, parallel when requested
Processing a batch concurrently is another choice I don’t want buried inside an arbitrary handler implementation.
SQS processing is sequential by default:
public sealed class Function
: SqsFunction<OrderCreated, OrderCreatedHandler>;
If processing the records independently and concurrently is appropriate, the function can state that explicitly:
public sealed class Function
: ParallelSqsFunction<OrderCreated, OrderCreatedHandler>;
The parallel variant uses bounded concurrency rather than simply creating a task for every record.
This keeps concurrency out of the application handler. The handler still receives exactly one OrderCreated.
Whether several handlers can execute simultaneously is a property of the function.
SNS looks similar, until it doesn’t
This is also why RecordFunction exists as a foundation rather than an SQS implementation that other integrations happen to copy.
SNS can also deliver multiple records in an invocation.
So its application model looks deliberately familiar:
public sealed class Function
: SnsFunction<OrderCreated, OrderCreatedHandler>;
with:
public sealed class OrderCreatedHandler
: ISnsNotificationHandler<OrderCreated>
{
public ValueTask HandleAsync(
OrderCreated notification,
SnsNotificationContext context,
CancellationToken cancellationToken)
{
// ...
}
}
But the failure semantics aren’t the same.
SNS doesn’t have the SQS partial-batch response protocol.
A notification failure therefore causes the invocation to fail rather than being converted into a list of failed record identifiers.
Same record-processing foundation.
Different AWS semantics.
That’s an important distinction for the design: the abstraction handles common lifecycle mechanics without trying to erase differences between the underlying services.
Payload decoding is another layer
SQS messages and SNS notifications commonly contain JSON, but JSON isn’t intrinsically part of either service.
So decoding isn’t built directly into RecordFunction.
V6 exposes payload decoder abstractions and the SQS/SNS integrations select the appropriate one.
JSON decoding is provided by default, while applications can replace it through dependency injection. There are also raw-handler variants when decoding shouldn’t happen at all.
The JSON decoders support normal JsonSerializerOptions, but also source-generated JsonSerializerContext and JsonTypeInfo<T> metadata.
That matters for Lambda functions where Native AOT is part of the deployment strategy.
The source integration doesn’t need to care how the payload becomes TMessage.
It only needs to know that it has a string or binary payload and that a decoder exists for it.
The abstractions don’t depend on the runtime
Handlers, contexts and payload decoder contracts live in a separate package:
Kralizek.Lambda.Template.Abstractions
That package doesn’t reference the AWS Lambda runtime, dependency injection, configuration, logging or JSON serialization packages.
The main runtime package provides the bridge to AWS.
Source-specific packages build on the abstractions.
Application libraries can reference the contracts without dragging the complete Lambda runtime stack with them.
This is also what makes adding integrations considerably more interesting in V6.
An EventBridge integration, for example, doesn’t need another complete Lambda framework. It can specialize the event model, add an EventBridge-specific context, expose the appropriate AWS envelope and let the existing invocation lifecycle do the rest.
Likewise, DynamoDB Streams and Kinesis naturally fit the record model while retaining their own record and failure semantics.
Cognito shows the other side of specialization
Cognito User Pool triggers don’t need record processing.
They are request/response invocations: Cognito sends a typed trigger document to Lambda and expects the modified document back.
V6 therefore builds Cognito on RequestFunction.
Instead of requiring application code to repeatedly specify the appropriate AWS input and output types, each trigger gets its own function specialization.
A pre-sign-up function becomes:
public sealed class Function
: CognitoPreSignUpFunction<PreSignUpHandler>;
The handler contract already fixes the correct Cognito event type.
The same approach is used for post confirmation, authentication hooks, custom auth challenges, custom messages, user migration, custom email/SMS senders and pre-token-generation triggers.
Again, the value isn’t that the underlying AWS event classes disappeared.
They didn’t.
The integration just captures knowledge that otherwise has to be repeated in every application.
What about Lambda Annotations?
AWS Lambda Annotations is probably the closest official alternative when the concern is dependency injection and reducing handler boilerplate.
It uses attributes and source generation to produce much of the Lambda plumbing and supports dependency injection while retaining the standard Lambda programming model.
I think it solves a different problem.
Annotations makes individual Lambda handlers easier to author.
AWSLambdaSharpTemplate tries to define semantics shared by families of Lambda functions.
The distinction becomes most visible with something like SQS.
For me, the interesting abstraction isn’t primarily how IOrderRepository reaches the method.
It’s that:
ISqsMessageHandler<OrderCreated>
means “process exactly one decoded SQS message inside its own scope”, while:
SqsFunction<OrderCreated, OrderCreatedHandler>
owns the relationship between that handler and the AWS batch invocation.
Both approaches can use dependency injection.
The library is opinionated about considerably more than dependency injection.
And what about Powertools?
For batch processing specifically, Powertools for AWS Lambda deserves mentioning too.
If partial-batch handling is the only abstraction I need, I’d use its Batch Processing utility rather than introduce an entire application framework just to avoid building an SQSBatchResponse.
V6’s reason for existing is broader.
The SQS implementation uses the same handler lifecycle, contexts, dependency injection, payload decoding and record-processing model that other integrations can specialize.
Partial-batch response happens to be the correct SQS result of that processing model.
It isn’t the model itself.
Templates should demonstrate application code, not framework plumbing
The other part of the refresh is the dotnet new templates.
The V5 templates exposed quite a bit of setup because the application was responsible for quite a bit of setup.
V6 templates are intentionally boring.
Install them with:
dotnet new install Kralizek.Lambda.Templates
and, for example:
dotnet new lambda-template-sqs \
--name OrderProcessor
The generated project contains the function declaration, handler and the customization hooks that application code is expected to use.
It doesn’t reproduce the framework’s internal bootstrap process.
The package currently contains generic Event and Request templates together with source-specific templates for SQS, SNS and Cognito.
As part of the V6 work, CI also tests the templates as actual consumers: it packs the runtime and template packages, installs the generated template package, creates fresh projects, restores them against the packages produced by the same commit and builds them with warnings treated as errors.
That catches a different class of problems from simply compiling the template sources inside the repository.
This isn’t an AWS abstraction layer
I think this is probably the most important boundary in the design.
AWSLambdaSharpTemplate isn’t intended to make Lambda portable to another cloud.
An SqsFunction is unapologetically an SQS Lambda function.
An SnsNotificationContext contains SNS concepts.
A Cognito pre-sign-up handler operates on the official Cognito event contract.
Partial batch responses behave the way Lambda expects them to behave.
Trying to abstract those concepts into ICloudQueueFunction or IGenericIdentityTrigger would mostly result in losing useful information.
The abstraction is around the programming model, not around AWS.
AWS defines how Lambda is invoked.
The library defines how I want application code to participate in that invocation.
When I wouldn’t use it
Not every Lambda needs this.
For a small function with one dependency and a dozen lines of code, the standard AWS template is perfectly reasonable.
Likewise, if I only need better SQS batch handling, Powertools already solves that problem.
The library starts becoming useful when I want several Lambda functions to share the same expectations around application structure and lifecycle, or when the source integration itself has enough behavior that I don’t want each function reproducing it.
That’s the trade-off.
It introduces a framework.
In return, the application gets to work at the semantic level of “handle this event”, “answer this request” or “process this record” while the integration owns the mechanics of turning that into a correct Lambda invocation.
V6 is coming
The V6 redesign is now on master.
The current implementation includes the new Event/Request/Record programming model, lightweight abstractions and payload decoders, SQS and SNS integrations, Cognito User Pool triggers, and a rebuilt Kralizek.Lambda.Templates package.
The next interesting step isn’t adding integrations merely to make the list longer.
It’s seeing how well the model holds when applied to sources with different semantics.
EventBridge is an obvious event-function candidate. DynamoDB Streams and Kinesis exercise the record model differently. There are more possibilities after that.
That’s what I wanted from the V6 redesign in the first place.
Not a larger collection of Lambda base classes.
A small enough programming model that the AWS-specific parts can become specializations of it.
Support this blog
If you liked this article, consider supporting this blog by buying me a pizza!