Skip to content
~/sph.sh

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:

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:

yaml
# serverless.ymlfunctions:  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:

typescript
// lib/constructs/production-lambda.tsimport { 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 patternsconst 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 handlingconst 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:

yaml
layers:  shared:    path: layers/shared    compatibleRuntimes:      - nodejs20.x
functions:  createUser:    handler: src/handlers/users.create    layers:      - {Ref: SharedLambdaLayer}

CDK approach with better type safety:

typescript
// lib/constructs/shared-layer.tsimport { 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 stackconst 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:

typescript
// lib/constructs/optimized-function.tsexport 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:

yaml
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:

typescript
// lib/constructs/validated-api.tsimport {  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,      },    });  }}
// Usageconst 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:

yaml
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:

typescript
// lib/constructs/api-integration.tsexport 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:

yaml
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:

typescript
// lib/constructs/api-authorizer.tsimport {  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 stackconst 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 endpointconst 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:

typescript
// src/libs/api-gateway.tsexport 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.tsexport 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

typescript
// src/handlers/users.tsimport { 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

typescript
// lib/stacks/versioned-api-stack.tsexport 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

typescript
// lib/constructs/warm-function.tsimport { 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 handlerexport const handler = async (event: any) => {  // Skip warming invocations  if (event.source === 'warmer') {    return { statusCode: 200, body: 'Warmed' };  }
  // Regular handler logic};

API Gateway Caching

typescript
// lib/constructs/cached-method.tsexport 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 levelconst 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.

Progress3/6 posts completed

Related Posts