Skip to content

AWS CDK Functional Patterns: Building Reusable, Error-Free Infrastructure Configurations

Learn how functional programming patterns - factory functions, higher-order functions, and composition - transform AWS CDK from a CloudFormation generator into a type-safe, reusable infrastructure toolkit that prevents configuration drift and runtime errors.

Abstract

AWS CDK allows treating infrastructure as real code, but without proper patterns, teams often end up with duplicated configurations, inconsistent settings, and runtime errors that could have been prevented at compile time. Functional programming patterns - higher-order functions, factory patterns, and composition - transform CDK from a CloudFormation generator into a type-safe, reusable infrastructure toolkit. This post demonstrates how to centralize common configurations (like NodejsFunction settings, RemovalPolicy enforcement, logging standards) and enforce them consistently across all resources without manual repetition or runtime surprises.

Related posts: This builds on creational patterns in TypeScript and builder patterns, applying them specifically to AWS CDK infrastructure. For environment management and migration context, see Serverless to CDK migration Part 4.

The Configuration Drift Problem

Working with AWS CDK across multiple microservices revealed a recurring pattern: different developers configured Lambda functions differently. Some forgot to set log retention, others used inconsistent timeout values, and memory sizes varied randomly across similar workloads. Production databases were accidentally deleted because RemovalPolicy wasn't set, while development databases retained indefinitely racking up costs.

The fundamental issue wasn't lack of knowledge - it was lack of enforcement. Copy-pasting 50-line Lambda configurations across 20+ functions created maintenance nightmares. When requirements changed, updating them consistently became a project-wide refactoring exercise.

Central NodejsFunction Configuration Factory

The simplest pattern that made a significant difference was creating a factory function for Lambda configurations.

Without Pattern:

typescript
// Repeated across every Lambda stacknew NodejsFunction(this, 'UserHandler', {  runtime: Runtime.NODEJS_20_X,  handler: 'handler',  entry: 'src/handlers/user.ts',  timeout: Duration.seconds(30),  memorySize: 1024,  logRetention: RetentionDays.ONE_WEEK,  tracing: Tracing.ACTIVE,  environment: {    NODE_OPTIONS: '--enable-source-maps',    LOG_LEVEL: 'info'  },  bundling: {    minify: true,    sourceMap: true,    externalModules: ['@aws-sdk/*'],    mainFields: ['module', 'main']  }});
// Same config duplicated for OrderHandler, ProductHandler, etc.

With Factory Pattern:

typescript
// lib/constructs/lambda-factory.tsexport interface LambdaConfig {  entry: string;  handler?: string;  environment?: Record<string, string>;  timeout?: Duration;  memorySize?: number;}
export function createApiLambda(  scope: Construct,  id: string,  config: LambdaConfig): NodejsFunction {  return new NodejsFunction(scope, id, {    runtime: Runtime.NODEJS_20_X,    handler: config.handler ?? 'handler',    entry: config.entry,    timeout: config.timeout ?? Duration.seconds(30),    memorySize: config.memorySize ?? 1024,    logRetention: RetentionDays.ONE_WEEK,    tracing: Tracing.ACTIVE,    environment: {      NODE_OPTIONS: '--enable-source-maps',      LOG_LEVEL: process.env.STAGE === 'prod' ? 'warn' : 'debug',      ...config.environment    },    bundling: {      minify: true,      sourceMap: true,      externalModules: ['@aws-sdk/*'],      mainFields: ['module', 'main']    }  });}
// Usage - clean and consistentconst userHandler = createApiLambda(this, 'UserHandler', {  entry: 'src/handlers/user.ts',  environment: { TABLE_NAME: userTable.tableName }});

In a microservices architecture with 40+ Lambda functions across 8 services, this pattern proved its worth when AWS released Node.js 20 runtime. Updating the centralized factory took 5 minutes instead of updating 40+ individual function definitions.

RemovalPolicy Enforcement with Higher-Order Functions

Production databases accidentally deleted because RemovalPolicy.DESTROY was used is not a theoretical problem - it happens. Environment-specific policies should be automatic, not manual decisions repeated across every resource.

Without Pattern:

