2025-09-04
Migrating from Serverless Framework to AWS CDK: Part 3 - Lambda Functions and API Gateway
Deep dive into migrating Lambda functions, API Gateway configurations, request validations, and error handling from Serverless Framework to AWS CDK with practical examples.
Lambda functions and API Gateway configurations are where the real migration complexity lies. What seems like a straightforward YAML-to-TypeScript conversion quickly reveals itself as a multi-layered challenge involving bundling optimization, memory tuning, and error handling patterns.
Working through this migration taught me valuable lessons about standardizing function patterns, optimizing cold starts, and building maintainable API configurations. Here’s what I learned from migrating a collection of Lambda functions with different memory settings, timeout configurations, and deployment patterns.
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 I encountered:
- 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. This complexity is why creating a standardized approach becomes essential.
Building a Standardized Lambda Construct
After migrating several functions manually and encountering performance issues, I learned the value of creating a standardized construct. This approach helps ensure consistency and includes proven optimizations:
# 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
Here’s the standardized construct that incorporates lessons learned from various function migrations:
// 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, Stack } from 'aws-cdk-lib';
import { RetentionDays } from 'aws-cdk-lib/aws-logs';
export interface ProductionLambdaProps extends Omit<NodejsFunctionProps, 'runtime'> {
stage: string;
functionName: string;
// Performance optimizations from migration experience
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 optimization based on function profiling
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
treeShaking: 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 optimization based on function profiling
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 with proper error handling
const userIdResource = users.addResource('{id}');
userIdResource.addMethod('GET', new LambdaIntegration(getUserFn, {
// Integration responses for proper error handling
integrationResponses: [
{
statusCode: '200',
responseTemplates: {
'application/json': '$input.path("$")',
},
},
{
statusCode: '404',
selectionPattern: '.*"statusCode":404.*',
responseTemplates: {
'application/json': '{"error": "User not found"}',
},
},
],
}));
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, {
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): APIGatewayProxyResultV2 => {
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 { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';
import { ApiError, withErrorHandling } from '../libs/api-gateway';
export const get = withErrorHandling(
async (event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> => {
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 } 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: {
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,
cachingEnabled: boolean = true,
ttl: Duration = Duration.minutes(5)
): Method {
return resource.addMethod(httpMethod, integration, {
methodResponses: [{
statusCode: '200',
responseParameters: {
'method.response.header.Cache-Control': true,
},
}],
requestParameters: {
'method.request.querystring.page': false,
'method.request.querystring.limit': false,
},
});
}
// Enable caching 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
Key Lessons Learned
Through this migration experience, several important patterns emerged:
Standardization Pays Off
Creating a consistent function construct eliminates configuration drift and makes performance optimizations automatic. New functions inherit proven patterns instead of requiring custom configuration.
Memory and Architecture Choices Matter
ARM64 architecture and right-sized memory allocation can significantly impact both performance and cost. Different function types benefit from different memory configurations.
Bundling Strategy is Critical
Thoughtful bundling with tree shaking and external module exclusion reduces cold start times. The AWS SDK v3 is available in the Lambda runtime, so excluding it from bundles helps.
Error Handling Needs Structure
API Gateway error handling requires careful integration response configuration. Having consistent error response patterns across all functions improves debugging and client handling.
Next Steps: Database and Environment Management
With Lambda functions and API Gateway configurations migrated, the next challenge involves database resources and environment management. Unlike stateless functions, databases require careful handling since they contain persistent data that can’t be easily recreated.
In Part 4, we’ll explore:
- 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
Database migration requires different strategies than function migration, particularly around data safety and environment isolation.
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
A comprehensive technical guide to Amazon Cognito's advanced features including custom authentication flows, federation patterns, multi-tenancy architectures, migration strategies, and production-grade security implementation.
Learn to build automated preview environments using AWS CDK, Lambda, and GitHub Actions for seamless PR testing and review workflows
A comprehensive technical guide to choosing and implementing AWS edge computing solutions for global applications with practical examples and cost optimization strategies.
A comprehensive technical guide comparing AWS Secrets Manager and Systems Manager Parameter Store, demonstrating when to use each service with real-world implementation patterns.
A comprehensive guide to reducing AWS costs by 40-70% through systematic optimization using native AWS services, automation, and proven implementation patterns.