TCP #79: Master Infrastructure as Code using CDK Constructs
The comprehensive guide that could have saved me 6 months of painful mistakes
You can also read my newsletters from the Substack mobile app and be notified when a new issue is available.
For a LIMITED TIME ONLY!
Access my AWS Certified AI Practitioner Practice Tests on Udemy at the lowest price possible!
https://www.udemy.com/course/aws-certified-ai-practitioner-practice-exams-aif-c01/?couponCode=11F73908B5CAFDBC6A1B
Use coupon code: 11F73908B5CAFDBC6A1B
Offer expires July 17 at 11 am EST.
Six months.
That's how long I spent wrestling with raw CloudFormation, debugging YAML indentation errors at 2 AM, and explaining to stakeholders why our "simple" infrastructure changes took weeks instead of hours.
The breaking point came when a single character typo in a 500-line CloudFormation template brought down our production environment. As I stared at the cascade of red alerts, I realized I was fighting the wrong battle.
CDK Constructs changed everything.
What used to take days now takes minutes. What used to break now works reliably. What used to require deep AWS expertise now follows intuitive patterns.
In today’s newsletter issue, I share the hard-won insights from migrating 4 production systems and mentoring dozens of teams through their CDK journeys.
The Construct Hierarchy - Your Infrastructure Foundation
Understanding the Three Levels
L1 Constructs (CFN Resources)
These are direct mappings to CloudFormation resources. Every property must be explicitly defined. Think of them as the assembly language of CDK, robust but verbose.
When to use: Only when you need access to brand-new AWS features not yet abstracted by higher-level constructs, or when you require precise control over every parameter.
Real-world example: I once spent three days configuring an L1 CfnFunction
for Lambda, manually setting up IAM roles, environment variables, and VPC configuration. The same functionality took 10 minutes with an L2 construct.
L2 Constructs (AWS Constructs)
These provide sensible defaults while maintaining flexibility. They handle the 80% use case brilliantly and let you override specifics when needed.
Implementation insight: L2 constructs automatically create supporting resources. A Function
Construct doesn't just create the Lambda—it generates appropriate IAM roles, CloudWatch log groups, and handles resource naming conventions.
Edge case warning: Some L2 constructs make opinionated choices about security groups, subnet selection, or IAM permissions. Always review the generated CloudFormation before production deployment.
L3 Constructs (Patterns/Solutions)
Complete architectural patterns that solve common use cases. These encode years of AWS best practices into reusable components.
Pro tip: Start here for new projects. Even if you eventually need customization, L3 constructs serve as excellent reference implementations for security, monitoring, and operational patterns.
The Progression Strategy
Phase 1: Use L3 constructs from the AWS Solutions Constructs library for proven patterns
Phase 2: Compose L2 constructs for custom business logic
Phase 3: Drop to L1 only when L2 doesn't expose required properties
This progression saves months of trial-and-error learning. I've seen teams jump straight to L1 constructs and spend weeks recreating functionality that L2 constructs provide automatically.
AWS Solutions Constructs
Why Most Teams Miss This
The aws-solutions-constructs library contains over 50 pre-built patterns, yet 90% of the teams I work with have never heard of it. They're reinventing the wheel while battle-tested solutions sit unused.
High-Impact Patterns
API Gateway + Lambda + DynamoDB Instead of manually configuring API Gateway stages, Lambda permissions, DynamoDB indexes, and IAM roles:
new ApiGatewayToDynamoDb(this, 'ApiToDdb', {
apiGatewayProps: {
restApiName: 'orders-api'
}
});
This single construct creates:
API Gateway with proper CORS configuration
Lambda function with appropriate runtime and memory settings
DynamoDB table with on-demand billing
IAM roles following least-privilege principles
CloudWatch log groups with retention policies
X-Ray tracing enabled by default
CloudFront + S3 + Lambda@Edge For static sites with dynamic content:
new CloudFrontToS3(this, 'WebDistribution', {
bucketProps: {
versioned: true,
encryption: BucketEncryption.S3_MANAGED
},
cloudFrontDistributionProps: {
priceClass: PriceClass.PRICE_CLASS_100
}
});
Automatically handles:
Origin Access Identity configuration
Security headers via Lambda@Edge
Gzip compression
SSL certificate management
S3 bucket policies for CloudFront access
Implementation Checklist
Before building custom constructs:
Search constructs.dev for existing patterns
Check aws-solutions-constructs documentation
Review community constructs on GitHub
Consider the AWS CDK Patterns repository
Time-saving reality: Spending 30 minutes researching existing constructs often saves 30 hours of implementation and debugging.
Construct Composition - Building Scalable Architecture
The Monolith Trap
The biggest mistake I see teams make: creating massive constructs that do everything. These become unmaintainable, untestable, and impossible to reuse.
Anti-pattern example: A single construct that creates VPC, subnets, security groups, load balancer, auto-scaling group, RDS database, ElastiCache cluster, and monitoring dashboards. Changes to monitoring require understanding networking. Database updates risk breaking compute resources.
The Composition Strategy
Single Responsibility Principle
Each construct should have one clear purpose and well-defined boundaries.
// Good: Focused constructs
class DatabaseConstruct extends Construct {
public readonly cluster: DatabaseCluster;
public readonly secret: Secret;
}
class ApiConstruct extends Construct {
public readonly api: RestApi;
public readonly functions: { [key: string]: Function };
}
class MonitoringConstruct extends Construct {
public readonly dashboard: Dashboard;
public readonly alarms: Alarm[];
}
Dependency Injection Pass dependencies between constructs rather than creating them internally:
const database = new DatabaseConstruct(this, 'Database');
const api = new ApiConstruct(this, 'Api', {
database: database.cluster,
databaseSecret: database.secret
});
const monitoring = new MonitoringConstruct(this, 'Monitoring', {
api: api.api,
functions: Object.values(api.functions)
});
Advanced Composition Patterns
Cross-Stack References: For large applications spanning multiple stacks:
// Shared stack
export class SharedStack extends Stack {
public readonly vpc: Vpc;
public readonly database: DatabaseCluster;
}
// Application stack
export class AppStack extends Stack {
constructor(scope: Construct, id: string, sharedResources: SharedStack) {
super(scope, id);
new ApplicationConstruct(this, 'App', {
vpc: sharedResources.vpc,
database: sharedResources.database
});
}
}
Construct Libraries: Create internal libraries for organization-specific patterns:
// @company/cdk-constructs package
export class CompanyApiConstruct extends Construct {
// Encodes company standards for APIs:
// - Authentication patterns
// - Monitoring setup
// - Security configurations
// - Naming conventions
}
Props Interface Design
The Configuration Explosion Problem
As constructs evolve, they accumulate configuration options. Without thoughtful design, props interfaces become unwieldy and error-prone.
Design Principles
Required vs Optional Clarity: Use TypeScript's type system to enforce correct usage:
interface DatabaseConstructProps {
// Required - no sensible defaults
readonly databaseName: string;
readonly vpc: IVpc;
// Optional with defaults
readonly instanceType?: InstanceType;
readonly multiAz?: boolean;
readonly backupRetention?: Duration;
// Optional advanced configuration
readonly customParameterGroup?: IParameterGroup;
readonly monitoringRole?: IRole;
}
Nested Configuration Objects: Group related options to prevent prop explosion:
interface ApiConstructProps {
readonly apiConfig: {
readonly name: string;
readonly stage: string;
readonly throttling?: ThrottleSettings;
};
readonly monitoring: {
readonly enableXRay?: boolean;
readonly logLevel?: string;
readonly alarmEmail?: string;
};
readonly security: {
readonly apiKey?: boolean;
readonly cors?: CorsOptions;
readonly authorizer?: IAuthorizer;
};
}
Validation and Defaults: Implement validation logic in the construct constructors:
constructor(scope: Construct, id: string, props: DatabaseConstructProps) {
super(scope, id);
// Validation
if (props.databaseName.length < 3) {
throw new Error('Database name must be at least 3 characters');
}
// Defaults with context awareness
const instanceType = props.instanceType ??
(this.node.tryGetContext('environment') === 'prod'
? InstanceType.of(InstanceClass.T3, InstanceSize.MEDIUM)
: InstanceType.of(InstanceClass.T3, InstanceSize.MICRO));
}
Advanced Patterns
Builder Pattern for Complex Configuration: For constructs with many optional configurations:
class ApiConstructBuilder {
private props: Partial<ApiConstructProps> = {};
withThrottling(settings: ThrottleSettings): this {
this.props.throttling = settings;
return this;
}
withMonitoring(config: MonitoringConfig): this {
this.props.monitoring = config;
return this;
}
build(scope: Construct, id: string): ApiConstruct {
return new ApiConstruct(scope, id, this.props as ApiConstructProps);
}
}
Environment Management
The Hardcoding Disaster
I've seen production outages caused by hardcoded environment values in constructs. A database name change in development accidentally propagated to production, causing widespread data inconsistency.
CDK Context Strategy
Hierarchical Context: Structure context for environment-specific values:
// cdk.json
{
"context": {
"environments": {
"dev": {
"database": {
"instanceType": "t3.micro",
"multiAz": false
},
"monitoring": {
"retainLogs": false
}
},
"prod": {
"database": {
"instanceType": "r5.large",
"multiAz": true
},
"monitoring": {
"retainLogs": true
}
}
}
}
}
Context Access Patterns: Create helper functions for context access:
class EnvironmentConfig {
constructor(private scope: Construct) {}
getDatabaseConfig(): DatabaseConfig {
const env = this.scope.node.tryGetContext('environment');
const config = this.scope.node.tryGetContext(`environments.${env}.database`);
if (!config) {
throw new Error(`No database configuration found for environment: ${env}`);
}
return config;
}
}
Stack-Level Environment Injection
Environment-Specific Stacks: Create different stack classes for different environments:
interface EnvironmentStackProps extends StackProps {
readonly environment: 'dev' | 'staging' | 'prod';
readonly config: EnvironmentConfig;
}
export class ApplicationStack extends Stack {
constructor(scope: Construct, id: string, props: EnvironmentStackProps) {
super(scope, id, {
...props,
stackName: `myapp-${props.environment}`,
tags: {
Environment: props.environment,
Application: 'myapp'
}
});
const databaseConfig = props.config.database[props.environment];
// Use environment-specific configuration
}
}
Secrets and Parameter Management
AWS Systems Manager Integration: For runtime configuration that changes frequently:
const apiEndpoint = StringParameter.fromStringParameterName(
this, 'ApiEndpoint',
`/myapp/${environmentName}/api-endpoint`
);
const dbCredentials = Secret.fromSecretNameV2(
this, 'DbCredentials',
`myapp/${environmentName}/db-credentials`
);
Custom Resources
When Standard Constructs Fall Short
AWS releases services faster than CDK can create L2 constructs. Custom resources bridge this gap, allowing you to manage any AWS resource or execute arbitrary logic during deployment.
Simple API Call Pattern
Most Common Use Case: 90% of custom resource needs involve making AWS API calls not covered by CloudFormation:
new AwsCustomResource(this, 'EnableS3EventNotifications', {
onUpdate: {
service: 'S3',
action: 'putBucketNotificationConfiguration',
parameters: {
Bucket: bucket.bucketName,
NotificationConfiguration: {
CloudWatchEvents: [{
Event: 's3:ObjectCreated:*'
}]
}
},
physicalResourceId: PhysicalResourceId.of('s3-notifications-config')
},
policy: AwsCustomResourcePolicy.fromSdkCalls({
resources: AwsCustomResourcePolicy.ANY_RESOURCE
})
});
Complex Lifecycle Management
Provider Framework: For custom resources requiring complex logic:
const provider = new Provider(this, 'CustomResourceProvider', {
onEventHandler: new Function(this, 'OnEvent', {
runtime: Runtime.PYTHON_3_9,
handler: 'index.on_event',
code: Code.fromAsset('lambda/custom-resource')
}),
isCompleteHandler: new Function(this, 'IsComplete', {
runtime: Runtime.PYTHON_3_9,
handler: 'index.is_complete',
code: Code.fromAsset('lambda/custom-resource')
})
});
new CustomResource(this, 'MyCustomResource', {
serviceToken: provider.serviceToken,
properties: {
DatabaseEndpoint: database.clusterEndpoint.hostname,
InitialData: JSON.stringify(seedData)
}
});
Error Handling and Debugging
Robust Error Patterns: Custom resources can cause stack rollback failures.
Implement defensive patterns:
# Lambda handler example
import boto3
import json
def on_event(event, context):
try:
request_type = event['RequestType']
if request_type == 'Create':
return on_create(event)
elif request_type == 'Update':
return on_update(event)
elif request_type == 'Delete':
return on_delete(event)
except Exception as e:
print(f"Error: {str(e)}")
# Always return success for Delete to prevent rollback issues
if event['RequestType'] == 'Delete':
return {'PhysicalResourceId': event.get('PhysicalResourceId', 'failed-resource')}
raise
Testing Constructs
Why Most Teams Skip Testing
Testing infrastructure code feels abstract. Unlike application code, which has clear inputs and outputs, infrastructure testing requires an understanding of the generated CloudFormation and AWS resource relationships.
The cost of skipping tests becomes apparent during the first production incident caused by a "simple" configuration change.
Unit Testing Strategy
Template Assertions: Test the generated CloudFormation template:
import { Template } from 'aws-cdk-lib/assertions';
test('creates S3 bucket with encryption', () => {
const app = new App();
const stack = new Stack(app, 'TestStack');
new MyS3Construct(stack, 'TestBucket', {
bucketName: 'test-bucket'
});
const template = Template.fromStack(stack);
template.hasResourceProperties('AWS::S3::Bucket', {
BucketEncryption: {
ServerSideEncryptionConfiguration: [
{
ServerSideEncryptionByDefault: {
SSEAlgorithm: 'AES256'
}
}
]
}
});
});
Resource Count Validation: Ensure constructs create the expected number of resources:
test('creates exactly one Lambda function and one IAM role', () => {
const template = Template.fromStack(stack);
template.resourceCountIs('AWS::Lambda::Function', 1);
template.resourceCountIs('AWS::IAM::Role', 1);
});
Integration Testing
Cross-Stack Dependencies: Test that stack references work correctly:
test('API stack references database from shared stack', () => {
const app = new App();
const sharedStack = new SharedInfrastructureStack(app, 'Shared');
const apiStack = new ApiStack(app, 'Api', {
database: sharedStack.database
});
const apiTemplate = Template.fromStack(apiStack);
// Verify the API stack references the shared database
apiTemplate.hasResourceProperties('AWS::Lambda::Function', {
Environment: {
Variables: {
DATABASE_ENDPOINT: {
'Fn::ImportValue': Match.stringLikeRegexp('Shared.*DatabaseEndpoint')
}
}
}
});
});
Testing Custom Resources
Mock AWS API Calls: Test custom resource logic without making actual AWS calls:
// Using AWS SDK mocks
import { mockClient } from 'aws-sdk-client-mock';
import { S3Client, PutBucketNotificationConfigurationCommand } from '@aws-sdk/client-s3';
const s3Mock = mockClient(S3Client);
beforeEach(() => {
s3Mock.reset();
});
test('custom resource configures S3 notifications', async () => {
s3Mock.on(PutBucketNotificationConfigurationCommand).resolves({});
const result = await handler({
RequestType: 'Create',
ResourceProperties: {
BucketName: 'test-bucket'
}
});
expect(result.PhysicalResourceId).toBeDefined();
expect(s3Mock.call(0).args[0].input).toMatchObject({
Bucket: 'test-bucket'
});
});
Performance Optimization
The Deployment Time Problem
Large CDK applications can take 20 minutes or more to deploy. This significantly impacts productivity and makes rollbacks painful. I've seen teams avoid necessary changes because deployment time makes iteration impossible.
Asset Bundling Optimization
Lambda Bundling Strategy: Optimize Lambda function bundling for faster builds:
new Function(this, 'MyFunction', {
runtime: Runtime.NODEJS_18_X,
handler: 'index.handler',
code: Code.fromAsset('lambda', {
bundling: {
image: Runtime.NODEJS_18_X.bundlingImage,
minify: true,
sourceMap: false,
target: 'es2020',
externalModules: ['aws-sdk'], // Don't bundle AWS SDK
nodeModules: ['lodash'], // Only bundle specific modules
commandHooks: {
beforeBundling: (inputDir, outputDir) => [
'npm ci --only=production'
],
afterBundling: () => []
}
}
})
});
Docker Image Caching: For Container-Based Workloads:
new DockerImageAsset(this, 'MyImage', {
directory: 'docker',
buildArgs: {
BUILDKIT_INLINE_CACHE: '1'
},
cacheFrom: [
DockerCacheOption.registry({
ref: 'myregistry/myapp:cache'
})
],
cacheTo: [
DockerCacheOption.registry({
ref: 'myregistry/myapp:cache',
mode: 'max'
})
]
});
Stack Architecture for Speed
Micro-Stack Strategy: Break large applications into focused stacks:
// Fast-changing application logic
class ApplicationStack extends Stack {
// Lambda functions, API Gateway configurations
// Deploy frequently, rollback quickly
}
// Slow-changing infrastructure
class InfrastructureStack extends Stack {
// VPC, databases, security groups
// Deploy rarely, high stability
}
// Cross-cutting concerns
class MonitoringStack extends Stack {
// CloudWatch dashboards, alarms
// Independent deployment lifecycle
}
Conditional Resource Creation: Avoid recreating unchanged resources:
const isProd = this.node.tryGetContext('environment') === 'prod';
// Only create expensive resources in production
if (isProd) {
new DatabaseCluster(this, 'Database', {
instanceType: InstanceType.of(InstanceClass.R5, InstanceSize.LARGE),
instances: 3
});
} else {
new DatabaseInstance(this, 'Database', {
instanceType: InstanceType.of(InstanceClass.T3, InstanceSize.MICRO)
});
}
Parallel Deployment Strategies
Stack Dependencies: Minimize cross-stack dependencies to enable parallel deployment:
// Independent stacks can deploy in parallel
class FrontendStack extends Stack {} // No dependencies
class ApiStack extends Stack {} // No dependencies
class DatabaseStack extends Stack {} // No dependencies
// Dependent stack waits for others
class IntegrationStack extends Stack {
constructor(scope: Construct, id: string, deps: {
frontend: FrontendStack,
api: ApiStack,
database: DatabaseStack
}) {
// This stack depends on others
}
}
Debugging Workflow
The Anatomy of Infrastructure Failures
When CDK deployments fail, panic sets in. The error messages often point to CloudFormation resources that don't map back to your CDK code. The key is following a systematic debugging process.
The Four-Step Debug Process
Step 1: Synthesize and Inspect: Before deployment, always check what CDK generates:
cdk synth --output cdk.out
Examine the generated CloudFormation in cdk.out/
. This reveals:
Actual resource names and properties
Inter-resource dependencies
IAM policies and roles
Parameter resolution
Pro tip: Use cdk synth --verbose
to see asset bundling and context resolution details.
Step 2: Diff Analysis: Understand what changes CDK wants to make:
cdk diff --context environment=prod
Pay special attention to:
Resource replacements (marked with
[-/+]
)IAM policy changes
Security group modifications
Database parameter changes
Red flag: Any replacement of stateful resources (databases, S3 buckets with data) in production.
Step 3: CloudFormation Event Tracking: When deployment fails, CloudFormation events contain the real error:
aws cloudformation describe-stack-events \
--stack-name MyStack \
--query 'StackEvents[?ResourceStatus==`CREATE_FAILED`]'
Common failure patterns:
IAM permissions errors
Resource limit exceeded
Invalid parameter combinations
Dependency ordering issues
Step 4: Construct Source Investigation: For L2/L3 construct failures, examine the construct source code:
# Find the construct implementation
npm list aws-cdk-lib
# Navigate to node_modules/aws-cdk-lib/aws-lambda/lib/function.js
Understanding the construct logic helps identify:
Default values that conflict with your use case
Required properties you missed
Resource creation order
Advanced Debugging Techniques
CloudFormation Stack Outputs: Add debugging outputs to understand resource creation:
new CfnOutput(this, 'LambdaFunctionName', {
value: myFunction.functionName,
description: 'Generated Lambda function name'
});
new CfnOutput(this, 'DatabaseEndpoint', {
value: database.clusterEndpoint.hostname,
description: 'Database cluster endpoint'
});
Resource Tagging for Tracking: Tag all resources for easier identification:
Tags.of(this).add('Stack', this.stackName);
Tags.of(this).add('Environment', environmentName);
Tags.of(this).add('Owner', 'platform-team');
Tags.of(this).add('CostCenter', 'engineering');
CDK Metadata Analysis: Enable metadata collection for deployment insights:
// In cdk.json
{
"app": "npx ts-node app.ts",
"versionReporting": true,
"analyticsReporting": true,
"metadata": {
"aws:cdk:enable-path-metadata": true,
"aws:cdk:enable-asset-metadata": true
}
}
The Construct Hub
Beyond AWS: The Community Ecosystem
AWS maintains only a fraction of useful constructs. The real power lies in the community ecosystem at constructs.dev, where thousands of developers share battle-tested patterns.
High-Value Community Constructs
Security Patterns
@aws-cdk/aws-lambda-destinations
: Reliable event processingcdk-monitoring-constructs
: Comprehensive monitoring setup@aws-solutions-constructs/aws-waf-*
: Web application firewall patterns
Cost Optimization
cdk-cost-optimization
: Automated cost-saving configurationsaws-lambda-powertools
: Reduce Lambda cold starts and costscdk-spot-instance
: Managed spot instance patterns
Developer Experience
cdk-pipelines
: CI/CD pipeline templatesprojen
: Project generation and maintenanceaws-cdk-github-actions
: GitHub integration patterns
Evaluation Criteria
Before adopting any community construct:
Maintenance activity: Recent commits, active issues resolution
Documentation quality: Clear examples, API documentation
Test coverage: Unit tests, integration tests
Production usage: Download statistics, GitHub stars
Breaking change policy: Semantic versioning compliance
Creating Internal Construct Libraries
Organization-Specific Patterns Encode company standards into reusable constructs:
// @company/cdk-constructs
export class CompanyLambdaFunction extends Function {
constructor(scope: Construct, id: string, props: FunctionProps) {
super(scope, id, {
...props,
// Company defaults
runtime: Runtime.NODEJS_18_X,
architecture: Architecture.ARM_64,
tracing: Tracing.ACTIVE,
insightsVersion: LambdaInsightsVersion.VERSION_1_0_229_0,
environment: {
...props.environment,
// Standard environment variables
LOG_LEVEL: 'INFO',
POWERTOOLS_SERVICE_NAME: props.functionName || id
}
});
// Standard tags
Tags.of(this).add('Owner', 'platform-team');
Tags.of(this).add('ManagedBy', 'cdk');
}
}
Publishing Internal Packages Use npm private packages or internal package repositories:
{
"name": "@company/cdk-constructs",
"version": "1.0.0",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"peerDependencies": {
"aws-cdk-lib": "^2.0.0",
"constructs": "^10.0.0"
}
}
Final Thoughts
The 30-60-90 Day Plan
First 30 Days: Foundation
Migrate a straightforward service from CloudFormation to CDK L2 constructs
Implement proper testing for all constructs
Set up environment-specific configuration management
Create your first custom construct for a repeated pattern
Days 31-60: Composition
Break monolithic constructs into focused, composable pieces
Implement cross-stack resource sharing
Add monitoring and alerting constructs to all services
Create an internal construct library for company patterns
Days 61-90: Optimization
Implement performance optimizations for faster deployments
Set up an automated testing pipeline for infrastructure changes
Contribute to or create community constructs
Establish construct governance and review processes
Your Next Step
Choose one pain point in your current infrastructure management:
Repetitive CloudFormation patterns you copy-paste
Environment configuration that breaks between dev and prod
Deployment processes that take too long
Infrastructure that's hard for team members to understand
Apply one pattern from this guide to solve that specific problem. Document the before and after. Share the results with your team.
The path from infrastructure hell to cloud heaven isn't traveled in a single leap. It's built one construct at a time.
SPONSOR US
The Cloud Playbook is now offering sponsorship slots in each issue. If you want to feature your product or service in my newsletter, explore my sponsor page
That’s it for today!
Did you enjoy this newsletter issue?
Share with your friends, colleagues, and your favorite social media platform.
Until next week — Amrut
Get in touch
You can find me on LinkedIn or X.
If you would like to request a topic to read, you can contact me directly via LinkedIn or X.