Getting Started: Benzene on AWS Lambda

Benzene runs efficiently in AWS Lambda, supporting multiple event sources (API Gateway, SQS, SNS, EventBridge, Kafka, S3) through a single middleware pipeline. This guide starts from an empty folder and ends with one handler, deployed as one function, answering both an HTTP request and an SQS message — the thing a plain Lambda handler can't do without becoming two Lambdas.

This is Benzene's flagship host: one function, every event source. The same handler you write below is reached over API Gateway (HTTP) and SQS in this guide, and over SNS, EventBridge, Kafka, and more with one more line each — adding a transport is wiring, never a change to your logic. See Supported Event Sources for the rest.

Runnable version: examples/Aws/Benzene.Examples.Aws.Minimal is the smallest form of this guide — one handler over API Gateway + SNS + SQS + EventBridge, with a test that boots the real StartUp in-memory (no AWS account needed) and pushes a native event through each source. Read it alongside this page.

Prerequisites

1. Create the project

mkdir MyFunction && cd MyFunction
dotnet new classlib -f net10.0

2. Install the NuGet packages

Benzene's packages are published as prerelease (-alpha) versions, so --prerelease is required until 1.0:

dotnet add package Benzene.Aws.Lambda --prerelease

Benzene.Aws.Lambda is the umbrella for building a Lambda service: one package brings in the middleware pipeline and message-handler infrastructure (transitively via Benzene.Microsoft.Dependencies), the BenzeneStartUp base class, UseApiGateway, and every event-source middleware — UseSqs, UseSns, UseKafka, UseS3, and the rest — so you don't add a package per event source (see Supported Event Sources below). Prefer a narrower dependency? Reference Benzene.Aws.Lambda.Core plus just the transport package(s) you need (Benzene.Aws.Lambda.Sqs, .Sns, .Kafka, .S3, …) instead of the umbrella.

You'll also need the concrete Microsoft.Extensions.Configuration implementation for GetConfiguration() below (only its abstractions are referenced transitively):

dotnet add package Microsoft.Extensions.Configuration
dotnet add package Microsoft.Extensions.Configuration.EnvironmentVariables
dotnet add package Microsoft.Extensions.Configuration.FileExtensions

3. Define a message handler

Business logic lives in message handlers, not in the Lambda entry point — this keeps it testable and portable across hosts. See Message Handlers for the full picture; the minimal shape is:

using Benzene.Abstractions.MessageHandlers;
using Benzene.Abstractions.Results;
using Benzene.Core.MessageHandlers;
using Benzene.Http;
using Benzene.Results;

[Message("hello:world")]
[HttpEndpoint("GET", "/hello/{name}")]
public class HelloWorldMessageHandler : IMessageHandler<HelloWorldRequest, HelloWorldResponse>
{
    public Task<IBenzeneResult<HelloWorldResponse>> HandleAsync(HelloWorldRequest message)
    {
        return Task.FromResult(BenzeneResult.Ok(new HelloWorldResponse { Message = $"Hello {message.Name}!" }));
    }
}

public class HelloWorldRequest
{
    public string Name { get; set; }
}

public class HelloWorldResponse
{
    public string Message { get; set; }
}

[Message] maps the handler to a topic; [HttpEndpoint] maps an HTTP method and path to that same topic — the same handler answers both a direct {"topic": "hello:world", ...} message (e.g. from SQS/SNS) and an API Gateway GET /hello/{name} request. Both attributes are discovered by reflection, so there is nothing further to register per-handler.

4. Define your StartUp

BenzeneStartUp (from Benzene.Microsoft.Dependencies, referenced transitively) is the platform-neutral application definition shared by every Benzene host — the same class shape you'd write for Azure Functions or the .NET generic host. Configure the AWS-specific event pipeline via UseAwsLambda(...), which hands you an IMiddlewarePipelineBuilder<AwsEventStreamContext> to wire up event sources on:

using Benzene.Abstractions.Hosting;
using Benzene.Aws.Lambda.ApiGateway;
using Benzene.Aws.Lambda.Core;
using Benzene.Aws.Lambda.Sqs;
using Benzene.Core.MessageHandlers;
using Benzene.Microsoft.Dependencies;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

public class StartUp : BenzeneStartUp
{
    public override IConfiguration GetConfiguration()
    {
        return new ConfigurationBuilder()
            .SetBasePath(Directory.GetCurrentDirectory())
            .AddEnvironmentVariables()
            .Build();
    }

