The AWS documentation gets you a "Hello World" Lambda in ten minutes. Getting a .NET Lambda function ready for production — testable, observable, and safe to deploy without surprises — takes a bit more structure. Here's what we standardize on for client work.
- Use the generic host (
Amazon.Lambda.RuntimeSupport) so you can register DI, configuration, and logging exactly like an ASP.NET Core app. - Keep the handler thin — it should parse the event, call into your Application layer, and map the result. No business logic in
Function.cs. - Configure a dead-letter queue or an on-failure destination for every asynchronously invoked function; silent drops are worse than a loud failure.
- Emit structured logs and enable X-Ray tracing from day one — retrofitting observability after an incident is the hard way to learn this lesson.
Project structure
Treat the Lambda function like any other entry point into your Application layer — not a place where business logic lives.
src/
OrderProcessing.Domain/
OrderProcessing.Application/
OrderProcessing.Infrastructure/
OrderProcessing.Lambda/
Function.cs
Startup.cs
aws-lambda-tools-defaults.json
Wiring dependency injection with the generic host
Skip the static-class-with-manually-constructed-dependencies pattern. The generic host lets you use the same IServiceCollection registration style as an ASP.NET Core project.
public class Function
{
private static readonly IServiceProvider _services = ConfigureServices();
private static IServiceProvider ConfigureServices()
{
var services = new ServiceCollection();
services.AddLogging(b => b.AddLambdaLogger());
services.AddSingleton<IAmazonDynamoDB>(new AmazonDynamoDBClient());
services.AddScoped<IOrderRepository, DynamoOrderRepository>();
services.AddScoped<SubmitOrderHandler>();
return services.BuildServiceProvider();
}
public async Task<APIGatewayProxyResponse> FunctionHandler(
APIGatewayProxyRequest request, ILambdaContext context)
{
using var scope = _services.CreateScope();
var handler = scope.ServiceProvider.GetRequiredService<SubmitOrderHandler>();
var command = JsonSerializer.Deserialize<SubmitOrderCommand>(request.Body)
?? throw new BadRequestException("Invalid request body.");
await handler.Handle(command, context.RemainingTime());
return new APIGatewayProxyResponse
{
StatusCode = 204,
Headers = new Dictionary<string, string> { ["Content-Type"] = "application/json" }
};
}
}
Note the CreateScope() per invocation — the container itself is built once (outside the handler) so it survives warm starts, but scoped services still get a fresh lifetime per request, matching how you'd reason about them in ASP.NET Core.
Error handling: fail loudly, not silently
For functions invoked asynchronously (S3 events, SNS, EventBridge), configure an on-failure destination or a dead-letter queue. Without one, a function that exhausts its retries simply drops the event with no trace.
{
"DestinationConfig": {
"OnFailure": {
"Destination": "arn:aws:sqs:us-east-1:123456789012:order-processing-dlq"
}
},
"MaximumRetryAttempts": 2
}
maxReceiveCount. Without it, one poison message retries forever and starves every other message behind it.Structured logging and tracing
Plain Console.WriteLine logs are hard to query once you have more than a handful of invocations a day. Use structured logging so CloudWatch Logs Insights can filter and aggregate by field, and enable AWS X-Ray for end-to-end traces across Lambda, DynamoDB, and downstream HTTP calls.
_logger.LogInformation(
"Order {OrderId} submitted for customer {CustomerId} in {ElapsedMs}ms",
order.Id, order.CustomerId, stopwatch.ElapsedMilliseconds);
Least-privilege IAM, every time
Resist the temptation to attach a broad managed policy while iterating and "tighten it up later." Scope the execution role to the specific table, queue, and secret ARNs the function actually touches from the start — it's much cheaper to write a precise policy up front than to audit a wildcard policy after the fact.