Skip to content

Migrating from Serverless Framework to AWS CDK: Part 2 - Setting Up Your CDK Environment

Learn how to structure a CDK project for serverless applications, configure TypeScript for Lambda development, and establish patterns that ease migration from Serverless Framework.

When teams decide to migrate from Serverless Framework to CDK, the immediate question becomes: where to start? Working with Lambda functions across multiple environments requires careful planning, especially when coordinating between multiple developers.

Scaling CDK from personal projects to production systems presents unique challenges. Teams need structure, conventions, and patterns that work for everyone involved.

This guide covers designing a CDK project structure that allows multiple engineers to work in parallel without conflicts, maintains familiar patterns from Serverless Framework, and serves as a foundation for production platforms.

Series Navigation:

The Project Structure That Actually Scales

Initial attempts often struggle with simple tutorial structures. Common issues include merge conflicts, unclear ownership, and confusion about file organization.

Here's the evolution from chaos to order:

bash
# Serverless Framework Structuremy-service/├── serverless.yml├── package.json├── src/│  └── handlers/│  ├── users.js│  └── products.js├── resources/│  └── dynamodb-tables.yml└── config/    ├── dev.yml    └── prod.yml
# CDK Structure (After 3 failed attempts)my-service/├── cdk.json  # CDK app configuration├── package.json├── bin/│  └── my-service.ts  # Single entry point (important for simplicity)├── lib/│  ├── stacks/  # Stack definitions by domain│  │  ├── api-stack.ts  # API Gateway + Lambda functions│  │  ├── data-stack.ts  # DynamoDB tables (stateful)│  │  └── auth-stack.ts  # Cognito + auth logic│  ├── constructs/  # Reusable patterns for consistency│  │  ├── production-lambda.ts  # Standard Lambda configuration│  │  ├── api-with-auth.ts  # Common API patterns│  │  └── monitored-table.ts  # DynamoDB with alarms│  └── config/  # Environment-specific configs│  ├── development.ts│  ├── staging.ts│  └── production.ts├── src/│  └── handlers/  # Lambda code (familiar location)│  ├── users/  # Grouped by domain│  │  ├── create.ts│  │  ├── update.ts│  │  └── list.ts│  └── products/│  ├── catalog.ts│  └── inventory.ts└── test/    ├── unit/  # Handler unit tests    ├── integration/  # API integration tests    └── infrastructure/  # CDK stack tests

Key insight: Domain-driven organization prevents merge conflicts when multiple engineers work in parallel.

Initializing Your CDK Project

First, ensure you have the prerequisites:

bash
# Install AWS CDK CLI globallynpm install -g aws-cdk@2
# Verify installationcdk --version  # Should show 2.x.x
# Configure AWS credentials (if not already done)aws configure

Now create your project:

bash
# Create project directorymkdir my-serverless-api && cd my-serverless-api
# Initialize CDK with TypeScriptcdk init app --language typescript
# Install Lambda-specific dependenciesnpm install @types/aws-lambda
# Install development toolsnpm install --save-dev esbuild @types/node ts-node

Configuring TypeScript for Lambda Development

CDK generates a basic tsconfig.json. Let's optimize it for serverless development:

json
{  "compilerOptions": {    "target": "ES2022",    "module": "commonjs",    "lib": ["ES2022"],    "declaration": true,    "strict": true,    "noImplicitAny": true,    "strictNullChecks": true,    "noImplicitThis": true,    "alwaysStrict": true,    "esModuleInterop": true,    "noUnusedLocals": true,    "noUnusedParameters": true,    "noImplicitReturns": true,    "noFallthroughCasesInSwitch": true,    "inlineSourceMap": true,    "inlineSources": true,    "experimentalDecorators": true,    "strictPropertyInitialization": false,    "skipLibCheck": true,    "resolveJsonModule": true,    "outDir": "./dist",    "rootDir": "./",    "baseUrl": "./",    "paths": {      "@handlers/*": ["src/handlers/*"],      "@libs/*": ["src/libs/*"],      "@constructs/*": ["lib/constructs/*"]    }  },  "include": [    "bin/**/*",    "lib/**/*",    "src/**/*",    "test/**/*"  ],  "exclude": [    "cdk.out",    "node_modules"  ]}

Environment Configuration Management

Serverless Framework uses YAML files for environment-specific configuration. Let's create a TypeScript-based equivalent:

typescript
// lib/config/environment.tsexport interface EnvironmentConfig {  stage: string;  region: string;  account: string;  api: {    throttling: {      rateLimit: number;      burstLimit: number;    };    cors: {      origins: string[];      credentials: boolean;    };  };  lambda: {    memorySize: number;    timeout: number;    reservedConcurrentExecutions?: number;  };  monitoring: {    alarmEmail?: string;    enableXRay: boolean;    logRetentionDays: number;  };}
// lib/config/stages/dev.tsexport const devConfig: EnvironmentConfig = {  stage: 'dev',  region: 'us-east-1',  account: '123456789012',  api: {    throttling: {      rateLimit: 100,      burstLimit: 200,    },    cors: {      origins: ['http://localhost:3000'],      credentials: true,    },  },  lambda: {    memorySize: 512,    timeout: 30,  },  monitoring: {    enableXRay: true,    logRetentionDays: 7,  },};
// lib/config/stages/prod.tsexport const prodConfig: EnvironmentConfig = {  stage: 'prod',  region: 'us-east-1',  account: '123456789012',  api: {    throttling: {      rateLimit: 1000,      burstLimit: 2000,    },    cors: {      origins: ['https://myapp.com'],      credentials: true,    },  },  lambda: {    memorySize: 1024,    timeout: 30,    reservedConcurrentExecutions: 100,  },  monitoring: {    alarmEmail: '[email protected]',    enableXRay: true,    logRetentionDays: 30,  },};
// lib/config/index.tsimport { devConfig } from './stages/dev';import { prodConfig } from './stages/prod';
export function getConfig(stage: string): EnvironmentConfig {  switch (stage) {    case 'dev':      return devConfig;    case 'prod':      return prodConfig;    default:      throw new Error(`Unknown stage: ${stage}`);  }}

Creating Your First Construct

Constructs are CDK's building blocks. Let's create a reusable pattern for Lambda functions:

typescript
// lib/constructs/serverless-function.tsimport { Construct } from 'constructs';import { NodejsFunction, NodejsFunctionProps } from 'aws-cdk-lib/aws-lambda-nodejs';import { Runtime, Tracing } from 'aws-cdk-lib/aws-lambda';import { Duration } from 'aws-cdk-lib';import { EnvironmentConfig } from '../config/environment';
export interface ServerlessFunctionProps {  entry: string;  handler?: string;  environment?: Record<string, string>;  config: EnvironmentConfig;  memorySize?: number;  timeout?: number;}
export class ServerlessFunction extends NodejsFunction {  constructor(scope: Construct, id: string, props: ServerlessFunctionProps) {    const { config, ...functionProps } = props;
    super(scope, id, {      runtime: Runtime.NODEJS_20_X, // Consider NODEJS_22_X for latest features      handler: props.handler || 'handler',      entry: props.entry,      memorySize: props.memorySize || config.lambda.memorySize,      timeout: Duration.seconds(props.timeout || config.lambda.timeout),      tracing: config.monitoring.enableXRay ? Tracing.ACTIVE : Tracing.DISABLED,      environment: {        NODE_OPTIONS: '--enable-source-maps',        STAGE: config.stage,        ...props.environment,      },      bundling: {        minify: config.stage === 'prod',        sourceMap: true,        sourcesContent: false,        target: 'es2022',        keepNames: true,        // Exclude AWS SDK v3 (provided in Lambda runtime)        externalModules: [          '@aws-sdk/*',        ],      },      reservedConcurrentExecutions: config.lambda.reservedConcurrentExecutions,    });  }}

Setting Up Your First Stack

Now let's create a stack that uses our construct:

typescript
// lib/stacks/api-stack.tsimport { Stack, StackProps } from 'aws-cdk-lib';import { Construct } from 'constructs';import { RestApi, LambdaIntegration, Cors } from 'aws-cdk-lib/aws-apigateway';import { ServerlessFunction } from '../constructs/serverless-function';import { EnvironmentConfig } from '../config/environment';
export interface ApiStackProps extends StackProps {  config: EnvironmentConfig;}
export class ApiStack extends Stack {  public readonly api: RestApi;
  constructor(scope: Construct, id: string, props: ApiStackProps) {    super(scope, id, props);
    const { config } = props;
    // Create API Gateway    this.api = new RestApi(this, 'ServerlessApi', {      restApiName: `my-service-${config.stage}`,      deployOptions: {        stageName: config.stage,        throttlingRateLimit: config.api.throttling.rateLimit,        throttlingBurstLimit: config.api.throttling.burstLimit,      },      defaultCorsPreflightOptions: {        allowOrigins: config.api.cors.origins,        allowCredentials: config.api.cors.credentials,        allowMethods: Cors.ALL_METHODS,        allowHeaders: [          'Content-Type',          'Authorization',          'X-Api-Key',        ],      },    });
    // Create Lambda functions    const createUserFn = new ServerlessFunction(this, 'CreateUserFunction', {      entry: 'src/handlers/users.ts',      handler: 'create',      config,      environment: {        // Environment variables will be added in Part 4      },    });
    // Set up routes    const users = this.api.root.addResource('users');    users.addMethod('POST', new LambdaIntegration(createUserFn));  }}

CDK App Entry Point

Update the CDK app entry point to use our configuration system:

typescript
// bin/my-service.ts#!/usr/bin/env nodeimport 'source-map-support/register';import { App } from 'aws-cdk-lib';import { ApiStack } from '../lib/stacks/api-stack';import { getConfig } from '../lib/config';
const app = new App();
// Get stage from context or environmentconst stage = app.node.tryGetContext('stage') || process.env.STAGE || 'dev';const config = getConfig(stage);
new ApiStack(app, `MyServiceApiStack-${stage}`, {  config,  env: {    account: config.account,    region: config.region,  },  tags: {    Stage: stage,    Service: 'my-service',    ManagedBy: 'cdk',  },});

Your First Lambda Handler

Create a Lambda handler using TypeScript:

typescript
// src/handlers/users.tsimport { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';
export const create = async (  event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> => {  console.log('Event:', JSON.stringify(event, null, 2));
  try {    const body = JSON.parse(event.body || '{}');
    // Handler logic here (to be expanded in Part 3)
    return {      statusCode: 201,      headers: {        'Content-Type': 'application/json',      },      body: JSON.stringify({        message: 'User created successfully',        stage: process.env.STAGE,      }),    };  } catch (error) {    console.error('Error:', error);
    return {      statusCode: 500,      headers: {        'Content-Type': 'application/json',      },      body: JSON.stringify({        error: 'Internal server error',      }),    };  }};

Deployment Commands

Add these scripts to your package.json:

json
{  "scripts": {    "build": "tsc",    "watch": "tsc -w",    "cdk": "cdk",    "bootstrap": "cdk bootstrap",    "deploy:dev": "cdk deploy --context stage=dev",    "deploy:prod": "cdk deploy --context stage=prod",    "diff:dev": "cdk diff --context stage=dev",    "diff:prod": "cdk diff --context stage=prod",    "synth": "cdk synth",    "test": "jest",    "test:watch": "jest --watch"  }}

First Deployment

Bootstrap your AWS environment (one-time setup that prepares your AWS account for CDK deployments by creating necessary S3 buckets and IAM roles):

bash
npm run bootstrap

Deploy to development:

bash
npm run deploy:dev

CDK will show you what resources it plans to create. Review and confirm.

Local Development Setup

Unlike Serverless Framework's serverless-offline, CDK doesn't provide built-in local API Gateway emulation. For local development, you have several options:

  1. SAM CLI Integration (Recommended):
bash
# Install SAM CLIbrew install aws-sam-cli  # macOS# or follow: https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html
# Generate CloudFormation templatecdk synth --no-staging > template.yaml
# Start local APIsam local start-api -t template.yaml
  1. Direct Handler Testing:
typescript
// test/handlers/users.test.tsimport { create } from '../../src/handlers/users';import { APIGatewayProxyEventV2 } from 'aws-lambda';
describe('Users Handler', () => {  it('should create a user', async () => {    const event: Partial<APIGatewayProxyEventV2> = {      body: JSON.stringify({ name: 'John Doe' }),    };
    const result = await create(event as APIGatewayProxyEventV2);
    expect(result.statusCode).toBe(201);    expect(JSON.parse(result.body!)).toHaveProperty('message');  });});

Key Differences to Remember

AspectServerless FrameworkCDK
ConfigurationYAML filesTypeScript code
Environment Variables${self:provider.stage}Config objects
Local Developmentserverless-offlineSAM CLI or testing
Deploymentserverless deploycdk deploy
Resource References!Ref or ${cf:stackName.output}Direct object references

What's Next

You now have a solid CDK foundation that mirrors Serverless Framework conventions while embracing CDK's type safety and composability. Your Lambda functions live in familiar locations, but your infrastructure is now code - real, testable TypeScript code.

Related reading: For a comprehensive comparison of different CDK organization patterns (service-based vs domain-based vs feature-based), see AWS CDK Code Organization: Service-Based vs Domain-Based Architecture Patterns.

In Part 3, we'll migrate Lambda functions and API Gateway configurations, including:

  • Request/response transformations
  • API Gateway models and validators
  • Lambda layers and dependencies
  • Error handling patterns
  • API versioning strategies

The foundation is set. Let's build your serverless API.

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.

Progress2/6 posts completed

Related Posts