    public override void ConfigureServices(IServiceCollection services, IConfiguration configuration)
    {
        // Nothing Benzene-specific to register here. Register your own application services -
        // repositories, HTTP clients, options - and Benzene wires itself up in Configure below.
    }

    public override void Configure(IBenzeneApplicationBuilder app, IConfiguration configuration)
    {
        app.UseAwsLambda(eventPipeline => eventPipeline
            .UseApiGateway(apiGatewayApp => apiGatewayApp
                .UseMessageHandlers())
            .UseSqs(sqsApp => sqsApp
                .UseMessageHandlers()));
    }
}

That's the whole pitch in two lines: UseApiGateway and UseSqs both call UseMessageHandlers() against the same handler registry, so an API Gateway GET /hello/{name} request and an SQS message on the hello:world topic both resolve to the identical HelloWorldMessageHandler from step 3 — nothing about the handler changes. Wire up SNS, EventBridge, Kafka, or any other supported source the same way, one more .Use...(...) call each.

Notice there is no Benzene registration in ConfigureServices — no AddBenzene(), no AddMessageHandlers(). UseMessageHandlers() in Configure discovers your [Message]/[HttpEndpoint] handlers by reflection and — since the router depends on them — pulls in the core pipeline services (routing, result statuses, serialization) for you; each transport you wire (UseApiGateway, and UseSqs/UseSns/... below) registers its own request/response mappers the same way. The no-argument UseMessageHandlers() scans every loaded assembly; pass an assembly or a list of types (UseMessageHandlers(typeof(HelloWorldMessageHandler).Assembly)) if you'd rather scope discovery explicitly. Either way, ConfigureServices is now just for your services.

This is the platform-neutral pattern used by every Benzene host — the examples/Aws project follows exactly this shape. Only the AWS-specific event wiring lives inside UseAwsLambda(...); the same StartUp runs unchanged on other Benzene hosts (see Azure Functions Setup).

5. Wire up the Lambda entry point

Subclass AwsLambdaHost<TStartUp> — it builds the pipeline once on cold start and implements the Lambda entry point for you, so there's no separate handler method to write:

using Benzene.Aws.Lambda.Core;

public class Function : AwsLambdaHost<StartUp>
{
}

Point your Lambda's function-handler at it:

MyFunction::MyFunction.Function::FunctionHandlerAsync

(replace MyFunction with your assembly and namespace, and Function if you named the class something else)

6. Test locally with BenzeneTestHost

Before deploying, exercise the pipeline in-memory using the same StartUp class you'll deploy. Add the test-helper packages to your test project:

dotnet add package Benzene.Testing --prerelease
dotnet add package Benzene.Aws.Lambda.Core.TestHelpers --prerelease
dotnet add package Benzene.Aws.Lambda.ApiGateway.TestHelpers --prerelease
dotnet add package Benzene.Aws.Lambda.Sqs.TestHelpers --prerelease

BenzeneTestHost.Create<TStartUp>().BuildAwsLambdaTestHost() runs your real GetConfiguration()/ ConfigureServices()/Configure() — the same construction AwsLambdaHost<TStartUp> performs for a real deployment — and returns a ready-to-use test host you can send events into and get typed responses back from, all in one fluent chain:

using Benzene.Aws.Lambda.ApiGateway.TestHelpers;
using Benzene.Aws.Lambda.Core.TestHelpers;
using Benzene.Aws.Lambda.Sqs.TestHelpers;
using Benzene.Testing;
using Microsoft.Extensions.DependencyInjection;

var host = BenzeneTestHost.Create<StartUp>()
    .WithServices(services => services.AddScoped(_ => mockSomeDependency.Object))
    .BuildAwsLambdaTestHost();

// Same host, same handler, two transports:
var httpRequest = HttpBuilder.Create("GET", "/hello/world");
var httpResponse = await host.SendApiGatewayAsync(httpRequest);

var sqsMessage = MessageBuilder.Create("hello:world", new HelloWorldRequest { Name = "world" });
var sqsResponse = await host.SendSqsAsync(sqsMessage);

Both calls exercise the identical HelloWorldMessageHandler on the identical host — proof, before a single byte reaches AWS, that "one handler, two transports" isn't just a wiring convenience, it's the same code path.

