Reusable AWS CDK Constructs: Factory Functions, Builders, and Aspects
How factory functions, higher-order functions, and composition turn AWS CDK into a type-safe, reusable infrastructure toolkit that prevents configuration drift.
AWS CDK lets you treat infrastructure as real code. Without shared patterns, though, teams end up with duplicated configurations, inconsistent settings, and deployment failures that a type checker could have caught. Two moves fix most of it: put every recurring resource behind a factory function, and enforce the settings nobody can afford to forget (RemovalPolicy, encryption, log retention) with CDK Aspects. Builders, composers, and custom L3 constructs are extensions of those two, added later.
The trade-off is indirection. Once a Lambda is created by createApiLambda, a reader has to open the factory to see what that function actually gets. That cost is worth paying for resources that repeat and policies that must hold everywhere, and it is not worth paying for a one-off bucket.
Related posts: Creational patterns in TypeScript and builder patterns cover the general shapes; what follows applies them 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 several microservices surfaces a recurring pattern: different developers configure Lambda functions differently. Some forget to set log retention, others use inconsistent timeout values, and memory sizes vary across similar workloads for no stated reason. Production databases get deleted because RemovalPolicy was never set, while development databases are retained indefinitely and quietly accumulate cost.
The fundamental issue wasn’t lack of knowledge; it was lack of enforcement. A Lambda configuration block copy-pasted into every stack has to be corrected in every stack. When the requirement changes, updating it consistently turns into 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:
// Repeated across every Lambda stack
new 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:
// lib/constructs/lambda-factory.ts
export 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 consistent
const userHandler = createApiLambda(this, 'UserHandler', {
entry: 'src/handlers/user.ts',
environment: { TABLE_NAME: userTable.tableName }
});
The payoff shows up on runtime upgrades. When AWS ships a new Node.js runtime and the old one approaches deprecation, the change is one line in the factory rather than one edit per function definition, and there is no chance of missing a function that someone added last week.
RemovalPolicy Enforcement with Higher-Order Functions
Deleting a production table because RemovalPolicy.DESTROY was copied out of a dev stack is a routine failure with an expensive recovery path. Environment-specific policies belong in one place that applies them to every stateful resource automatically.
Without Pattern:
// Easy to forget, inconsistent across team
const 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:
// lib/utils/removal-policy.ts
export 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 environment
const 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:
// Automatically apply to ALL stateful resources
export 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 stack
Aspects.of(this).add(new RemovalPolicyAspect(this.stage));
An Aspect visits every node in the construct tree during synthesis, so the policy reaches resources that the individual stack files never mention. That is the difference between the wrapper and the Aspect: the wrapper only protects the tables somebody remembered to wrap.
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.
// lib/constructs/lambda-composers.ts
export 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 behaviors
export function composeLambda(
fn: NodejsFunction,
...composers: LambdaComposer[]
): NodejsFunction {
composers.forEach(composer => composer(fn));
return fn;
}
// Usage - clean composition
const 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.
// config/environment.ts
import { 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.ts
export 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.ts
export 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.ts
export 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 stack
export 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.
// lib/constructs/api-endpoint.ts
export 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 - table, handler, API, and both alarms in one construct
const 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.
// lib/aspects/security-compliance.ts
export 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-wide
export 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));
}
}
Those three classes behave differently on purpose. S3EncryptionAspect fails the synth with an error annotation, PublicAccessBlockAspect repairs the resource silently, and LambdaLogRetentionAspect adds a resource that was missing. Annotating is the safer choice for anything a team might legitimately want to override, because a silent mutation hides the decision from whoever reads the stack later.
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.
// Bad - where does 'vpc' come from?
function createLambda(entry: string): NodejsFunction {
return new NodejsFunction(this, 'Fn', {
entry,
vpc, // Implicit dependency
});
}
// Good - explicit dependency
function 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.
// Bad - type safety lost
function createResource(props: any): any {
// ...
}
// Good - type safety preserved
function 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.
// Same factory, different config
const 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.
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.
When Factories Earn Their Keep
Reach for a factory once a resource type appears in three or more places, or once a setting has to hold everywhere whether or not anyone remembers it. RemovalPolicy, encryption, and log retention belong in that second group, which is why they go in an Aspect rather than a factory parameter: an Aspect also covers the stack a colleague writes next month. Layer builders, composers, and custom L3 constructs on top only after the combinations they hide have actually appeared in the codebase.
Override the default when the stack is small enough to read end to end, when a resource is genuinely one of a kind, or when the team has not yet agreed on what the standard should be. A factory written before the pattern exists encodes a guess, and a wrong guess in shared infrastructure code is harder to unwind than the duplication it replaced. Of the patterns above, the Aspect is the one to adopt first, since enforcement is where mistakes cost money, and Template.fromStack in a unit test is what keeps the factory honest afterwards.
References
- AWS CDK Developer Guide - Official guide covering CDK concepts, constructs, stacks, and deployment workflows
- AWS CDK API Reference - Complete API documentation for all L1, L2, and L3 constructs in the AWS Construct Library
- AWS CDK Best Practices - Official guidance on project structure, construct design, and configuration management
- Working with CDK in TypeScript - TypeScript-specific patterns and tooling for CDK development
- Organize Code for Large-Scale CDK Projects - AWS Prescriptive Guidance on folder structure and code organization for CDK TypeScript projects
- Create or Extend Constructs - AWS Prescriptive Guidance - Best practices for building reusable and extensible CDK constructs
Related posts
A lifecycle test for CDK stack layout: give a resource its own long-lived stack when it outlives any single deployer, then reach it by a well-known name.
When to use service-based, domain-based, feature-based, or layer-based organization in AWS CDK projects, with decision frameworks and common pitfalls.
A CDK guide for deploying a minimal Strands agent on AgentCore Runtime: parameterized stack, arm64 build, deploy and invoke, with IAM and Marketplace prerequisites.
Overcome CloudFormation's 500 resource limit with nested stacks, cross-stack references, SSM Parameter Store, and microstack architecture, shown in TypeScript CDK.
A measured benchmark of 9 bundlers and 3 cdk synth runners for CDK TypeScript Lambdas, with a per-layer default and the rule that picks each one.