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 and returns the outcome of processing that record:
public sealed class OrderCreatedHandler
: ISqsMessageHandler<OrderCreated>
{
public async ValueTask<SqsRecordResult> HandleAsync(
OrderCreated message,
SqsMessageContext context,
CancellationToken cancellationToken)
{
// process one message
return SqsRecordResult.Success;
}
}
and the function declaration selects that model:
public sealed class Function
: SqsFunction<OrderCreated, OrderCreatedHandler>;
The handler can also return SqsRecordResult.Failed(reason) when it wants to explicitly report that a record wasn’t processed successfully.
The SQS integration owns the rest:
- iterating the batch;
- creating an independent DI scope per record;
- decoding the message body;
- preserving each record together with its result;
- translating failed results into
SQSBatchResponseidentifiers; - keeping Lambda invocation cancellation separate from an individual message failure.
The division of responsibility is explicit: the handler owns the outcome of processing one record, while the source integration owns what that outcome means to Lambda.
The handler never needs to construct the final batch response.
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.
Other record sources deliberately make different choices. DynamoDB Streams and Kinesis keep record processing sequential inside an invocation so the framework doesn’t weaken the ordering semantics of the underlying stream. Additional concurrency for those sources belongs in the Lambda event-source mapping rather than inside an individual invocation.
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<SnsRecordResult> HandleAsync(
OrderCreated notification,
SnsNotificationContext context,
CancellationToken cancellationToken)
{
// ...
return ValueTask.FromResult(SnsRecordResult.Completed);
}
}
But the result vocabulary is intentionally different.
SNS doesn’t have the SQS partial-batch response protocol, so there is no SnsRecordResult.Failed counterpart. A successfully handled notification returns SnsRecordResult.Completed; an exception still fails the invocation.
Same record-processing foundation.
Different result model and 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.
Record results encode source semantics
Every record handler returns a source-specific result derived from LambdaRecordResult.
Source packages define their own result models on top of it.
SQS can express success or an explicit failed record. DynamoDB Streams and Kinesis can do the same while their integrations translate failures into the checkpoint/partial-batch response expected by Lambda. SNS only exposes completion because it doesn’t have an equivalent per-record acknowledgement protocol.
LambdaRecordResult exposes a Value property shaped deliberately like the future C# IUnion.Value contract. The source-specific results are modeled as closed sets of typed cases, leaving a path toward native union types without changing the handler return types.
That may sound like an implementation detail, but it reinforces a larger design goal: shared infrastructure shouldn’t force unrelated AWS sources into the same result vocabulary.
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 source integrations select the appropriate one.
JSON decoding is provided by default for SQS and SNS, while applications can replace it through dependency injection. Kinesis exercises the same idea with binary payloads rather than pretending stream data is JSON by definition. 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 the application type.
It only needs to know which transport representation it has and which decoder contract applies to it.
The abstractions don’t depend on the runtime
Handlers, contexts, record results 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.
That separation lets source integrations reuse the programming model without turning into another collection of independent Lambda base classes.
EventBridge, DynamoDB Streams and Kinesis exercise different roots
EventBridge is a deliberately thin specialization of EventFunction.
A Lambda invocation receives one CloudWatchEvent<TDetail>. The AWS Lambda serializer materializes the typed Detail, and the EventBridge integration mainly fixes the event envelope and context while reusing the generic event-function lifecycle. There is no extra payload decoder just because EventBridge happens to carry JSON.
DynamoDB Streams goes in the other direction. It builds on RecordFunction, invokes one independently scoped handler per stream record, exposes keys and old/new images without pretending DynamoDB AttributeValue data is generic JSON, and translates failed record results into StreamsEventResponse using sequence numbers.
Kinesis also specializes RecordFunction, but its record payload is binary data. It combines the stream/checkpoint behavior with the binary payload-decoder abstraction, so application handlers can work with decoded values without moving Kinesis envelope processing into application code.
These integrations share lifecycle and record-processing infrastructure while keeping the parts that matter for each AWS source visible.
That’s exactly the kind of reuse I wanted the Event / Request / Record split to enable.
S3 stretches the model in another direction
S3 is interesting because “an S3 Lambda” doesn’t describe just one invocation shape.
Native S3 event notifications fit the record-processing model: an invocation can contain multiple object events, each of which should be handled independently. The S3 integration exposes an application-facing object event and reference while keeping the original AWS S3 record available through the source context.
S3 Batch Operations is a different contract. Lambda receives Batch tasks and must return an explicit result for every task, including whether it succeeded or failed temporarily or permanently.
Both scenarios live in the same S3 package, but they don’t get flattened into the same handler contract merely because they come from S3. Native notifications use S3Function<THandler>, while Batch Operations uses S3BatchFunction<THandler> and its own result model.
That is another useful boundary for the framework: source packages can share domain concepts such as an S3 object reference without forcing different Lambda protocols through one abstraction.
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, explicit record results and record-processing model that DynamoDB Streams, Kinesis and other integrations can specialize.
Partial-batch response happens to be the correct SQS translation 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
The Kralizek.Lambda.Templates package covers generic Event and Request functions together with source-specific templates for SQS, SNS, Cognito, EventBridge, DynamoDB Streams, Kinesis, S3 notifications and S3 Batch Operations.
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.
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.
A Kinesis handler still has Kinesis-specific context and stream semantics.
An S3 Batch handler returns an S3 Batch result rather than some generic record status.
A Cognito pre-sign-up handler operates on the official Cognito event contract.
Trying to abstract those concepts into ICloudQueueFunction, IGenericStreamFunction 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 on master.
The programming model covers generic Event and Request functions, SQS, SNS, Cognito User Pool triggers, EventBridge, DynamoDB Streams, Kinesis, S3 event notifications and S3 Batch Operations.
The breadth matters because each integration puts a different part of the model under pressure. EventBridge shows how little code a source integration needs when it is fundamentally just an event specialization. DynamoDB Streams and Kinesis require the record model to account for ordering, checkpointing, explicit outcomes and non-JSON payloads. S3 shows that even one AWS service can expose fundamentally different Lambda invocation contracts that shouldn’t be flattened into one abstraction.
That’s what I wanted from the V6 redesign in the first place.
Not a larger collection of Lambda base classes.
A small programming model where AWS-specific integrations can reuse lifecycle and dispatch infrastructure without giving up the semantics that make each source different.
Support this blog
If you liked this article, consider supporting this blog by buying me a pizza!