Migrating from Serverless Framework to AWS CDK: Part 3 - Lambda Functions and API Gateway
Migrate Lambda functions, API Gateway, request validation, and error handling from Serverless Framework to AWS CDK with practical examples.
Lambda functions and API Gateway configurations are where migration complexity concentrates. A YAML-to-TypeScript conversion looks mechanical until bundling, memory sizing, and error handling each turn into a separate decision.
The pattern that holds up is a single shared NodejsFunction construct: runtime, architecture, memory, tracing, and bundling defaults live in one place, and every prop stays overridable for the functions that need something else. API Gateway gets the same treatment, with validators, authorizers, and integration responses defined once and reused across routes.
Series Navigation:
- Part 1: Why Make the Switch?
- Part 2: Setting Up Your CDK Environment
- Part 3: Migrating Lambda Functions and API Gateway (this post)
- Part 4: Database Resources and Environment Management
- Part 5: Authentication, Authorization, and IAM
- Part 6: Migration Strategies and Best Practices
Understanding Function Complexity
Lambda function migrations quickly become complex when you realize how many different patterns exist in a real system. Functions often fall into different categories with varying requirements:
Common function types:
- API endpoint handlers with different response patterns
- Background job processors with varying memory needs
- Webhook handlers requiring fast response times
- Scheduled functions with different timeout requirements
Each type benefits from different memory settings, timeout configurations, and deployment patterns.
Building a Standardized Lambda Construct
In Serverless Framework, each function definition carries its own memory, timeout, and environment settings, repeated per function:
# serverless.yml
functions:
getUser:
handler: src/handlers/users.get
events:
- http:
path: users/{id}
method: get
cors: true
environment:
USERS_TABLE: ${self:service}-${opt:stage}-users
timeout: 10
memorySize: 256
The CDK equivalent pushes those defaults into one construct and leaves only the per-function differences at the call site:
// lib/constructs/production-lambda.ts
import { NodejsFunction, NodejsFunctionProps } from 'aws-cdk-lib/aws-lambda-nodejs';
import { Runtime, Tracing, Architecture } from 'aws-cdk-lib/aws-lambda';
import { Duration, Tags } from 'aws-cdk-lib';
import { RetentionDays } from 'aws-cdk-lib/aws-logs';
import { Construct } from 'constructs';
export interface ProductionLambdaProps extends Omit<NodejsFunctionProps, 'runtime'> {
stage: string;
functionName: string;
// Optional per-function overrides
enableProvisioning?: boolean;
enableSnapStart?: boolean;
}
export class ProductionLambda extends NodejsFunction {
constructor(scope: Construct, id: string, props: ProductionLambdaProps) {
super(scope, id, {
...props,
runtime: Runtime.NODEJS_20_X,
architecture: Architecture.ARM_64, // ARM64 offers better price-performance
// Memory default derived from the function's category
memorySize: props.memorySize || ProductionLambda.getOptimalMemory(props.functionName),
// Timeout strategy: 28s max (API Gateway limit is 29s)
timeout: props.timeout || Duration.seconds(28),
// Tracing enabled in production only
tracing: props.stage === 'prod' ? Tracing.ACTIVE : Tracing.DISABLED,
// Log retention optimized for cost vs compliance
logRetention: props.stage === 'prod' ? RetentionDays.ONE_MONTH : RetentionDays.ONE_WEEK,
// Environment variables that every function needs
environment: {
NODE_OPTIONS: '--enable-source-maps --max-old-space-size=896',
AWS_NODEJS_CONNECTION_REUSE_ENABLED: '1',
STAGE: props.stage,
FUNCTION_NAME: props.functionName,
...props.environment,
},
// Bundling optimizations for cold start improvement
bundling: {
minify: props.stage === 'prod',
sourceMap: true,
sourcesContent: false,
target: 'node20',
keepNames: true,
// Tree shaking for smaller bundle sizes
esbuildArgs: { '--tree-shaking': 'true' },
// External modules (provided by Lambda runtime)
externalModules: [
'@aws-sdk/*', // AWS SDK v3 in Lambda runtime
'aws-lambda', // Lambda types
],
// Bundle analysis for large functions
metafile: props.stage !== 'prod',
// Custom banner for production debugging
banner: props.stage === 'prod'
? '/* Production Lambda - Generated by CDK */'
: undefined,
// Define for dead code elimination
define: {
'process.env.NODE_ENV': props.stage === 'prod' ? '"production"' : '"development"',
},
},
// Reserved concurrency for critical functions
reservedConcurrentExecutions: props.enableProvisioning ? 10 : undefined,
});
// Add standard tags for all functions
Tags.of(this).add('Stage', props.stage);
Tags.of(this).add('FunctionName', props.functionName);
Tags.of(this).add('ManagedBy', 'CDK');
}
// Memory defaults by function category
private static getOptimalMemory(functionName: string): number {
// API functions: CPU-bound, benefit from more memory
if (functionName.includes('api-')) return 1024;
// Background jobs: Memory-intensive processing
if (functionName.includes('job-')) return 2048;
// Webhooks: Fast response needed
if (functionName.includes('webhook-')) return 512;
// Default: Balanced performance/cost
return 1024;
}
}
// Usage example with standardized patterns
const getUserFn = new ProductionLambda(this, 'GetUserFunction', {
stage: config.stage,
functionName: 'api-get-user',
entry: 'src/handlers/users/get.ts',
handler: 'handler',
environment: {
USERS_TABLE: usersTable.tableName,
},
});
// Type-safe permissions (no more wildcard IAM policies)
usersTable.grantReadData(getUserFn);
// API Gateway integration: the proxy integration passes the handler's
// statusCode, headers, and body straight through, so error responses
// stay in the handler
const userIdResource = users.addResource('{id}');
userIdResource.addMethod('GET', new LambdaIntegration(getUserFn));
Lambda Layers Migration
Serverless Framework layers:
layers:
shared:
path: layers/shared
compatibleRuntimes:
- nodejs20.x
functions:
createUser:
handler: src/handlers/users.create
layers:
- {Ref: SharedLambdaLayer}
CDK approach with better type safety:
// lib/constructs/shared-layer.ts
import { LayerVersion, Code, Runtime } from 'aws-cdk-lib/aws-lambda';
import { Construct } from 'constructs';
export class SharedLayer extends LayerVersion {
constructor(scope: Construct, id: string) {
super(scope, id, {
code: Code.fromAsset('layers/shared'),
compatibleRuntimes: [Runtime.NODEJS_20_X],
description: 'Shared utilities and dependencies',
});
}
}
// Usage in stack
const sharedLayer = new SharedLayer(this, 'SharedLayer');
const createUserFn = new ServerlessFunction(this, 'CreateUserFunction', {
entry: 'src/handlers/users.ts',
handler: 'create',
config,
layers: [sharedLayer],
});
Function Bundling and Dependencies
CDK’s NodejsFunction provides sophisticated bundling options:
// lib/constructs/optimized-function.ts
export class OptimizedFunction extends ServerlessFunction {
constructor(scope: Construct, id: string, props: ServerlessFunctionProps) {
super(scope, id, {
...props,
bundling: {
minify: props.config.stage === 'prod',
sourceMap: true,
sourcesContent: false,
target: 'es2022',
keepNames: true,
// External modules (not bundled)
externalModules: [
'@aws-sdk/*', // AWS SDK v3 provided by Lambda runtime
'aws-lambda', // Types only
],
// Force include specific modules
nodeModules: ['bcrypt', 'sharp'], // Native dependencies
// Build environment
environment: {
NODE_ENV: props.config.stage === 'prod' ? 'production' : 'development',
},
// Custom esbuild plugins
esbuildArgs: {
'--log-level': 'warning',
'--tree-shaking': 'true',
},
},
});
}
}
API Gateway Advanced Configurations
Request Validation
Serverless Framework request validation:
functions:
createUser:
handler: src/handlers/users.create
events:
- http:
path: users
method: post
request:
schemas:
application/json: ${file(schemas/create-user.json)}
CDK with inline models and validators:
// lib/constructs/validated-api.ts
import {
RestApi,
Model,
JsonSchema,
JsonSchemaType,
RequestValidator,
MethodOptions
} from 'aws-cdk-lib/aws-apigateway';
export class ValidatedApi extends RestApi {
private validator: RequestValidator;
constructor(scope: Construct, id: string, props: RestApiProps) {
super(scope, id, props);
// Create reusable validator
this.validator = new RequestValidator(this, 'BodyValidator', {
restApi: this,
validateRequestBody: true,
validateRequestParameters: false,
});
}
addValidatedMethod(
resource: IResource,
httpMethod: string,
integration: Integration,
schema: JsonSchema
): Method {
// Create model from schema
const model = new Model(this, `${httpMethod}${resource.path}Model`, {
restApi: this,
contentType: 'application/json',
schema,
});
// Add method with validation
return resource.addMethod(httpMethod, integration, {
requestValidator: this.validator,
requestModels: {
'application/json': model,
},
});
}
}
// Usage
const createUserSchema: JsonSchema = {
type: JsonSchemaType.OBJECT,
required: ['email', 'name'],
properties: {
email: {
type: JsonSchemaType.STRING,
format: 'email',
},
name: {
type: JsonSchemaType.STRING,
minLength: 1,
maxLength: 100,
},
age: {
type: JsonSchemaType.INTEGER,
minimum: 0,
maximum: 150,
},
},
};
api.addValidatedMethod(
users,
'POST',
new LambdaIntegration(createUserFn),
createUserSchema
);
Response Transformations
Serverless Framework response templates:
functions:
getUsers:
handler: src/handlers/users.list
events:
- http:
path: users
method: get
response:
headers:
Content-Type: "'application/json'"
template: $input.path(')
statusCodes:
200:
pattern: ''
404:
pattern: '.*"statusCode":404.*'
template: $input.path('$.errorMessage')
CDK integration response configuration:
// lib/constructs/api-integration.ts
export function createLambdaIntegration(
fn: IFunction,
options?: {
enableCors?: boolean;
responseMapping?: Record<string, IntegrationResponse>;
}
): LambdaIntegration {
const responseParameters: Record<string, string> = {};
if (options?.enableCors) {
responseParameters['method.response.header.Access-Control-Allow-Origin'] = "'*'";
}
return new LambdaIntegration(fn, {
// Non-proxy integration: selectionPattern matches the Lambda error,
// so these handlers throw instead of returning a status code
proxy: false,
integrationResponses: [
{
statusCode: '200',
responseParameters,
responseTemplates: {
'application/json': '$input.path("$")',
},
},
{
statusCode: '404',
selectionPattern: '.*"statusCode":404.*',
responseParameters,
responseTemplates: {
'application/json': '$input.path("$.errorMessage")',
},
},
{
statusCode: '500',
selectionPattern: '.*"statusCode":5\\d{2}.*',
responseParameters,
responseTemplates: {
'application/json': '{"error": "Internal Server Error"}',
},
},
],
});
}
API Gateway Authorizers
Migrating from Serverless Framework authorizers:
functions:
auth:
handler: src/handlers/auth.handler
getProfile:
handler: src/handlers/users.profile
events:
- http:
path: users/profile
method: get
authorizer: auth
CDK Lambda authorizer implementation:
// lib/constructs/api-authorizer.ts
import {
TokenAuthorizer,
IdentitySource,
IRestApi
} from 'aws-cdk-lib/aws-apigateway';
import { Duration } from 'aws-cdk-lib';
export class ApiAuthorizer extends TokenAuthorizer {
constructor(scope: Construct, id: string, props: {
api: IRestApi;
authorizerFunction: IFunction;
}) {
super(scope, id, {
restApi: props.api,
handler: props.authorizerFunction,
identitySource: IdentitySource.header('Authorization'),
resultsCacheTtl: Duration.minutes(5),
authorizerName: `${props.api.restApiName}-authorizer`,
});
}
}
// Usage in stack
const authFn = new ServerlessFunction(this, 'AuthorizerFunction', {
entry: 'src/handlers/auth.ts',
handler: 'handler',
config,
});
const authorizer = new ApiAuthorizer(this, 'ApiAuthorizer', {
api: this.api,
authorizerFunction: authFn,
});
// Protected endpoint
const profile = users.addResource('profile');
profile.addMethod('GET', new LambdaIntegration(getProfileFn), {
authorizer,
authorizationType: AuthorizationType.CUSTOM,
});
Error Handling Patterns
Structured Error Responses
Create a robust error handling system:
// src/libs/api-gateway.ts
export class ApiError extends Error {
constructor(
public statusCode: number,
message: string,
public code?: string
) {
super(message);
this.name = 'ApiError';
}
}
export const formatError = (error: unknown): APIGatewayProxyResult => {
console.error('Error:', error);
if (error instanceof ApiError) {
return {
statusCode: error.statusCode,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
error: {
code: error.code || 'UNKNOWN_ERROR',
message: error.message,
},
}),
};
}
return {
statusCode: 500,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
error: {
code: 'INTERNAL_SERVER_ERROR',
message: 'An unexpected error occurred',
},
}),
};
};
// src/libs/lambda.ts
export const withErrorHandling = <T extends (...args: any[]) => any>(
handler: T
): T => {
return (async (...args: Parameters<T>) => {
try {
return await handler(...args);
} catch (error) {
return formatError(error);
}
}) as T;
};
Using Error Handling in Handlers
// src/handlers/users.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { ApiError } from '../libs/api-gateway';
import { withErrorHandling } from '../libs/lambda';
export const get = withErrorHandling(
async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
const { id } = event.pathParameters || {};
if (!id) {
throw new ApiError(400, 'User ID is required', 'MISSING_PARAMETER');
}
// Simulate database lookup
const user = await getUserById(id);
if (!user) {
throw new ApiError(404, 'User not found', 'USER_NOT_FOUND');
}
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user }),
};
}
);
API Versioning Strategies
Path-Based Versioning
// lib/stacks/versioned-api-stack.ts
export class VersionedApiStack extends Stack {
constructor(scope: Construct, id: string, props: ApiStackProps) {
super(scope, id, props);
const api = new RestApi(this, 'VersionedApi', {
restApiName: `my-service-${props.config.stage}`,
});
// Version 1
const v1 = api.root.addResource('v1');
this.setupV1Routes(v1, props.config);
// Version 2 with breaking changes
const v2 = api.root.addResource('v2');
this.setupV2Routes(v2, props.config);
}
private setupV1Routes(parent: IResource, config: EnvironmentConfig) {
const users = parent.addResource('users');
// Legacy response format
const getUserV1Fn = new ServerlessFunction(this, 'GetUserV1Function', {
entry: 'src/handlers/v1/users.ts',
handler: 'get',
config,
});
users.addResource('{id}').addMethod('GET',
new LambdaIntegration(getUserV1Fn)
);
}
private setupV2Routes(parent: IResource, config: EnvironmentConfig) {
const users = parent.addResource('users');
// New response format with pagination
const getUserV2Fn = new ServerlessFunction(this, 'GetUserV2Function', {
entry: 'src/handlers/v2/users.ts',
handler: 'get',
config,
});
users.addResource('{id}').addMethod('GET',
new LambdaIntegration(getUserV2Fn)
);
}
}
Performance Optimizations
Lambda Cold Start Optimization
// lib/constructs/warm-function.ts
import { Rule, Schedule, RuleTargetInput } from 'aws-cdk-lib/aws-events';
import { LambdaFunction } from 'aws-cdk-lib/aws-events-targets';
export class WarmFunction extends ServerlessFunction {
constructor(scope: Construct, id: string, props: ServerlessFunctionProps & {
warmingSchedule?: Schedule;
}) {
super(scope, id, props);
if (props.config.stage === 'prod' && props.warmingSchedule) {
// Create warming rule
new Rule(this, 'WarmingRule', {
schedule: props.warmingSchedule,
targets: [
new LambdaFunction(this, {
event: RuleTargetInput.fromObject({
source: 'warmer',
action: 'ping',
}),
}),
],
});
// Add warming check to handler
this.addEnvironment('ENABLE_WARMING', 'true');
}
}
}
// In handler
export const handler = async (event: any) => {
// Skip warming invocations
if (event.source === 'warmer') {
return { statusCode: 200, body: 'Warmed' };
}
// Regular handler logic
};
API Gateway Caching
// lib/constructs/cached-method.ts
export function addCachedMethod(
resource: IResource,
httpMethod: string,
integration: Integration
): Method {
return resource.addMethod(httpMethod, integration, {
methodResponses: [{
statusCode: '200',
responseParameters: {
'method.response.header.Cache-Control': true,
},
}],
// Query strings the stage cache can key on
requestParameters: {
'method.request.querystring.page': false,
'method.request.querystring.limit': false,
},
});
}
// Caching itself is switched on at stage level
const api = new RestApi(this, 'CachedApi', {
deployOptions: {
cachingEnabled: true,
cacheClusterEnabled: true,
cacheClusterSize: '0.5',
cacheTtl: Duration.minutes(5),
cacheDataEncrypted: true,
},
});
Migration Checklist
Before moving to production, ensure you’ve addressed:
- All Lambda functions migrated with proper memory/timeout settings
- Environment variables properly scoped and encrypted
- API Gateway routes match existing paths exactly
- CORS configuration matches current settings
- Request validation schemas migrated
- Custom authorizers implemented and tested
- Error responses maintain backward compatibility
- Lambda layers properly configured
- Cold start optimizations in place
- API caching strategy implemented
- Monitoring and alarms configured
When the Shared Construct Holds
The shared construct earns its place once several functions need the same runtime, tracing, and bundling defaults. A change to log retention or architecture then happens in one file instead of every stack. Below a handful of functions, plain NodejsFunction calls stay easier to follow and the construct only adds indirection.
Override the props rather than extend the construct when a function is genuinely different. A video encoder that needs 10 GB of memory and a 15-minute timeout should pass those values explicitly instead of gaining a branch in getOptimalMemory. Once that helper grows a third special case, the naming convention has stopped carrying the decision. Bundling follows the same rule: externalModules and nodeModules are per-function facts, so keep them at the call site when they diverge from the default.
Next Steps: Database and Environment Management
With Lambda functions and API Gateway configurations migrated, database resources and environment management come next. Unlike stateless functions, databases hold persistent data that cannot be recreated on a whim, so the migration steps are different.
Part 4 covers:
- Migrating DynamoDB tables and RDS instances
- Environment variable management and secrets handling
- VPC configurations for database access
- Backup and disaster recovery strategies
- Cross-environment consistency patterns
References
- AWS CDK API Reference - aws-lambda-nodejs - NodejsFunction construct reference covering bundling, external modules, and tree shaking
- Amazon API Gateway Developer Guide - REST API configuration, request validation, CORS, and method response patterns
- Building Lambda functions with TypeScript - AWS Lambda - TypeScript handler patterns, bundling options, and Lambda runtime for TypeScript functions
- Configuring provisioned concurrency for a function - AWS Lambda - Eliminating cold starts with provisioned concurrency, an alternative to warming schedules
- Best practices for working with AWS Lambda functions - Memory sizing, ARM64 architecture, and bundle optimization recommendations from AWS
- AWS CDK v2 Developer Guide - Construct composition, environment configuration, and CDK deployment patterns
Migrating from Serverless Framework to AWS CDK
A comprehensive 6-part guide covering the complete migration process from Serverless Framework to AWS CDK, including setup, implementation patterns, and best practices.
All Posts in This Series
Related posts
Before building an internal service layer, decide whether you need one: what it costs per call, the volume where VPC Lattice wins, and when direct invoke still beats it.
A private REST API structurally cannot carry gRPC, and every AWS surface that speaks gRPC excludes Lambda targets. What to keep from gRPC, and what to drop.
The private REST API, the resource policy that switches it on, per-route AWS_IAM grants, the two CDK stacks, and signing the call from a Node 22 Lambda.
SigV4 proves which service is calling and nothing about which user it is for. How to propagate a verified subject, and what the transport actually encrypts.
Same-account, the resource policy and the caller's identity policy are an OR. Cross-account they become an AND, and silence denies. What that changes in the perimeter.