WithServices(...) runs immediately after ConfigureServices, so it's the standard way to swap in a mock or fake for a test; WithConfiguration(...) does the same for configuration values. This works for any transport your StartUp wires up — SendSqsAsync/SendSnsAsync come from the matching Benzene.Aws.Lambda.Sqs.TestHelpers/Benzene.Aws.Lambda.Sns.TestHelpers packages, and a topic-routed BenzeneMessage (no specific transport) can be sent directly via SendBenzeneMessageAsync from Benzene.Core.MessageHandlers.TestHelpers, if your Configure wires up UseBenzeneMessage(...). See Testing Benzene for the full pattern, including configuration/service overrides.

7. Deploy with SAM

Add a minimal template.yaml alongside your .csproj:

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31

Globals:
  Function:
    Timeout: 30
    MemorySize: 1024
    # AWS added a managed .NET 10 Lambda runtime in Jan 2026 (built on provided.al2023),
    # so this net10.0 project runs on the matching managed runtime directly.
    Runtime: dotnet10
    Architectures:
      - arm64

Resources:
  MyQueue:
    Type: AWS::SQS::Queue

  MyFunction:
    Type: AWS::Serverless::Function
    Metadata:
      BuildMethod: dotnet10
    Properties:
      FunctionName: my-function
      Handler: MyFunction::MyFunction.Function::FunctionHandlerAsync
      CodeUri: ./
      Policies:
        # SAM policy template - grants exactly the SQS actions the event source mapping
        # needs. See "SQS trigger" in aws-iam-permissions.md.
        - SQSPollerPolicy:
            QueueName: !GetAtt MyQueue.QueueName
      Events:
        HttpApi:
          Type: HttpApi
        SqsEvent:
          Type: SQS
          Properties:
            Queue: !GetAtt MyQueue.Arn
            BatchSize: 10

Outputs:
  QueueUrl:
    Description: URL of the SQS queue (for the send-message verification below)
    Value: !Ref MyQueue

Then build and deploy:

sam build
sam deploy --guided

sam deploy --guided walks you through stack name, region, and parameter values on first run, then remembers them in samconfig.toml for subsequent deploys. SAM prints the API Gateway URL and the QueueUrl output on completion — both routes into the same deployed function:

# HTTP, via API Gateway:
curl https://<api-id>.execute-api.<region>.amazonaws.com/hello/world

# SQS, via the queue - a manually-sent message needs the topic message attribute a real Benzene
# client would set automatically, so the router can dispatch it (see the SQS section below):
aws sqs send-message \
  --queue-url <QueueUrl from the SAM output> \
  --message-body '{"Name":"world"}' \
  --message-attributes '{"topic":{"DataType":"String","StringValue":"hello:world"}}'

Both reach the same HelloWorldMessageHandler. CloudWatch Logs for the function shows one invocation per call, regardless of which transport triggered it.

See examples/Aws/Benzene.Examples.Aws/template.yaml for a fuller example covering SQS, SNS, and an optional MSK/Kafka event source.

Supported Event Sources

Benzene provides specialized middleware for various AWS event sources, each configured inside the same Configure method, on the same eventPipeline (an IMiddlewarePipelineBuilder<AwsEventStreamContext>) shown in step 4 — a single Lambda function can handle several event sources at once, each routed to its own sub-pipeline based on the shape of the incoming payload:

SQS

eventPipeline.UseSqs(sqsApp => sqsApp
    .UseMessageHandlers(router => router.UseFluentValidation())
);

SQS messages are processed in batches; the message body is deserialized to your request type and message attributes are mapped to headers. SqsApplication supports reporting partial-batch failures back to SQS, so only the records that actually failed are retried/redriven to a dead-letter queue rather than the whole batch. See AWS IAM Permissions for the execution-role permissions the SQS event source mapping needs (sqs:ReceiveMessage, sqs:DeleteMessage, sqs:GetQueueAttributes).

The topic is normally read from a topic message attribute, set by a Benzene client. If a queue's producer isn't a Benzene client and never sets one (a raw SQS send, a queue fed by another system), call .UsePresetTopic("orders.created") before .UseMessageHandlers() in that queue's pipeline to route every message on it to a fixed topic instead — see Common Middleware: UsePresetTopic.

SNS

eventPipeline.UseSns(snsApp => snsApp
    .UseMessageHandlers(router => router.UseFluentValidation())
);

SNS invokes your function via a resource-based Lambda permission — no extra execution-role IAM is needed to receive notifications (see AWS IAM Permissions). The topic is resolved from a topic message attribute (or the SNS topic ARN, depending on delivery configuration) and routed to the matching message handler, same as every other transport. There is no response to write back — SNS delivery is fire-and-forget.