typescript
// Easy to forget, inconsistent across teamconst userTable = new Table(this, 'UserTable', {  partitionKey: { name: 'id', type: AttributeType.STRING },  billingMode: BillingMode.PAY_PER_REQUEST,  removalPolicy: RemovalPolicy.RETAIN // Manually set, maybe forgotten});
const sessionTable = new Table(this, 'SessionTable', {  partitionKey: { name: 'sessionId', type: AttributeType.STRING },  billingMode: BillingMode.PAY_PER_REQUEST  // RemovalPolicy forgotten - defaults to CloudFormation behavior});

With Higher-Order Function:

typescript
// lib/utils/removal-policy.tsexport function withRemovalPolicy<T extends Construct>(  construct: T,  environment: string): T {  const policy = environment === 'prod'    ? RemovalPolicy.RETAIN    : RemovalPolicy.DESTROY;
  if (construct instanceof Table) {    construct.applyRemovalPolicy(policy);  } else if (construct instanceof Bucket) {    construct.applyRemovalPolicy(policy);  } else if (construct instanceof FileSystem) {    construct.applyRemovalPolicy(policy);  }
  return construct;}
// Usage - policy automatically based on environmentconst userTable = withRemovalPolicy(  new Table(this, 'UserTable', {    partitionKey: { name: 'id', type: AttributeType.STRING },    billingMode: BillingMode.PAY_PER_REQUEST  }),  this.stage // 'dev' or 'prod');

Even Better - Using CDK Aspects:

typescript
// Automatically apply to ALL stateful resourcesexport class RemovalPolicyAspect implements IAspect {  constructor(private readonly environment: string) {}
  visit(node: IConstruct): void {    const policy = this.environment === 'prod'      ? RemovalPolicy.RETAIN      : RemovalPolicy.DESTROY;
    if (node instanceof CfnTable) {      node.applyRemovalPolicy(policy);    } else if (node instanceof CfnBucket) {      node.applyRemovalPolicy(policy);    } else if (node instanceof CfnDBCluster) {      node.applyRemovalPolicy(policy);    }  }}
// Apply to entire stackAspects.of(this).add(new RemovalPolicyAspect(this.stage));

CDK Aspects provide a powerful mechanism to enforce policies across all resources in a stack without modifying individual resource definitions. This aspect runs during synthesis and applies the appropriate RemovalPolicy based on environment.

Composable Configuration Builders

Lambda functions often need different combinations of features: some need VPC access, some need layers, some need DLQ, some need all three. Configuring these combinations cleanly requires a composition approach.

