Getting Started: Benzene on AWS Lambda
Benzene runs efficiently in AWS Lambda, handling multiple event sources — API Gateway, SQS, SNS, EventBridge, Kafka, and more — through a single middleware pipeline. This guide starts from an empty folder and ends with a bundled Lambda function serving API Gateway requests, then adds a second transport so you can see how one handler works across event sources without changing a line of it.
If you're brand new to Benzene, read Getting Started first — it builds the same kind of service locally on Express in about five minutes. The message handler you write there runs unchanged on Lambda; only the entry point differs, and that's what this guide covers.
TypeScript port. This is the TypeScript port of Benzene. It mirrors the .NET library's shape as closely as the language allows; where the two differ, the README's Porting conventions explain why. The .NET production host adapter
AwsLambdaHost<TStartUp>(running a canonicalBenzeneStartUp) is ported — you write oneStartUpclass and boot it with the one-linerexport const handler = new AwsLambdaHost(StartUp).lambdaHandler, the same composition root thebenzeneTestHost(...)test harness boots (see Testing Benzene), so what you test is what deploys. The terse fluentInlineAwsLambdaStartUpbuilder remains as an advanced/terse alternative for inline tests and small standalone hosts.
Prerequisites
- Node.js 22+ and npm
- Any editor
- An AWS account, with the AWS CLI and AWS SAM CLI configured — only if you want to deploy. Everything up to that point runs locally.
The core idea in 30 seconds
Benzene separates what your service does from how it's invoked:
- A message handler contains your logic. It receives a typed request, returns a typed result, and knows nothing about Lambda, API Gateway, or queues.
- Each handler is mapped to a topic — a stable string like
order:place— via the@messagedecorator, and (for HTTP) to a method and path via@httpEndpoint. - A transport pipeline turns an incoming Lambda event into a message, routes it to the matching handler by topic, and turns the result back into a transport-native response.
On Lambda the transport pipeline is built by an entry point you export as handler. The handler
itself is identical to the one you'd host on Express or
Azure Functions. See Message Handlers and
Middleware for the full picture.
1. Create the project
mkdir orders-lambda && cd orders-lambda
npm init -y
npm pkg set type=module
Setting type=module makes this an ES-module project, which Benzene's packages require — and it's the
shape you want for a modern Lambda bundle.
2. Install the packages
npm install @benzene/aws-lambda @benzene/http
npm install --save-dev typescript esbuild @types/aws-lambda
@benzene/aws-lambda is the umbrella for building a Lambda service: one install brings in the
middleware pipeline and message-handler infrastructure, the AwsLambdaHost production host (and the
useAwsLambda selector), the results/handler building blocks, and every event-source transport —
useApiGateway, useSqs, useSns, useEventBridge, useKafka, and the rest — so you don't add a
package per event source (see Supported event sources). @benzene/http adds
the httpEndpoint helper for HTTP-shaped handlers. The canonical BenzeneStartUp contract lives in
@benzene/abstractions-middleware (installed transitively). Prefer a narrower dependency set? Install
the individual @benzene/aws-lambda-* packages instead.
3. Write a message handler
Create src/handlers.ts. This is where your logic lives — the file you'd carry over verbatim if you
later moved to Express or Azure Functions:
import { IBenzeneResultOf, IMessageHandler, message, BenzeneResult } from '@benzene/aws-lambda';
import { httpEndpoint } from '@benzene/http';
// Payloads are classes, not interfaces: the runtime recovers the erased request type from its
// constructor (for topic/schema keying), which an interface can't provide.
export class PlaceOrder {
customerId?: string;
}
export class OrderConfirmation {
orderId?: string;
}
@httpEndpoint('POST', '/orders')
@message('order:place', { requestType: PlaceOrder, responseType: OrderConfirmation })
export class PlaceOrderHandler implements IMessageHandler<PlaceOrder, OrderConfirmation> {
handleAsync(request: PlaceOrder): Promise<IBenzeneResultOf<OrderConfirmation>> {
const confirmation = new OrderConfirmation();
confirmation.orderId = `order-${request.customerId ?? 'anon'}`;
return Promise.resolve(BenzeneResult.created(confirmation));
}
}
Two decorators do the wiring:
@message('order:place', …)maps the handler to its topic. Every Benzene transport routes by topic, so this identifier stays constant across API Gateway, SQS, SNS, and the rest. TherequestType/responseTypegive the runtime the concrete classes it needs (TypeScript erases generics, so they can't be inferred).@httpEndpoint('POST', '/orders')maps an HTTP method and path onto that same topic, so the same handler answers both a direct topic-routed message (from SQS/SNS/EventBridge) and an API Gateway request.
BenzeneResult.created(...) is the success case that maps to HTTP 201; use BenzeneResult.ok(...)
for 200. The result carries success/failure status alongside the payload — see
Message Result.
Request binding. Benzene binds the JSON request body onto your request object, so a
POSTwith{"customerId":"acme"}populatesrequest.customerId. Unlike .NET, the TypeScript port does not bind path/query segments onto a bodyless request, so this guide uses aPOSTbody rather than the .NET guide'sGET /hello/{name}. Read values a client sends in the body.
4. Write the composition root and the entry point
Two small files. First src/startUp.ts — the composition root, the single place your service is wired.
It implements the canonical BenzeneStartUp contract (the same shape on every cloud): configureServices
registers the service graph, configure wires the transport pipeline(s). Inside configure you select
AWS with useAwsLambda(app, aws => …):
// src/startUp.ts
import { IBenzeneServiceContainer } from '@benzene/abstractions';
import { BenzeneConfiguration, BenzeneStartUp, IBenzeneApplicationBuilder } from '@benzene/abstractions-middleware';
import { addBenzene, useAwsLambda, useApiGateway, useMessageHandlers } from '@benzene/aws-lambda';
import { PlaceOrderHandler } from './handlers.js';
export class StartUp implements BenzeneStartUp {
configureServices(services: IBenzeneServiceContainer, _config: BenzeneConfiguration): void {
// Register your own services here — a component test can override any of them.
addBenzene(services);
}
configure(app: IBenzeneApplicationBuilder, _config: BenzeneConfiguration): void {
useAwsLambda(app, (aws) => useApiGateway(aws, (api) => useMessageHandlers(api, PlaceOrderHandler)));
}
}
Then src/handler.ts — the only file that knows it's running on Lambda, a single line:
// src/handler.ts
import { AwsLambdaHost } from '@benzene/aws-lambda';
import { StartUp } from './startUp.js';
export const handler = new AwsLambdaHost(StartUp).lambdaHandler;
What each piece does:
StartUp implements BenzeneStartUp—configureServices(services, config)registers your service graph (addBenzenepulls in Benzene's baseline;useMessageHandlersalso ensures it idempotently), andconfigure(app, config)wires the pipeline.useAwsLambda(app, aws => …)scopes the wiring to AWS; inside ituseApiGateway(aws, (api) => …)inserts the API Gateway transport anduseMessageHandlers(api, PlaceOrderHandler)routes a matched request to its handler. Pass every handler class you want served.new AwsLambdaHost(StartUp)builds the pipeline once on cold start from thatStartUp— the same composition root a component test boots viabenzeneTestHost(StartUp).buildAwsLambdaHost()— and.lambdaHandleris the correctly-bound function AWS calls on each invocation.
Export the bound handler — this is the one gotcha. Always write:
export const handler = new AwsLambdaHost(StartUp).lambdaHandler;Do not write
export const handler = host.functionHandlerAsync. It compiles — the method is assignable to the handler type — but assigning the method detachesthis, so the host loses its pipeline and the function crashes at the first invocation..lambdaHandlercloses over the host and keepsthisbound; use it every time.Advanced/terse alternative. For an inline test or a tiny standalone host you can skip the
StartUpclass and use the fluentInlineAwsLambdaStartUpbuilder directly:const entryPoint = new InlineAwsLambdaStartUp().configure(app => useApiGateway(app, …)).build();thenexport const handler = toLambdaHandler(entryPoint);. TheAwsLambdaHost(StartUp)one-liner is the taught path because the sameStartUpboots your component tests.
5. Test locally
Before deploying, exercise the exported handler in-memory with the same builder you'll ship, using
@benzene/aws-lambda-testing to construct native events and @benzene/testing's builders for payloads:
npm install --save-dev vitest @benzene/testing @benzene/aws-lambda-testing
// test/apiGateway.test.ts
import { describe, expect, it } from 'vitest';
import { Context } from 'aws-lambda';
import { httpBuilder } from '@benzene/testing';
import { asApiGatewayRequest } from '@benzene/aws-lambda-testing';
import { handler } from '../src/handler.js';
const context = {} as Context;
const noopCallback = () => undefined;
describe('orders-lambda', () => {
it('POST /orders returns a 201 confirmation', async () => {
const event = asApiGatewayRequest(httpBuilder('POST', '/orders', { customerId: 'acme' }));
const response = (await handler(event, context, noopCallback)) as { statusCode: number; body: string };
expect(response.statusCode).toBe(201); // BenzeneResult.created -> 201
expect(JSON.parse(response.body)).toEqual({ orderId: 'order-acme' });
});
});
asApiGatewayRequest(...) builds the exact API Gateway event shape AWS delivers, and invoking handler
runs your real pipeline end-to-end — the same code path a deployed function takes. The matching
asSqs/asSns/asEventBridge/asAwsKafkaEvent helpers cover the other transports. See
Testing Benzene for the full pattern, and
examples/aws-lambda-functions for one domain tested across all
five transports.
6. Bundle and deploy
Lambda's nodejs22.x runtime runs a single JavaScript file, so bundle src/handler.ts and its
dependencies into one ESM file with esbuild:
npx esbuild src/handler.ts --bundle --platform=node --format=esm --target=node22 \
--outfile=dist/handler.mjs --banner:js="import { createRequire } from 'module'; const require = createRequire(import.meta.url);"
That produces dist/handler.mjs exporting handler. Zip it (cd dist && zip function.zip handler.mjs)
and deploy with your tool of choice — the handler string is handler.handler.
A minimal AWS SAM template.yaml:
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Globals:
Function:
Timeout: 30
MemorySize: 512
Runtime: nodejs22.x
Architectures:
- arm64
Resources:
PlaceOrderFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: place-order
Handler: handler.handler
CodeUri: dist/
Events:
HttpApi:
Type: HttpApi
sam deploy --guided
sam deploy --guided walks you through stack name and region on the first run, then remembers them in
samconfig.toml. Once deployed, SAM prints the API Gateway URL — POST to /orders with a JSON body
to confirm the handler responds. (If you prefer Serverless Framework or CDK, point the function's
handler at the same handler.handler and let it bundle with esbuild.)
7. Add a second transport
The whole point of Benzene is that a handler doesn't care which transport delivered its message. Add an event consumer that reacts to placed orders — the same shape, a different topic:
// add to src/handlers.ts
export class OrderPlaced {
orderId?: string;
}
export class WarehouseAck {
accepted?: boolean;
}
@message('order:placed', { requestType: OrderPlaced, responseType: WarehouseAck })
export class NotifyWarehouseHandler implements IMessageHandler<OrderPlaced, WarehouseAck> {
handleAsync(request: OrderPlaced): Promise<IBenzeneResultOf<WarehouseAck>> {
// ... notify the warehouse
const ack = new WarehouseAck();
ack.accepted = true;
return Promise.resolve(BenzeneResult.ok(ack));
}
}
NotifyWarehouseHandler has no @httpEndpoint — it's reached only by its topic, order:placed, over
whichever async transport delivers it. Now you have a deployment choice.
Model A — one Lambda function per transport (the default)
useSqs is already included in @benzene/aws-lambda. Give it its own StartUp and entry point:
// src/sqsStartUp.ts
import { IBenzeneServiceContainer } from '@benzene/abstractions';
import { BenzeneStartUp, IBenzeneApplicationBuilder } from '@benzene/abstractions-middleware';
import { addBenzene, useAwsLambda, useSqs, useMessageHandlers } from '@benzene/aws-lambda';
import { NotifyWarehouseHandler } from './handlers.js';
export class SqsStartUp implements BenzeneStartUp {
configureServices(services: IBenzeneServiceContainer): void {
addBenzene(services);
}
configure(app: IBenzeneApplicationBuilder): void {
useAwsLambda(app, (aws) => useSqs(aws, (sqs) => useMessageHandlers(sqs, NotifyWarehouseHandler)));
}
}
// src/sqs.ts
import { AwsLambdaHost } from '@benzene/aws-lambda';
import { SqsStartUp } from './sqsStartUp.js';
export const handler = new AwsLambdaHost(SqsStartUp).lambdaHandler;
Bundle src/sqs.ts to its own file and deploy it as a second Lambda function, with an SQS event-source
mapping pointing at sqs.handler. This is the port's default — one transport per entry point —
because under type erasure two transports can't share a single DI container (their message getters
register under the same erased token and overwrite each other). It maps cleanly to the AWS deployment
where each trigger points at its own Lambda function. Splitting like this doesn't multiply cold starts:
those scale with concurrency, not function count.
Model B — one Lambda function, several triggers (compositeAwsLambda)
When you'd rather have one Lambda function fronting several triggers — the AWS analog of .NET's
single stream-sniffing entry point — use compositeAwsLambda. It keeps each transport in its own
isolated container/pipeline (so no erasure collision) but exposes them behind one exported handler.
AWS delivers each trigger's event to that handler; the composite picks the first route whose
event-shape predicate matches and delegates:
// src/index.ts
import { useMessageHandlers, compositeAwsLambda, isApiGatewayEvent, isSqsEvent, toLambdaHandler, useApiGateway, useSqs } from '@benzene/aws-lambda';
import { PlaceOrderHandler, NotifyWarehouseHandler } from './handlers.js';
const entryPoint = compositeAwsLambda((c) => {
c.route(isApiGatewayEvent, (app) => useApiGateway(app, (api) => useMessageHandlers(api, PlaceOrderHandler)));
c.route(isSqsEvent, (app) => useSqs(app, (sqs) => useMessageHandlers(sqs, NotifyWarehouseHandler)));
});
export const handler = toLambdaHandler(entryPoint);
configureServices registrations apply to every route, and a registered instance
(addSingletonInstance) is the same object across all routes — a genuinely shared singleton — whereas
a factory singleton is built once per route. The event-shape predicates (isApiGatewayEvent,
isApiGatewayV2Event, isSqsEvent, isSnsEvent, isEventBridgeEvent, isKafkaEvent,
isKinesisEvent, isDynamoDbEvent, isS3Event) are the single source of truth each transport's own
routing delegates to.
Splitting into per-function Lambdas or consolidating into one composite is a deployment decision, not a
rewrite — the transport wiring inside configure/route is identical either way.
Supported event sources
Each transport is a use… free function you call inside configure (Model A) or a route (Model B).
A given function can wire up several sources; the incoming event's shape selects the right sub-pipeline.
| Event source | Function | Package |
|---|---|---|
| API Gateway (REST + HTTP API) | useApiGateway |
@benzene/aws-lambda-api-gateway |
| SQS | useSqs |
@benzene/aws-lambda-sqs |
| SNS | useSns |
@benzene/aws-lambda-sns |
| EventBridge | useEventBridge |
@benzene/aws-lambda-eventbridge |
| Kafka (MSK / self-managed) | useKafka |
@benzene/aws-lambda-kafka |
| DynamoDB Streams | useDynamoDb |
@benzene/aws-lambda-dynamodb |
| S3 | useS3 |
@benzene/aws-lambda-s3 |
| Kinesis | useKinesis |
@benzene/aws-lambda-kinesis |
The examples/aws-lambda-functions project hosts one order domain on
API Gateway, SQS, SNS, EventBridge, and Kafka — one function module per transport, each its own unified
BenzeneStartUp booted by the new AwsLambdaHost(StartUp).lambdaHandler one-liner.
SQS
useSqs(app, (sqs) => useMessageHandlers(sqs, NotifyWarehouseHandler));
SQS messages are processed in batches; each record's body is deserialized to your request type and
message attributes are mapped to headers. 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, or a queue fed by another system), call usePresetTopic before useMessageHandlers to route
every message on that queue to a fixed topic instead:
import { usePresetTopic, useMessageHandlers } from '@benzene/aws-lambda';
useSqs(app, (sqs) => {
usePresetTopic(sqs, 'order:placed');
useMessageHandlers(sqs, NotifyWarehouseHandler);
});
See Common Middleware for usePresetTopic in full.
SNS
useSns(app, (sns) => useMessageHandlers(sns, NotifyWarehouseHandler));
The topic is resolved from a topic message attribute and routed to the matching handler, same as
every other transport. There is no response to write back — SNS delivery is fire-and-forget.
EventBridge
useEventBridge(app, (eb) => useMessageHandlers(eb, NotifyWarehouseHandler));
The event's detail-type is the message topic — EventBridge's native routing key, so
@message('order:placed', …) handles events published with that detail-type — and detail is the
message body. Like SNS, delivery is fire-and-forget.
Kafka
useKafka(app, (kafka) => useMessageHandlers(kafka, NotifyWarehouseHandler));
Works for both Amazon MSK and self-managed Kafka. The record's Kafka topic routes it and the base64 record value is the payload; record headers are mapped to Benzene message headers.
Adding validation
Reject bad payloads before they reach a handler by configuring a router around the handlers with
useMessageHandlersWithRouter and a validation adapter such as useZodValidation:
import { useMessageHandlersWithRouter } from '@benzene/aws-lambda';
import { useZodValidation } from '@benzene/zod';
useSqs(app, (sqs) =>
useMessageHandlersWithRouter(sqs, (router) => useZodValidation(router), NotifyWarehouseHandler),
);
See Validation for the Zod, Joi, and Yup adapters.
Configuration
AwsLambdaHost builds the pipeline once, on cold start. Register configuration inside your StartUp's
configureServices — in Lambda the natural source is the function's environment variables via
process.env, which you set in the SAM template, console, or CDK/Terraform:
export class StartUp implements BenzeneStartUp {
configureServices(services: IBenzeneServiceContainer, _config: BenzeneConfiguration): void {
addBenzene(services);
services.addSingletonInstance(OrdersConfig, { tableName: process.env.ORDERS_TABLE ?? 'orders' });
}
configure(app: IBenzeneApplicationBuilder, _config: BenzeneConfiguration): void {
useAwsLambda(app, (aws) => useApiGateway(aws, (api) => useMessageHandlers(api, PlaceOrderHandler)));
}
}
Prefer the small
BenzeneConfigurationkey/value lookup (the second parameter) over readingprocess.envinline when a component test needs to layer overrides — implementgetConfiguration(): { get: (key) => process.env[key] }on theStartUpand the test can override any key withbenzeneTestHost(StartUp).withConfiguration({ ... }). The port'sBenzeneConfigurationstands in for .NET's richerIConfiguration; see the Porting conventions.
Troubleshooting
Function crashes immediately / "cannot read properties of undefined". You almost certainly wrote
export const handler = host.functionHandlerAsync, which detaches this. Use
export const handler = new AwsLambdaHost(StartUp).lambdaHandler — see
step 4.
Handler never called / 404 from API Gateway. Check that @httpEndpoint('METHOD', '/path') matches
the request exactly (method and path), and that the handler class was passed to useMessageHandlers(...).
SQS/SNS/Kafka message never routes to a handler. These transports resolve the topic from a message
attribute (or the record's topic), not the body. Confirm the producer sets a topic attribute, or call
usePresetTopic(...) to pin a fixed topic — and confirm a handler exists with a matching
@message('...') topic.
Two transports in one non-composite entry point clash. Under type erasure two transports can't share
one container. Give each transport its own StartUp + AwsLambdaHost (Model A) or use
compositeAwsLambda (Model B) — don't call two use… transports inside a single configure.
Bundle fails to load on Lambda (ESM / require errors). Ensure you bundle with
--format=esm --platform=node --target=node22 and name the output .mjs (or set "type":"module" in a
package.json shipped alongside it), so the nodejs22.x runtime loads it as an ES module.
Cold starts feel slow. new AwsLambdaHost(StartUp) runs configureServices/configure once per
execution environment, so cold-start cost is dominated by whatever configureServices does. Prefer lazy
initialization inside your services over eager work (e.g. opening a DB connection) at construction time.
See Also
- Getting Started — build the same handler locally on Express first
- Azure Functions Setup — the same handlers, hosted on Azure
- Message Handlers — the handler contract, topics, and
@message/@httpEndpoint - Message Result —
BenzeneResult.ok/.createdand the result envelope - Middleware and Common Middleware — what else composes into the pipeline
- Validation — reject bad requests with the Zod, Joi, or Yup adapters
- Correlation IDs — trace requests across services
- Testing Benzene — testing handlers and pipelines end-to-end
- Cookbooks — recipes for real-world scenarios
examples/aws-lambda-functions— one domain on five AWS transports