Unsafe by default: a handler that returns a failure result (rather than throwing) is silently accepted — the invocation reports success and SNS never retries it. Set SnsOptions.RaiseOnFailureStatus = true if you want failure results retried too (requires an idempotent handler, since SNS redelivery has no dedup) — see SNS Fan-Out Pattern.

EventBridge

eventPipeline.UseEventBridge(eventBridgeApp => eventBridgeApp
    .UseMessageHandlers(router => router.UseFluentValidation())
);

The event's detail-type is the message topic — EventBridge's native routing key, so [Message("order.created")] handles events published with that detail-type — and detail is the message body. Envelope metadata (source, id, account, region, time) is exposed as eventbridge--prefixed headers, and Benzene wire headers embedded by the outbound EventBridge client (see Clients) are lifted from the reserved _benzeneHeaders key inside detail. Like SNS, EventBridge invokes the function via a resource-based Lambda permission and delivery is fire-and-forget. The aws_cloudwatch_event_rule/target/permission wiring can be generated from your [Message] topics — see Terraform Code Generation.

DynamoDB Streams

eventPipeline.UseDynamoDb(dynamoDb => dynamoDb
    .UseMessageHandlers(router => router.UseFluentValidation())
);

DynamoDB Streams deliver ordered change-data-capture records when items in a table are inserted, modified, or removed. The topic is "{tableName}:{eventName}" — a handler declares [Message("orders:INSERT")] — and the body is the record's image unmarshalled from DynamoDB's AttributeValue format into plain JSON, so your handler deserializes an ordinary POCO (NewImage when present, falling back to OldImage for REMOVE events, then Keys for KEYS_ONLY stream views). Envelope metadata is exposed as dynamodb--prefixed headers (dynamodb-event-name, dynamodb-table, dynamodb-sequence-number, ...).

Because stream records within a shard are ordered, the batch is processed sequentially and stops at the first failure: the failed record's sequence number is returned as a partial batch failure, and (with ReportBatchItemFailures enabled on the event source mapping) Lambda checkpoints there and redelivers from that record. This is deliberately different from the SQS adapter's concurrent batch processing.

Kafka

eventPipeline.UseKafka(kafkaApp => kafkaApp
    .UseMessageHandlers(router => router.UseFluentValidation())
);

Works for both MSK and self-managed Kafka. Kafka record headers are mapped to Benzene message headers, and partition/offset are available on KafkaContext. See AWS IAM Permissions for the MSK-specific permissions your execution role needs — these are more involved than the other event sources since MSK event source mappings require VPC connectivity.

S3

eventPipeline.UseS3(s3App => s3App
    .UseMessageHandlers(router => router.UseFluentValidation())
);

Like SNS, S3 invokes via a resource-based permission plus a bucket notification configuration — no extra execution-role IAM needed to receive. S3 event notifications are fire-and-forget: no response is written back, since S3 doesn't expect one.

Kinesis (streaming)

Kinesis is different from the transports above: it's a streaming source, so the whole batch is handed to your pipeline as one ordered stream rather than fanned out into a handler per record. This preserves per-shard ordering and lets you window and aggregate — the things a fan-out model throws away.

eventPipeline.UseKinesisStream(kinesis => kinesis
    .UseStream<KinesisEventRecord>(async (records, ct) =>
    {
        // records within a partition key are in shard order
        await foreach (var partition in records.PartitionBy(r => r.Kinesis.PartitionKey, ct))
        {
            foreach (var record in partition.Value)
            {
                var payload = record.Kinesis.GetDataAsString();
                // ... process
            }
        }
    }));

The handler receives an IAsyncEnumerable<KinesisEventRecord> — pull records lazily (backpressure), decode each with record.Kinesis.GetData() / GetDataAsString(), and use the stream operators PartitionBy(...) (restore per-shard order the poller batched together) and Window(n) (fixed-size batches). Processing is fire-and-forget; the Lambda poller checkpoints the shard on success. This is the AWS counterpart to the Azure Event Hubs streaming binding. See AWS IAM Permissions for the stream-consumer permissions the event source mapping needs (kinesis:GetRecords, kinesis:GetShardIterator, kinesis:DescribeStream, kinesis:ListShards).

IAM Permissions

Each event source above has different IAM requirements — some need explicit execution-role permissions (SQS, Kafka), others invoke your function via a resource-based permission and need none. See AWS IAM Permissions Reference for a minimal policy per package, with the specific SDK call in Benzene's source that drives each requirement.