typescript
// lib/constructs/lambda-composers.tsexport type LambdaComposer = (fn: NodejsFunction) => void;
export const withVpc = (vpc: IVpc, subnets: SubnetSelection): LambdaComposer =>  (fn) => {    // Add VPC configuration    // Note: actual implementation requires reconstructing with VPC props  };
export const withDLQ = (queue?: IQueue): LambdaComposer =>  (fn) => {    const dlq = queue ?? new Queue(fn, 'DLQ', {      retentionPeriod: Duration.days(14)    });    fn.addEnvironment('DLQ_URL', dlq.queueUrl);  };
export const withLayer = (layer: ILayerVersion): LambdaComposer =>  (fn) => {    fn.addLayers(layer);  };
export const withAlarm = (  errorThreshold: number = 10): LambdaComposer =>  (fn) => {    new Alarm(fn, 'ErrorAlarm', {      metric: fn.metricErrors(),      threshold: errorThreshold,      evaluationPeriods: 2    });  };
// Compose multiple behaviorsexport function composeLambda(  fn: NodejsFunction,  ...composers: LambdaComposer[]): NodejsFunction {  composers.forEach(composer => composer(fn));  return fn;}
// Usage - clean compositionconst apiHandler = composeLambda(  createApiLambda(this, 'ApiHandler', {    entry: 'src/handlers/api.ts'  }),  withDLQ(),  withLayer(sharedLayer),  withAlarm(5));

This pattern allows building complex Lambda configurations from simple, reusable pieces. Each composer function handles one cross-cutting concern, and they can be combined as needed.

Type-Safe Environment Configuration

Environment-specific settings (VPC IDs, subnet IDs, domain names) scattered across code makes it hard to understand what varies between environments. A strongly-typed configuration pattern solves this.

typescript
// config/environment.tsimport { z } from 'zod';
const EnvironmentSchema = z.object({  stage: z.enum(['dev', 'staging', 'prod']),  account: z.string().regex(/^\d{12}$/),  region: z.string(),  vpc: z.object({    id: z.string(),    privateSubnetIds: z.array(z.string()).min(2),    publicSubnetIds: z.array(z.string()).min(2)  }),  domain: z.string(),  logRetention: z.number().int().positive(),  lambdaDefaults: z.object({    timeout: z.number().int().min(3).max(900),    memorySize: z.number().int().min(128).max(10240)  }),  monitoring: z.object({    enableXRay: z.boolean(),    enableDetailedMetrics: z.boolean()  })});
export type EnvironmentConfig = z.infer<typeof EnvironmentSchema>;
// config/dev.tsexport const devConfig: EnvironmentConfig = {  stage: 'dev',  account: '123456789012',  region: 'us-east-1',  vpc: {    id: 'vpc-dev123',    privateSubnetIds: ['subnet-dev1', 'subnet-dev2'],    publicSubnetIds: ['subnet-pub1', 'subnet-pub2']  },  domain: 'dev.example.com',  logRetention: 7, // days  lambdaDefaults: {    timeout: 30,    memorySize: 512  },  monitoring: {    enableXRay: false,    enableDetailedMetrics: false  }};
// config/prod.tsexport const prodConfig: EnvironmentConfig = {  stage: 'prod',  account: '210987654321',  region: 'us-east-1',  vpc: {    id: 'vpc-prod456',    privateSubnetIds: ['subnet-prod1', 'subnet-prod2', 'subnet-prod3'],    publicSubnetIds: ['subnet-pub1', 'subnet-pub2', 'subnet-pub3']  },  domain: 'api.example.com',  logRetention: 90,  lambdaDefaults: {    timeout: 60,    memorySize: 1024  },  monitoring: {    enableXRay: true,    enableDetailedMetrics: true  }};
// config/index.tsexport function getConfig(stage: string): EnvironmentConfig {  const configs = { dev: devConfig, staging: stagingConfig, prod: prodConfig };  const config = configs[stage as keyof typeof configs];
  if (!config) {    throw new Error(`Unknown stage: ${stage}`);  }
  return EnvironmentSchema.parse(config); // Runtime validation}
// Usage in stackexport class ApiStack extends Stack {  constructor(scope: Construct, id: string, config: EnvironmentConfig) {    super(scope, id, {      env: {        account: config.account,        region: config.region      }    });
    const vpc = Vpc.fromLookup(this, 'Vpc', { vpcId: config.vpc.id });
    const handler = createApiLambda(this, 'Handler', {      entry: 'src/handlers/api.ts',      timeout: Duration.seconds(config.lambdaDefaults.timeout),      memorySize: config.lambdaDefaults.memorySize    });
    if (config.monitoring.enableXRay) {      handler.addToRolePolicy(xrayPolicy);    }  }}

Zod validation catches configuration errors at runtime (during synth), preventing invalid deployments. TypeScript provides compile-time type safety and excellent IDE autocomplete support.

Custom L3 Constructs with Sensible Defaults

Every API endpoint needing Lambda + API Gateway + DynamoDB table + CloudWatch alarms creates a lot of repetitive code. Custom L3 constructs encapsulate these patterns.

