Skip to content

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:

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.

Progress 3 of 6 posts

Related posts