Configuration

GetConfiguration() runs once on cold start, before any services are registered, and its result is passed into both ConfigureServices and Configure. Anything built on top of Microsoft.Extensions.Configuration works here — the example above reads environment variables (the natural fit for Lambda, where you set configuration via the function's environment variables in the console, SAM template, or CDK/Terraform), but AddJsonFile(...), AWS Systems Manager Parameter Store providers, or AWS Secrets Manager providers all work the same way.

Health Checks

Add a health check topic so you (or a monitoring system) can confirm the function's dependencies are reachable, without a real request hitting your business handlers:

var healthChecks = new IHealthCheck[] { new SimpleHealthCheck() };

eventPipeline.UseApiGateway(apiGatewayApp => apiGatewayApp
    .UseHealthCheck("healthcheck", "POST", "/healthcheck", healthChecks)
    .UseMessageHandlers());

See Health Checks for writing your own IHealthCheck (e.g. one that checks database connectivity) and the full set of UseHealthCheck overloads.

Observability

See Monitoring & Diagnostics for the full picture, including logging providers and named timers.

Bare Metal Entry Point

If you prefer more control than BenzeneStartUp/AwsLambdaHost gives you, you can build the pipeline and entry point by hand:

public class BareMetalLambdaEntryPoint
{
    private readonly IMiddlewarePipeline<AwsEventStreamContext> _pipeline;
    private readonly MicrosoftServiceResolverFactory _microsoftServiceResolverFactory;

    public BareMetalLambdaEntryPoint()
    {
        var services = new ServiceCollection();

        var middlewarePipelineBuilder = new AwsEventStreamPipelineBuilder(new MicrosoftBenzeneServiceContainer(services))
            .UseBenzeneMessage(x => x
                .UseMessageHandlers(s => s.UseFluentValidation()));

        services.UsingBenzene(x => x.AddMessageHandlers(Assembly.GetExecutingAssembly()));

        _microsoftServiceResolverFactory = new MicrosoftServiceResolverFactory(services.BuildServiceProvider());
        _pipeline = middlewarePipelineBuilder.Build();
    }

    public async Task<Stream> FunctionHandler(Stream input, ILambdaContext lambdaContext)
    {
        using var serviceResolver = _microsoftServiceResolverFactory.CreateScope();

        var context = new AwsEventStreamContext(input, lambdaContext);
        await _pipeline.HandleAsync(context, serviceResolver);
        return context.Response;
    }
}

Troubleshooting

"IBenzeneInvocation was requested before the pipeline's UseBenzeneInvocation() middleware populated it for this invocation." — you injected IBenzeneInvocation somewhere, but .UseBenzeneInvocation() was never called on a pipeline upstream of it. Add it on the outer eventPipeline, before UseApiGateway(...)/UseSqs(...)/etc. Note it doesn't reach into SQS/SNS/Kafka's per-message batch dispatch (each message gets its own nested DI scope), so don't expect it to resolve inside those handlers even after adding it upstream.

Handler never gets called / 404 from API Gateway — check that [HttpEndpoint("METHOD", "/path")] matches exactly, including case and any route parameters ({name}), and that the handler's assembly was passed to AddMessageHandlers(...) (or that you called the no-argument AddMessageHandlers() overload, which scans the calling assembly).

SQS/SNS message never routes to a handler — both transports resolve the topic from a message attribute (topic for SNS by convention, or an explicit attribute set by the producer), not the message body. Confirm the producer sets that attribute, and that a handler exists with a matching [Message("...")] topic.

Deployment fails or the function returns nothing — double check the function-handler string matches Assembly::Namespace.ClassName::FunctionHandlerAsync exactly (including the namespace), and that your Function : AwsLambdaHost<StartUp> class has a public parameterless constructor (inherited automatically unless you add one of your own).

Cold starts are slowAwsLambdaHost<TStartUp>'s constructor runs GetConfiguration(), ConfigureServices(), and Configure() exactly once per execution environment, so cold-start cost is dominated by whatever your ConfigureServices does (e.g. opening a DB connection eagerly). Prefer lazy/on-demand initialization inside your services over eager work in ConfigureServices.

Local test host behaves differently from the deployed functionBuildAwsLambdaTestHost() performs the exact same construction AwsLambdaHost<TStartUp> does for a real deployment (same GetConfiguration/ConfigureServices/Configure calls), so a divergence usually means a WithServices/WithConfiguration override in the test masked something — remove the override temporarily to confirm.

See Also