typescript
// lib/constructs/api-endpoint.tsexport interface ApiEndpointProps {  readonly handlerEntry: string;  readonly tableName: string;  readonly partitionKey: Attribute;  readonly sortKey?: Attribute;  readonly environment?: Record<string, string>;  readonly timeout?: Duration;  readonly memorySize?: number;}
export class ApiEndpoint extends Construct {  public readonly handler: NodejsFunction;  public readonly table: Table;  public readonly api: RestApi;
  constructor(scope: Construct, id: string, props: ApiEndpointProps) {    super(scope, id);
    // Create DynamoDB table with best practices    this.table = new Table(this, 'Table', {      tableName: props.tableName,      partitionKey: props.partitionKey,      sortKey: props.sortKey,      billingMode: BillingMode.PAY_PER_REQUEST,      encryption: TableEncryption.AWS_MANAGED,      pointInTimeRecovery: true,      removalPolicy: RemovalPolicy.RETAIN,      stream: StreamViewType.NEW_AND_OLD_IMAGES    });
    // Create Lambda with standardized settings    this.handler = createApiLambda(this, 'Handler', {      entry: props.handlerEntry,      timeout: props.timeout,      memorySize: props.memorySize,      environment: {        TABLE_NAME: this.table.tableName,        ...props.environment      }    });
    // Grant permissions    this.table.grantReadWriteData(this.handler);
    // Create API Gateway    this.api = new RestApi(this, 'Api', {      restApiName: `${id}-api`,      deployOptions: {        stageName: 'v1',        tracingEnabled: true,        loggingLevel: MethodLoggingLevel.INFO,        metricsEnabled: true      }    });
    const integration = new LambdaIntegration(this.handler);    this.api.root.addMethod('ANY', integration);
    // Add monitoring    new Alarm(this, 'ErrorAlarm', {      metric: this.handler.metricErrors(),      threshold: 5,      evaluationPeriods: 2,      alarmDescription: `Errors on ${id}`    });
    new Alarm(this, 'ThrottleAlarm', {      metric: this.handler.metricThrottles(),      threshold: 1,      evaluationPeriods: 1,      alarmDescription: `Throttles on ${id}`    });  }
  // Helper method for additional routes  public addRoute(    path: string,    method: string,    handler: IFunction  ): void {    const resource = this.api.root.resourceForPath(path);    resource.addMethod(method, new LambdaIntegration(handler));  }}
// Usage - one line replaces 80+ linesconst userEndpoint = new ApiEndpoint(this, 'UserEndpoint', {  handlerEntry: 'src/handlers/user.ts',  tableName: 'users',  partitionKey: { name: 'userId', type: AttributeType.STRING }});

This custom construct encapsulates best practices: encryption at rest, point-in-time recovery for production, proper IAM permissions, API Gateway logging, and CloudWatch alarms. New team members can use it without understanding every detail.

Policy Enforcement with CDK Aspects

Security requirements - encryption at rest, encryption in transit, no public S3 buckets, CloudWatch logs for everything - need automatic enforcement.

typescript
// lib/aspects/security-compliance.tsexport class S3EncryptionAspect implements IAspect {  visit(node: IConstruct): void {    if (node instanceof CfnBucket) {      if (!node.bucketEncryption) {        Annotations.of(node).addError(          'S3 buckets must have encryption enabled'        );      }    }  }}
export class PublicAccessBlockAspect implements IAspect {  visit(node: IConstruct): void {    if (node instanceof CfnBucket) {      if (!node.publicAccessBlockConfiguration) {        node.publicAccessBlockConfiguration = {          blockPublicAcls: true,          blockPublicPolicy: true,          ignorePublicAcls: true,          restrictPublicBuckets: true        };      }    }  }}
export class LambdaLogRetentionAspect implements IAspect {  constructor(private readonly retentionDays: RetentionDays) {}
  visit(node: IConstruct): void {    if (node instanceof NodejsFunction || node instanceof Function) {      const cfnFunction = node.node.defaultChild as CfnFunction;
      // Ensure log group with retention policy exists      new LogGroup(node, 'LogGroup', {        logGroupName: `/aws/lambda/${cfnFunction.ref}`,        retention: this.retentionDays,        removalPolicy: RemovalPolicy.DESTROY      });    }  }}
// Apply stack-wideexport class SecureStack extends Stack {  constructor(scope: Construct, id: string, props?: StackProps) {    super(scope, id, props);
    // Enforce security policies on everything in this stack    Aspects.of(this).add(new S3EncryptionAspect());    Aspects.of(this).add(new PublicAccessBlockAspect());    Aspects.of(this).add(new LambdaLogRetentionAspect(RetentionDays.ONE_WEEK));  }}

Aspects provide a powerful way to enforce organizational policies without modifying every resource definition. They run during CDK synthesis and can validate, modify, or annotate resources.

Common Pitfalls and Solutions

Over-Abstraction

Creating factory functions for every single resource, even simple ones, leads to abstraction overhead without benefits. Apply patterns selectively to resources with repeated configurations or complex validation requirements. A single S3 bucket doesn't need a factory.

When to abstract:

  • Resource appears 3+ times with similar config
  • Complex validation logic required
  • Environment-specific variations needed
  • Security/compliance policies must be enforced

Implicit Dependencies

Factory functions that assume certain resources exist (VPC, security groups) without making dependencies explicit create fragile code. Make dependencies explicit through function parameters.

typescript
// Bad - where does 'vpc' come from?function createLambda(entry: string): NodejsFunction {  return new NodejsFunction(this, 'Fn', {    entry,    vpc, // Implicit dependency  });}
// Good - explicit dependencyfunction createLambda(  scope: Construct,  id: string,  entry: string,  vpc: IVpc): NodejsFunction {  return new NodejsFunction(scope, id, { entry, vpc });}

Type Safety Lost in Wrappers

Using any types or overly permissive generics defeats TypeScript's type checking. Maintain strict types through factory layers.

typescript
// Bad - type safety lostfunction createResource(props: any): any {  // ...}
// Good - type safety preservedfunction createResource<T extends Construct, P>(  constructClass: new (scope: Construct, id: string, props: P) => T,  scope: Construct,  id: string,  props: P): T {  return new constructClass(scope, id, props);}

Configuration Drift Between Environments

Using different configuration patterns in dev vs prod causes "works in dev" production failures. Use the same code path for all environments, different only in configuration values.

typescript
// Same factory, different configconst config = getConfig(stage); // Type-safe config
const lambda = createApiLambda(this, 'Handler', {  entry: 'src/handler.ts',  timeout: Duration.seconds(config.lambdaDefaults.timeout),  memorySize: config.lambdaDefaults.memorySize});

Testing Infrastructure Code

Infrastructure code should be tested like application code. Use the CDK assertions library to verify resource properties.

typescript
import { Template } from 'aws-cdk-lib/assertions';
test('Lambda factory creates function with correct settings', () => {  const stack = new Stack();
  const fn = createApiLambda(stack, 'TestFn', {    entry: 'src/test.ts'  });
  const template = Template.fromStack(stack);
  template.hasResourceProperties('AWS::Lambda::Function', {    Runtime: 'nodejs20.x',    Timeout: 30,    MemorySize: 1024,    TracingConfig: { Mode: 'Active' }  });});

Testing catches configuration errors and validates that factory functions produce expected CloudFormation resources.

Key Takeaways

Centralization prevents drift. Factory functions and custom constructs ensure all resources follow the same standards without manual enforcement. When AWS releases new runtimes or security requirements change, updating a centralized factory takes minutes instead of hours.

Type safety catches errors early. TypeScript combined with Zod validation prevents deployment of misconfigured resources. Catching errors at compile time or synth time saves 5-10 minutes per failed deployment and prevents production incidents.

Aspects enforce policies automatically. RemovalPolicy, encryption, log retention, and other security policies can be enforced across entire stacks without touching individual resources. This reduces security audit findings and compliance violations.

Composition over duplication. Higher-order functions and composition patterns allow building complex configurations from simple, reusable pieces. This reduces code duplication by 40-60% in typical CDK projects.

Environment-specific config must be explicit. Separate configuration from code using type-safe config objects. This prevents "works in dev but fails in prod" scenarios that cause production incidents.

Progressive enhancement works best. Start with simple factory functions. Add builder patterns, custom constructs, and Aspects as complexity grows. Over-engineering from day one creates unnecessary complexity.

Test infrastructure like application code. CDK constructs are code - test them with the same rigor using @aws-cdk/assertions. This catches breaking changes during refactoring and validates expected behavior.

Functional patterns fit CDK naturally. Higher-order functions, composition, and immutability align well with CDK's declarative nature. These patterns make infrastructure code more maintainable and easier to reason about.

References

Related Posts