Skip to content
Ayhan Sipahi Ayhan Sipahi

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:

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

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/6 posts completed

Related posts