cdk-rest-api-dynamodb
Purpose
Use this skill to design and implement DynamoDB-backed REST APIs on AWS in a way that fits the target repository, with DynamoDB as the default datastore.
When local reusable CDK constructs exist, use them as the source of truth. When they do not, use the references in this skill as portable patterns to generate an equivalent structure rather than one-off code.
Portable references live in references/. Load only the patterns needed for the task:
references/rest-api-pattern.mdfor API Gateway REST route compositionreferences/node-lambda-pattern.mdfor Lambda defaults and bundling shapereferences/dynamodb-pattern.mdfor DynamoDB table and access-pattern guidancereferences/importer-pattern.mdfor importing existing AWS resources when no local importer helper existsreferences/response-pattern.mdfor standardized API responses when no localRestResult-style helper existsreferences/jwt-pattern.mdfor JWT verification when no local token service existsreferences/runtime-composition-pattern.mdforsrc/app.ts-style singleton dependency wiring and repository context compositionreferences/middleware-pattern.mdfor reusable Middy middleware such as auth, validation, and HTTP error translationreferences/services-pattern.mdfor portable logger, storage, notification, and mapper service designreferences/utilities-pattern.mdfor shared response helpers, error types, status codes, cursor helpers, and other runtime utilities
Portability Rule
This skill should work across repositories. Do not assume a specific folder such as lib/constructs/ exists, and do not assume the target repository already has the same abstractions.
The source of truth order is:
- The target repository's current architecture, naming, and abstractions
- The skill's documented patterns
- The inline examples in this skill
If local code differs from the skill examples, follow local code and use the examples only as design guidance.
Repository Discovery
Start every use of this skill with a short discovery pass before proposing code.
Search for:
- API-related CDK code
- Lambda wrapper constructs or helpers
- DynamoDB constructs or direct table creation patterns
- resource importer helpers for existing infrastructure
- runtime composition files such as
src/app.ts,src/data/context.ts, or dependency containers - reusable middleware for auth, validation, JSON parsing, and HTTP error handling
- shared services for logging, storage, notifications, token verification, and mapping
- standardized API response utilities
- JWT verification helpers or token services
- Stack files that already compose routes, auth, models, or permissions
Likely locations:
lib/constructs/lib/infra/constructs/infra/packages/*/lib/constructs/packages/*/infra/- stack files that instantiate
RestApi,Lambda,NodejsFunction, orTableV2
After discovery, choose one mode and state it:
Existing pattern mode: extend the repository's existing abstractionsPattern generation mode: generate the missing reusable pattern because no relevant abstraction exists
Existing Pattern Mode
Use this mode when the repo already has abstractions similar to the current repository, such as:
lib/constructs/rest-api.tsfor API Gateway REST resources and route wiringlib/constructs/node-lambda.tsfor Lambda defaults and bundlinglib/constructs/dynamodb.tsfor DynamoDBTableV2creationlib/constructs/api-importer.tsfor importing existing AWS resources into stackslib/constructs/api-models.tsfor the route and API prop shapessrc/app.tsorsrc/dependencies/for singleton runtime wiringsrc/data/context.tsfor repository aggregationsrc/middlewares/for authorization, validation, and error middlewaresrc/services/logger.service.tsfor shared loggingsrc/services/storage/storage.service.tsfor presigned upload or download URLssrc/services/messaging/for SES or notification integrationssrc/services/mapping/orsrc/utilities/mappers/for DTO and query translationsrc/utilities/rest-result.tsfor standardized API Gateway responsessrc/services/token.service.tsfor JWT validation built onaws-jwt-verify
Default to DynamoDB as the datastore unless the user explicitly wants something else.
Pattern Generation Details
Use this mode when the target repository does not already provide a reusable abstraction for one or more layers.
In this mode:
- use the references as guidance for the shape of the generated code, not as a requirement that exact files or class names exist
- introduce the smallest reusable abstraction that makes future endpoints easier to add
- prefer generating a reusable route, repository, middleware, or service pattern over embedding everything in a single handler
- keep generated names and folders aligned with the target repository's conventions
Working Rules
- Read the relevant local construct or stack code before proposing or writing endpoint infrastructure.
- Prefer code snippets that mirror the actual construct APIs in the target repo instead of generic CDK examples.
- If the repository already has a route composition abstraction such as
RestServerlessApi, use it. If not, generate a small reusable pattern instead of scattering raw CDK logic. - Model data access around the repository's existing DynamoDB table pattern when it exists. Otherwise generate a consistent table and repository pattern.
- Keep examples aligned with the repository defaults: Node.js 24.x, ARM64, Middy handlers, Zod validation, and API Gateway REST API.
- When a stack needs to attach to existing infrastructure, prefer the local importer helper over direct
from*imports scattered throughout the stack. - When controllers or handlers return API Gateway responses, prefer the local response utility over ad hoc response objects.
- Prefer API Gateway Cognito authorizers for route protection when the repository already uses them for that endpoint shape. Use
aws-jwt-verifyonly when JWT verification must happen inside Lambda code rather than at the API Gateway authorizer layer. - Reuse existing runtime composition in
src/app.ts,src/dependencies/, or equivalent when it exists. If it does not, create a lightweight equivalent rather than instantiating AWS clients, repositories, or services inside handlers. - When local middleware exists, extend the existing Middy chain instead of embedding auth, validation, or error translation logic directly in controllers. If it does not, generate reusable middleware rather than inline checks.
- Prefer small reusable services for cross-cutting concerns such as logging, storage, notifications, and DTO mapping.
Default Assumptions
- API type: API Gateway REST API
- Compute: one Lambda per route unless the change is clearly tiny
- Datastore: DynamoDB
- Lambda runtime defaults: those from an existing Lambda wrapper when present, otherwise a consistent Node.js Lambda baseline
- Auth: inherit existing stack defaults; if auth is unclear, prefer route-level compatibility with Cognito authorizers
- Validation: use API Gateway request parameters/models plus existing handler-side validation
- JWT verification library: prefer
aws-jwt-verifyonly for in-Lambda verification paths
If the user gives constraints that conflict with these defaults, adapt and state the change.
Preferred src/ Structure
Unless the target repository already has a different established layout, use an application structure like this:
src/
├── controllers/
├── data/
│ └── context.ts
├── dependencies/
├── handlers/
├── middlewares/
├── models/
│ └── validation/
├── services/
├── utilities/
└── mappers/
└── app.ts
Treat this as a conceptual layout, not a hard requirement:
src/handlers/for Lambda entrypoints and Middy wiringsrc/controllers/for request orchestration andRestResultresponsessrc/services/for business logic, auth helpers, and cross-cutting domain operationssrc/data/for repositories and datastore accesssrc/data/context.tsfor a shared repository aggregate such asDatabaseContextorDataContextsrc/models/validation/for Zod schemas and request validation shapessrc/middlewares/for reusable Middy middlewaresrc/dependencies/for dependency wiring and composition rootssrc/utilities/for shared helperssrc/utilities/mappers/for mapping and transformation helperssrc/app.tsfor warm Lambda singleton wiring when the repository uses module-scope dependency exports
When generating endpoint work, prefer this file flow:
- handler in
src/handlers/ - controller in
src/controllers/ - service in
src/services/if business logic is more than trivial - repository in
src/data/for DynamoDB access - validation schema in
src/models/validation/when request validation is needed - dependency or context wiring in
src/app.tsorsrc/data/context.tswhen new repositories or services are introduced
If the repository already uses a different but coherent src/ structure, follow that instead of forcing this one.
Construct-Centered Guidance
1. Datastore design should start from a reusable table pattern
Use the local construct rather than raw dynamodb.TableV2 when adding a new table. If the repository does not have one, create a reusable table pattern instead of repeating table setup inline.
import { DynamoDBTable } from '../constructs/dynamodb';
const usersTable = new DynamoDBTable(this, 'UsersTable', {
tableName: `Users${props.deploymentStage}`,
globalIndexOverload: 1,
timeToLiveAttribute: 'ExpiresAt',
deletionProtection: props.deploymentStage === 'Prod',
}).table;
Important pattern details:
- Primary keys are fixed as
PKandSK globalIndexOverload: 1createsGSI1withGSI1PKandGSI1SKglobalIndexAttributecreates named GSIs from explicit attribute names- default
removalPolicyisRETAIN
Design endpoint storage and access patterns around those keys instead of inventing a different schema shape in the skill output.
2. Route composition should be centralized
If the repository already has a route composition abstraction such as RestServerlessApi, extend it. Otherwise create one reusable route composition layer and keep route registration centralized.
Common route helpers are:
getgetByIdpostputdelete
Set shared route defaults once when multiple endpoints share auth, environment, table access, memory, or VPC settings.
import { RestServerlessApi } from '../constructs/rest-api';
const api = new RestServerlessApi(this, 'IdentityApi', {
name: `IdentityApi${props.deploymentStage}`,
corsOrigin: process.env.CORS_ORIGIN || '*',
logging: {
enabled: true,
deploymentStage: props.deploymentStage,
notificationEmail: process.env.NOTIFICATION_EMAIL,
},
});
api.setDefaultRouteOptions({
authorizer: api.createCognitoAuthorizer(userPool),
grantTableAccess: [usersTable],
environmentVariables: {
USERS_TABLE_NAME: usersTable.tableName,
},
});
Important pattern behavior:
routePathmust start with/- default route options merge with per-route options
- setting
authorizer: undefinedon a route explicitly removes the default authorizer - DynamoDB permissions are granted automatically from
grantTableAccess GETroutes get read permissions;POST,PUT, andDELETEget read/write permissions- GSI query and scan permissions are added on
/index/*
3. Use route snippets based on the local RestRouteOptions
When documenting or generating an endpoint, prefer snippets like these.
Create route:
import * as path from 'node:path';
import { CREATE_USER_HANDLER } from '../../src/handlers/users/create-user.handler';
api.post({
routePath: '/users',
lambdaName: `CreateUser${props.deploymentStage}`,
filePath: path.join(__dirname, '../../src/handlers/users/create-user.handler.ts'),
handlerName: CREATE_USER_HANDLER,
description: 'Create a user record',
model: createUserModel,
});
List route with query parameters:
import { LIST_USERS_HANDLER } from '../../src/handlers/users/list-users.handler';
api.get({
routePath: '/users',
lambdaName: `ListUsers${props.deploymentStage}`,
filePath: path.join(__dirname, '../../src/handlers/users/list-users.handler.ts'),
handlerName: LIST_USERS_HANDLER,
description: 'List users',
requestParameters: {
'method.request.querystring.cursor': false,
'method.request.querystring.limit': false,
},
});
Get-by-id route with path parameter:
import { GET_USER_HANDLER } from '../../src/handlers/users/get-user.handler';
api.getById({
routePath: '/users/{id}',
lambdaName: `GetUser${props.deploymentStage}`,
filePath: path.join(__dirname, '../../src/handlers/users/get-user.handler.ts'),
handlerName: GET_USER_HANDLER,
description: 'Get a user by id',
requestParameters: {
'method.request.path.id': true,
},
});
Public route that overrides a default authorizer:
api.get({
routePath: '/health',
lambdaName: `Health${props.deploymentStage}`,
filePath: path.join(__dirname, '../../src/handlers/health.handler.ts'),
handlerName: HEALTH_HANDLER,
description: 'Health check',
authorizer: undefined,
});
4. NodeLambda defines the Lambda baseline
If you need a standalone Lambda or need to explain route internals, use the local construct shape.
import { NodeLambda } from '../constructs/node-lambda';
const fn = new NodeLambda(this, 'CreateUserFn', {
lambdaName: `CreateUser${props.deploymentStage}`,
filePath: path.join(__dirname, '../../src/handlers/users/create-user.handler.ts'),
handlerName: CREATE_USER_HANDLER,
description: 'Create a user record',
environmentVariables: {
USERS_TABLE_NAME: usersTable.tableName,
},
}).function;
Repository-specific defaults already baked in:
- runtime:
NODEJS_24_X - architecture:
ARM_64 - memory:
256 - timeout:
15 - tracing: active
- log retention: three months
@aws-sdk/*excluded from bundle
Do not restate conflicting defaults in skill-generated examples.
5. Prefer Importer for existing infrastructure
If the repository exposes an importer helper like lib/constructs/api-importer.ts, use it when wiring a stack to resources that already exist.
Prefer patterns like:
import { Importer } from '../constructs/api-importer';
const usersTable = Importer.getDynamoDbTable(this, process.env.DYNAMODB_TABLE!);
const userPool = Importer.getCognitoUserPoolById(this, process.env.COGNITO_USER_POOL_ID!);
const apiDomain = Importer.getApiGatewayDomainName(
this,
process.env.API_DOMAIN_NAME!,
process.env.API_DOMAIN_TARGET!,
process.env.API_DOMAIN_HOSTED_ZONE_ID!,
);
Use the importer when:
- the stack is extending an existing table, user pool, API, domain, or other shared resource
- the repository already centralizes
from*imports behind an importer helper
Do not replace a repository-wide importer pattern with direct fromTableName, fromUserPoolId, or similar calls unless the user explicitly asks for that.
6. Prefer RestResult for API responses
If the repository has a response helper like src/utilities/rest-result.ts, use it as the default response format for controllers and handlers.
Prefer methods such as:
RestResult.Ok(...)RestResult.Created(..., location)RestResult.NoContent()RestResult.BadRequest(...)RestResult.NotFound(...)RestResult.Conflict(...)RestResult.InternalServerError(...)
This keeps CORS headers, content types, status codes, and error body shapes consistent across endpoints.
If no local response helper exists, use the reference patterns and produce standard APIGatewayProxyResult objects instead.
7. Prefer aws-jwt-verify only when Cognito authorizers are not handling auth
If a route is already protected by an API Gateway Cognito authorizer, do not add duplicate JWT verification in the Lambda by default.
Use aws-jwt-verify only when the repository validates Cognito JWTs or other OIDC JWTs inside Lambda code instead of relying on API Gateway Cognito authorizers for that route.
Prefer local abstractions like:
import { TokenService, TokenType } from '../services/token.service';
const tokenService = TokenService.forCognito(
process.env.COGNITO_USER_POOL_ID!,
process.env.COGNITO_CLIENT_ID!,
process.env.AWS_REGION,
TokenType.ACCESS_TOKEN,
);
const payload = await tokenService.validateToken(token);
Use this preference because it keeps JWT verification aligned with AWS and Cognito semantics, especially issuer, audience, token use, and JWKS handling.
Decision rule:
- if the route uses
createCognitoAuthorizer(...)or an equivalent API Gateway Cognito authorizer, prefer that and do not addaws-jwt-verifyin the handler unless the user explicitly needs claim re-verification inside Lambda - if the route uses a custom token authorizer, Lambda-side verification flow, or non-Cognito OIDC JWT validation, prefer
aws-jwt-verify - if auth is already fully enforced at API Gateway and the handler only needs claims passed through context, do not introduce
aws-jwt-verify
Avoid:
- duplicating Cognito authorizer validation inside Lambda without a clear reason
- hand-rolled signature verification
- using
decodeas if it were verification - introducing another JWT library when the repo already uses
aws-jwt-verify
If the repository does not already have a token service or helper, load references/jwt-pattern.md.
Pattern Generation Mode
Use this mode only when the repository does not already expose relevant constructs or composition helpers.
When using pattern generation mode, load the relevant file from references/ instead of relying only on the inline examples in this skill.
Also use the reference patterns when the repository lacks:
- an importer helper for existing resources
- a standard API response helper such as
RestResult - a JWT verification helper or token service
- a runtime dependency container such as
src/app.ts - reusable middleware for auth, validation, or HTTP error handling
- shared services for logging, storage, notifications, or mapping
- shared utility modules for errors, status codes, or cursor helpers
In pattern generation mode:
- generate a reusable API construct or stack pattern instead of one-off infrastructure code
- use API Gateway REST API unless the user explicitly wants HTTP API
- use one Lambda per route by default
- choose a single-table or multi-table pattern deliberately instead of defaulting by habit
- keep examples concise and portable
Portable baseline:
const table = new dynamodb.TableV2(this, 'UsersTable', {
partitionKey: { name: 'PK', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'SK', type: dynamodb.AttributeType.STRING },
});
const createUserFn = new lambdaNodejs.NodejsFunction(this, 'CreateUserFn', {
runtime: lambda.Runtime.NODEJS_24_X,
architecture: lambda.Architecture.ARM_64,
entry: path.join(__dirname, '../../src/handlers/create-user.handler.ts'),
handler: 'CREATE_USER_HANDLER',
environment: {
USERS_TABLE_NAME: table.tableName,
},
});
table.grantReadWriteData(createUserFn);
api.root.addResource('users').addMethod('POST', new apiGateway.LambdaIntegration(createUserFn));
If the repository later adds its own constructs, stop using the generated pattern and move back to existing pattern mode.
Request Modeling
When the user asks for request validation, use the local API Gateway route support:
modelfor JSON request-body validationrequestParametersfor path/query/header requirementspostuses body validationgetandgetByIduse parameter validationputsupports mixed validation
If the repo already has API models or Zod schemas for the feature area, reuse them instead of inventing parallel validation definitions.
If the repo already has a standard API response helper, reuse it in the controller or handler examples.
DynamoDB Data Modeling Strategy
Choose the table strategy deliberately before generating repositories and routes.
Default to single-table design when:
- the API serves one bounded domain with several related entity types
- entities are commonly fetched together or by shared access patterns
- relationship traversal, fan-out queries, or timeline-style reads matter
- the repository does not already have separate tables with strong boundaries
Prefer multi-table design when:
- entities have independent lifecycles and very different throughput or retention needs
- access patterns are simple and mostly one-entity-at-a-time
- operational isolation matters more than shared-query flexibility
- the repository already organizes persistence as separate tables per aggregate
If the user does not specify, use this rule:
- single-table by default for greenfield DynamoDB APIs
- multi-table when extending a repository that already uses separate tables
When using single-table design, bias toward patterns like:
- Store entities under
PKandSK - Use GSIs for alternate lookup patterns
- Pass tables through
grantTableAccess - Pass table names through
environmentVariables - Keep Lambda handlers thin and push key construction/query logic into repositories or services
When using multi-table design, keep these rules:
- one repository per table or aggregate root
- name environment variables per table explicitly
- grant each route only the tables it needs
- avoid re-creating single-table key helpers where simple table-specific queries are clearer
- keep cross-table workflows in services, not handlers
Prefer examples such as:
api.put({
routePath: '/users/{id}',
lambdaName: `UpdateUser${props.deploymentStage}`,
filePath: path.join(__dirname, '../../src/handlers/users/update-user.handler.ts'),
handlerName: UPDATE_USER_HANDLER,
description: 'Update a user',
requestParameters: {
'method.request.path.id': true,
},
model: updateUserModel,
});
That keeps the skill focused on endpoint composition and DynamoDB-backed handler wiring while still making the table strategy explicit.
Response Style
When using this skill, produce:
- A short statement of the endpoint(s) being added or changed
- A note saying whether you are using
Existing pattern modeorPattern generation mode - CDK snippets based on the local constructs when present, or the portable baseline when not
- The chosen DynamoDB table strategy:
single-tableormulti-table, with one sentence of reasoning when you are generating the pattern - Any handler, model, or repository follow-on work needed to make the endpoint functional
- Explicit assumptions where auth, table shape, or validation is ambiguous
Avoid generic AWS guidance when a repository-specific construct snippet would answer the request better. Treat the repository's code as authoritative and the skill as workflow guidance, not as a competing source of truth.