Skip to content

Getting started

Eugene Lazutkin edited this page Apr 20, 2026 · 1 revision

Getting started

This page walks through a complete working Lambda deployment, once per trigger shape. All four end up importing the same createLambdaAdapter and exporting the same handler — only the Lambda trigger configuration differs.

Prerequisites

  • An AWS account with permissions to deploy Lambda + the relevant trigger (API Gateway, Function URL, or ALB).
  • A DynamoDB table (or DynamoDB Local running at http://localhost:8000 for experimentation via the local bridges).
  • Node 20+ for local development.
  • Your preferred IaC (SAM, CDK, Serverless Framework, Terraform, Pulumi, plain aws CLI). This wiki shows config fragments as plain CloudFormation YAML — port to your tool of choice.

Install

npm install dynamodb-toolkit-lambda dynamodb-toolkit @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb

No framework peer dep. If you use TypeScript for handler authoring, also install @types/aws-lambda as a dev dependency — the adapter exports types that reference aws-lambda symbols but doesn't require them at runtime.

The handler

// src/handler.js
import {DynamoDBClient} from '@aws-sdk/client-dynamodb';
import {DynamoDBDocumentClient} from '@aws-sdk/lib-dynamodb';
import {Adapter} from 'dynamodb-toolkit';
import {createLambdaAdapter} from 'dynamodb-toolkit-lambda';

const ddb = DynamoDBDocumentClient.from(
  new DynamoDBClient({region: process.env.AWS_REGION}),
  {marshallOptions: {removeUndefinedValues: true}}
);

const planets = new Adapter({client: ddb, table: process.env.PLANETS_TABLE, keyFields: ['name']});

export const handler = createLambdaAdapter(planets, {mountPath: '/planets'});

Everything outside handler runs once per container (cold start) and is reused across warm invocations — the DynamoDBDocumentClient, the Adapter, the policy merge, the factory. Keep the top of the module light: AWS SDK import + one client + one adapter is the fast path.

API Gateway HTTP API (payload 2.0)

Modern default. Flat envelope, native cookies, cheapest per-request.

# template.yaml (SAM)
PlanetsFunction:
  Type: AWS::Serverless::Function
  Properties:
    CodeUri: ./
    Handler: src/handler.handler
    Runtime: nodejs20.x
    Environment:
      Variables:
        PLANETS_TABLE: !Ref PlanetsTable
    Events:
      AnyPlanets:
        Type: HttpApi
        Properties:
          Path: /planets/{proxy+}
          Method: ANY
      RootPlanets:
        Type: HttpApi
        Properties:
          Path: /planets
          Method: ANY

Two events are needed because {proxy+} doesn't match the mount root /planets itself — only /planets/.... Both arrive at the same Lambda and the adapter's mountPath: '/planets' strips them consistently.

Lambda Function URL (payload 2.0)

Same payload format as API Gateway HTTP; skip the API Gateway layer entirely.

PlanetsFunction:
  Type: AWS::Serverless::Function
  Properties:
    Handler: src/handler.handler
    Runtime: nodejs20.x
    FunctionUrlConfig:
      AuthType: AWS_IAM       # or NONE for public; use a custom authorizer pattern otherwise

Drop mountPath when the function URL is the only thing routing to this Lambda — the adapter then owns / directly:

export const handler = createLambdaAdapter(planets);   // no mountPath
// Function URL: https://abc123.lambda-url.us-east-1.on.aws/earth

API Gateway REST API (payload 1.0)

Older API Gateway variant. Same factory, same export.

PlanetsApi:
  Type: AWS::Serverless::Api
  Properties:
    StageName: prod
    EndpointConfiguration: REGIONAL

PlanetsFunction:
  Type: AWS::Serverless::Function
  Properties:
    Handler: src/handler.handler
    Runtime: nodejs20.x
    Events:
      Any:
        Type: Api
        Properties:
          RestApiId: !Ref PlanetsApi
          Path: /planets/{proxy+}
          Method: ANY

The adapter auto-detects the 1.0 payload (v1 events carry httpMethod + path, not version: '2.0') and emits APIGatewayProxyResult — the right envelope shape. See Event shapes.

ALB target group

Map the Lambda to an ALB listener rule. The adapter auto-detects ALB events (event.requestContext.elb marker).

PlanetsTargetGroup:
  Type: AWS::ElasticLoadBalancingV2::TargetGroup
  Properties:
    TargetType: lambda
    Targets:
      - Id: !GetAtt PlanetsFunction.Arn
    TargetGroupAttributes:
      - Key: lambda.multi_value_headers.enabled
        Value: 'true'          # optional — see below

Flip lambda.multi_value_headers.enabled to true if downstream clients rely on duplicated headers (e.g. multiple Set-Cookie). The adapter mirrors whichever shape the trigger delivers — no extra configuration on the handler side. See Event shapes → Multi-value headers.

Try it

Assuming the API Gateway stage is at https://api.example.com/prod and the mount is /planets:

# Create
curl -X POST https://api.example.com/prod/planets/ \
     -H 'content-type: application/json' \
     -d '{"name":"earth","mass":5.97,"climate":"temperate"}'

# Read one
curl https://api.example.com/prod/planets/earth

# Partial update (PATCH merges, does not replace)
curl -X PATCH https://api.example.com/prod/planets/earth \
     -H 'content-type: application/json' \
     -d '{"population":8.2e9}'

# List with paging
curl 'https://api.example.com/prod/planets/?offset=0&limit=25'

# Projection
curl 'https://api.example.com/prod/planets/?fields=name,mass'

# Bulk fetch by name
curl 'https://api.example.com/prod/planets/-by-names?names=earth,mars,venus'

# Delete
curl -X DELETE https://api.example.com/prod/planets/pluto

Stage prefixes (/prod) are stripped by the trigger before the Lambda sees the event, so the adapter's mountPath shouldn't include them. See Options → mountPath.

IAM

Minimum DynamoDB permissions for the Lambda execution role:

Policies:
  - Version: '2012-10-17'
    Statement:
      - Effect: Allow
        Action:
          - dynamodb:GetItem
          - dynamodb:BatchGetItem
          - dynamodb:PutItem
          - dynamodb:UpdateItem
          - dynamodb:DeleteItem
          - dynamodb:BatchWriteItem
          - dynamodb:Query
          - dynamodb:Scan
          - dynamodb:TransactWriteItems
        Resource:
          - !GetAtt PlanetsTable.Arn
          - !Sub '${PlanetsTable.Arn}/index/*'

Prune by route — e.g. drop BatchWriteItem + DeleteItem if you never expose DELETE / or /-by-names.

Without a mount prefix

If the Lambda owns every path under its trigger (typical for Function URLs and single-resource deployments), omit mountPath:

const handler = createLambdaAdapter(planets);
// GET /earth, POST /, PATCH /earth — all hit the adapter directly.

The adapter matches routes against the full event path — event.path on v1 / ALB, event.rawPath on v2.

Local development before deploying

Don't round-trip to AWS while iterating. Spin up a local listener that speaks the exact Lambda event shape:

import http from 'node:http';
import {createNodeListener} from 'dynamodb-toolkit-lambda/local.js';
import {handler} from './src/handler.js';

http.createServer(createNodeListener(handler)).listen(3000);
node local-server.js
curl http://localhost:3000/planets/earth

Bun / Deno / Cloudflare Workers variants and Koa / Express bridging live on Local debug bridges.

Next steps

  • Custom status codes, error bodiesError handling.
  • Composite (partition + sort) keysComposite keys.
  • Per-tenant list filtering via exampleFromContextOptions.
  • Understanding the four event shapes and their gotchasEvent shapes.

Clone this wiki locally