CDK Construct Development
Outlines best practices for developing AWS CDK constructs. Following these guidelines will ensure that constructs are reusable, maintainable, and follow AWS best practices.
CDK
TypeScript
@cremich
Author
Submitted on April 11, 2025
# Construct Development ## Rules ### 1. Construct Hierarchy #### 1.1 Understanding Construct Levels - **L1 Constructs**: Direct mappings to CloudFormation resources with "Cfn" prefix - **L2 Constructs**: Higher-level abstractions with sensible defaults and helper methods - **L3 Constructs**: Patterns that combine multiple resources to solve specific use cases #### 1.2 When to Use Each Level - Use **L1 constructs** only when L2 or L3 constructs are not available for a specific resource - Use **L2 constructs** as the default choice for most resources - Use **L3 constructs** (patterns) when implementing common architectural patterns ```typescript // ❌ Using L1 construct when L2 is available const bucket = new s3.CfnBucket(this, "MyBucket", { bucketName: "my-bucket", versioning: { status: "Enabled", }, }); // ✅ Using L2 construct with better defaults and methods const bucket = new s3.Bucket(this, "MyBucket", { bucketName: "my-bucket", versioned: true, encryption: s3.BucketEncryption.S3_MANAGED, blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, }); ``` ### 2. Extension vs. Composition #### 2.1 When to Extend - Extend existing constructs when there's an "is a" relationship - Use extension to add default properties or behaviors to existing constructs - Override methods only when necessary to change behavior ```typescript // ✅ Extending an L2 construct to add default properties export class SecureBucket extends s3.Bucket { constructor(scope: Construct, id: string, props?: s3.BucketProps) { super(scope, id, { ...props, encryption: s3.BucketEncryption.S3_MANAGED, blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, versioned: true, lifecycleRules: [ { id: "TransitionToIntelligentTiering", transitions: [ { storageClass: s3.StorageClass.INTELLIGENT_TIERING, transitionAfter: Duration.days(30), }, ], ...props?.lifecycleRules?.[0], }, ...(props?.lifecycleRules?.slice(1) || []), ], }); } } ``` #### 2.2 When to Compose - Use composition when there's a "has a" relationship - Compose constructs to create higher-level abstractions (L3 constructs) - Expose only the necessary properties and methods to consumers ```typescript // ✅ Composing constructs to create a higher-level abstraction export interface ApiEndpointProps { apiName: string; handlerPath: string; environment?: Record<string, string>; timeout?: Duration; } export class ApiEndpoint extends Construct { public readonly url: string; private readonly api: apigateway.RestApi; private readonly handler: lambda.Function; constructor(scope: Construct, id: string, props: ApiEndpointProps) { super(scope, id); this.handler = new lambda.Function(this, "Handler", { runtime: lambda.Runtime.NODEJS_18_X, handler: "index.handler", code: lambda.Code.fromAsset(props.handlerPath), environment: props.environment, timeout: props.timeout || Duration.seconds(30), }); this.api = new apigateway.RestApi(this, "Api", { restApiName: props.apiName, deployOptions: { stageName: "prod", }, }); const integration = new apigateway.LambdaIntegration(this.handler); this.api.root.addMethod("GET", integration); this.url = this.api.url; } } ``` ### 3. Custom Construct Development #### 3.1 Construct Structure - Follow the standard construct initialization pattern - Accept `scope`, `id`, and `props` parameters - Call `super(scope, id)` in the constructor - Initialize resources in the constructor ```typescript // ✅ Standard construct structure export interface DatabaseProps { databaseName: string; instanceType?: ec2.InstanceType; backupRetentionDays?: number; vpc: ec2.IVpc; } export class Database extends Construct { public readonly endpoint: string; public readonly port: number; public readonly secret: secretsmanager.ISecret; constructor(scope: Construct, id: string, props: DatabaseProps) { super(scope, id); // Initialize resources const securityGroup = new ec2.SecurityGroup(this, "SecurityGroup", { vpc: props.vpc, description: "Security group for database", }); const databaseSecret = new secretsmanager.Secret(this, "Secret", { secretName: `${props.databaseName}-credentials`, generateSecretString: { secretStringTemplate: JSON.stringify({ username: "admin" }), generateStringKey: "password", excludePunctuation: true, }, }); const database = new rds.DatabaseInstance(this, "Instance", { engine: rds.DatabaseInstanceEngine.postgres({ version: rds.PostgresEngineVersion.VER_13, }), instanceType: props.instanceType || ec2.InstanceType.of(ec2.InstanceClass.T3, ec2.InstanceSize.MEDIUM), vpc: props.vpc, vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, securityGroups: [securityGroup], databaseName: props.databaseName, credentials: rds.Credentials.fromSecret(databaseSecret), backupRetention: Duration.days(props.backupRetentionDays || 7), }); // Set public properties this.endpoint = database.dbInstanceEndpointAddress; this.port = database.dbInstanceEndpointPort; this.secret = databaseSecret; } } ``` #### 3.2 Resource Naming - Use logical IDs that describe the resource's purpose - Avoid using generic names like "Bucket" or "Function" - Include the resource type in the logical ID ```typescript // ❌ Generic resource names const bucket = new s3.Bucket(this, 'Bucket'); const function = new lambda.Function(this, 'Function'); // ✅ Descriptive resource names const assetBucket = new s3.Bucket(this, 'AssetStorageBucket'); const imageProcessor = new lambda.Function(this, 'ImageProcessorFunction'); ``` ### 4. Props Interface Design #### 4.1 Props Interface Structure - Define a clear interface for construct props - Use descriptive property names - Mark required properties as non-optional - Provide sensible defaults for optional properties ```typescript // ✅ Well-designed props interface export interface ApiGatewayProps { // Required properties apiName: string; handlerFunction: lambda.IFunction; // Optional properties with defaults stageName?: string; enableCors?: boolean; apiKeyRequired?: boolean; throttlingRateLimit?: number; } // Usage in construct const stageName = props.stageName || "prod"; const enableCors = props.enableCors ?? true; const apiKeyRequired = props.apiKeyRequired ?? false; const throttlingRateLimit = props.throttlingRateLimit || 1000; ``` #### 4.2 Prop Validation - Validate props in the constructor - Throw clear error messages for invalid props - Use default values for optional props ```typescript // ✅ Prop validation constructor(scope: Construct, id: string, props: ApiGatewayProps) { super(scope, id); if (!props.apiName.match(/^[a-zA-Z0-9_-]+$/)) { throw new Error('API name must contain only alphanumeric characters, hyphens, and underscores'); } if (props.throttlingRateLimit && props.throttlingRateLimit < 0) { throw new Error('Throttling rate limit must be a positive number'); } // Continue with construct implementation } ``` ### 5. Escape Hatches #### 5.1 When to Use Escape Hatches - Use escape hatches only when necessary - Use them to access features not yet available in higher-level constructs - Document why the escape hatch is needed ```typescript // ✅ Using escape hatch to access features not available in L2 construct const dynamoTable = new dynamodb.Table(this, "Table", { partitionKey: { name: "id", type: dynamodb.AttributeType.STRING }, billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, }); // Access the L1 construct to set properties not available in L2 const cfnTable = dynamoTable.node.defaultChild as dynamodb.CfnTable; cfnTable.contributorInsightsSpecification = { enabled: true, }; ``` #### 5.2 Finding the Right Escape Hatch - Use `node.defaultChild` to access the underlying L1 construct - Cast to the appropriate type using `as` - Set properties directly on the L1 construct ```typescript // ✅ Finding the right escape hatch const bucket = new s3.Bucket(this, "AssetBucket"); // Access the L1 construct const cfnBucket = bucket.node.defaultChild as s3.CfnBucket; // Set properties not available in L2 cfnBucket.analyticsConfigurations = [ { id: "AnalyticsConfig", storageClassAnalysis: { dataExport: { outputSchemaVersion: "1", destination: { s3BucketDestination: { bucketArn: analyticsBucket.bucketArn, prefix: "analytics/", }, }, }, }, }, ]; ``` ### 6. Service-Specific Best Practices #### 6.1 Compute Constructs - Separate business logic from infrastructure code - Configure appropriate memory and timeout settings - Set up proper IAM permissions using the principle of least privilege ```typescript // ✅ Well-configured Lambda function export class ApiHandler extends Construct { public readonly function: lambda.Function; constructor(scope: Construct, id: string, props: ApiHandlerProps) { super(scope, id); this.function = new lambda.Function(this, "Function", { runtime: lambda.Runtime.NODEJS_18_X, handler: "index.handler", code: lambda.Code.fromAsset(props.codePath), memorySize: props.memorySize || 256, timeout: props.timeout || Duration.seconds(30), environment: props.environment || {}, tracing: lambda.Tracing.ACTIVE, logRetention: logs.RetentionDays.ONE_WEEK, }); // Grant specific permissions instead of broad access if (props.tableName) { const table = dynamodb.Table.fromTableName(this, "Table", props.tableName); table.grantReadWriteData(this.function); } if (props.bucketName) { const bucket = s3.Bucket.fromBucketName(this, "Bucket", props.bucketName); bucket.grantRead(this.function); } } } ``` #### 6.2 Storage Constructs - Configure appropriate encryption and access controls - Set up lifecycle rules for cost optimization - Implement proper backup and retention policies ```typescript // ✅ Well-configured S3 bucket export class DataBucket extends Construct { public readonly bucket: s3.Bucket; constructor(scope: Construct, id: string, props: DataBucketProps) { super(scope, id); this.bucket = new s3.Bucket(this, "Bucket", { bucketName: props.bucketName, encryption: s3.BucketEncryption.S3_MANAGED, blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, versioned: props.versioned ?? true, enforceSSL: true, removalPolicy: props.removalPolicy || RemovalPolicy.RETAIN, lifecycleRules: [ { id: "TransitionToInfrequentAccess", transitions: [ { storageClass: s3.StorageClass.INFREQUENT_ACCESS, transitionAfter: Duration.days(30), }, ], }, { id: "TransitionToGlacier", transitions: [ { storageClass: s3.StorageClass.GLACIER, transitionAfter: Duration.days(90), }, ], }, { id: "ExpireNoncurrentVersions", noncurrentVersionExpiration: Duration.days(30), }, ], }); } } ``` #### 6.3 Networking Constructs - Design VPCs with appropriate subnet architecture - Configure proper security groups with least privilege - Set up VPC endpoints for AWS services ```typescript // ✅ Well-designed VPC export class ApplicationVpc extends Construct { public readonly vpc: ec2.Vpc; public readonly privateSubnets: ec2.ISubnet[]; public readonly publicSubnets: ec2.ISubnet[]; constructor(scope: Construct, id: string, props: ApplicationVpcProps) { super(scope, id); this.vpc = new ec2.Vpc(this, "Vpc", { maxAzs: props.maxAzs || 3, natGateways: props.natGateways || 1, subnetConfiguration: [ { name: "public", subnetType: ec2.SubnetType.PUBLIC, cidrMask: 24, }, { name: "private", subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, cidrMask: 24, }, { name: "isolated", subnetType: ec2.SubnetType.PRIVATE_ISOLATED, cidrMask: 24, }, ], }); // Add VPC endpoints for common AWS services new ec2.GatewayVpcEndpoint(this, "S3Endpoint", { vpc: this.vpc, service: ec2.GatewayVpcEndpointAwsService.S3, }); new ec2.GatewayVpcEndpoint(this, "DynamoDBEndpoint", { vpc: this.vpc, service: ec2.GatewayVpcEndpointAwsService.DYNAMODB, }); if (props.includeInterfaceEndpoints) { new ec2.InterfaceVpcEndpoint(this, "SecretsManagerEndpoint", { vpc: this.vpc, service: ec2.InterfaceVpcEndpointAwsService.SECRETS_MANAGER, privateDnsEnabled: true, }); new ec2.InterfaceVpcEndpoint(this, "EcrEndpoint", { vpc: this.vpc, service: ec2.InterfaceVpcEndpointAwsService.ECR, privateDnsEnabled: true, }); } this.privateSubnets = this.vpc.privateSubnets; this.publicSubnets = this.vpc.publicSubnets; } } ``` ## Examples ### L2 Construct Extension Example ```typescript // Extending an L2 construct to add default properties export class SecureBucket extends s3.Bucket { constructor(scope: Construct, id: string, props?: s3.BucketProps) { super(scope, id, { encryption: s3.BucketEncryption.S3_MANAGED, blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, versioned: true, enforceSSL: true, removalPolicy: RemovalPolicy.RETAIN, ...props, }); // Add default lifecycle rules if not provided if (!props?.lifecycleRules || props.lifecycleRules.length === 0) { this.addLifecycleRule({ id: "TransitionToIntelligentTiering", transitions: [ { storageClass: s3.StorageClass.INTELLIGENT_TIERING, transitionAfter: Duration.days(30), }, ], }); } } } ``` ### L3 Construct (Pattern) Example ```typescript // Creating an L3 construct for a complete serverless API solution export interface ServerlessApiProps { apiName: string; handlerPath: string; tableName: string; environment?: Record<string, string>; allowedOrigins?: string[]; } export class ServerlessApi extends Construct { public readonly apiEndpoint: string; public readonly table: dynamodb.Table; constructor(scope: Construct, id: string, props: ServerlessApiProps) { super(scope, id); // Create DynamoDB table this.table = new dynamodb.Table(this, "Table", { tableName: props.tableName, partitionKey: { name: "id", type: dynamodb.AttributeType.STRING }, billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, removalPolicy: RemovalPolicy.RETAIN, pointInTimeRecovery: true, }); // Create Lambda function const handler = new lambda.Function(this, "Handler", { runtime: lambda.Runtime.NODEJS_18_X, handler: "index.handler", code: lambda.Code.fromAsset(props.handlerPath), environment: { TABLE_NAME: this.table.tableName, ...props.environment, }, tracing: lambda.Tracing.ACTIVE, }); // Grant permissions this.table.grantReadWriteData(handler); // Create API Gateway const api = new apigateway.RestApi(this, "Api", { restApiName: props.apiName, defaultCorsPreflightOptions: { allowOrigins: props.allowedOrigins || ["*"], allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], allowHeaders: ["Content-Type", "Authorization"], }, deployOptions: { tracingEnabled: true, loggingLevel: apigateway.MethodLoggingLevel.INFO, dataTraceEnabled: true, }, }); // Create resources and methods const items = api.root.addResource("items"); items.addMethod("GET", new apigateway.LambdaIntegration(handler)); items.addMethod("POST", new apigateway.LambdaIntegration(handler)); const item = items.addResource("{id}"); item.addMethod("GET", new apigateway.LambdaIntegration(handler)); item.addMethod("PUT", new apigateway.LambdaIntegration(handler)); item.addMethod("DELETE", new apigateway.LambdaIntegration(handler)); this.apiEndpoint = api.url; } } ``` ### Escape Hatch Example ```typescript // Using an escape hatch to configure features not available in L2 constructs export class EnhancedTable extends Construct { public readonly table: dynamodb.Table; constructor(scope: Construct, id: string, props: EnhancedTableProps) { super(scope, id); // Create the table using L2 construct this.table = new dynamodb.Table(this, "Table", { partitionKey: { name: props.partitionKey, type: dynamodb.AttributeType.STRING }, sortKey: props.sortKey ? { name: props.sortKey, type: dynamodb.AttributeType.STRING } : undefined, billingMode: props.billingMode || dynamodb.BillingMode.PAY_PER_REQUEST, removalPolicy: props.removalPolicy || RemovalPolicy.RETAIN, pointInTimeRecovery: props.pointInTimeRecovery ?? true, }); // Use escape hatch to set contributor insights (not available in L2) const cfnTable = this.table.node.defaultChild as dynamodb.CfnTable; cfnTable.contributorInsightsSpecification = { enabled: props.enableContributorInsights ?? true, }; // Use escape hatch to set kinesis stream specification (not available in L2) if (props.kinesisStreamArn) { cfnTable.kinesisStreamSpecification = { streamArn: props.kinesisStreamArn, }; } // Add global secondary indexes if provided if (props.globalSecondaryIndexes) { props.globalSecondaryIndexes.forEach((gsi) => { this.table.addGlobalSecondaryIndex({ indexName: gsi.indexName, partitionKey: { name: gsi.partitionKey, type: dynamodb.AttributeType.STRING }, sortKey: gsi.sortKey ? { name: gsi.sortKey, type: dynamodb.AttributeType.STRING } : undefined, projectionType: gsi.projectionType || dynamodb.ProjectionType.ALL, }); }); } } } ``` ## Anti-patterns ### ❌ Mixing Resource Creation and Business Logic ```typescript // ❌ Bad: Mixing resource creation and business logic export class DataProcessor extends Construct { constructor(scope: Construct, id: string, props: DataProcessorProps) { super(scope, id); const bucket = new s3.Bucket(this, "Bucket"); // Business logic mixed with resource creation if (props.environment === "production") { bucket.addLifecycleRule({ id: "DeleteAfter90Days", expiration: Duration.days(90), }); } else { bucket.addLifecycleRule({ id: "DeleteAfter7Days", expiration: Duration.days(7), }); } // More business logic if (props.region === "us-east-1") { // Special handling for us-east-1 } } } ``` **Why it's bad**: Mixing resource creation with business logic makes constructs less reusable and harder to test. Business logic should be separated from infrastructure definition. ### ❌ Hardcoding Resource Configuration ```typescript // ❌ Bad: Hardcoding resource configuration export class ApiGateway extends Construct { constructor(scope: Construct, id: string) { super(scope, id); const api = new apigateway.RestApi(this, "Api", { restApiName: "MyApi", deployOptions: { stageName: "prod", loggingLevel: apigateway.MethodLoggingLevel.INFO, dataTraceEnabled: true, }, defaultCorsPreflightOptions: { allowOrigins: ["https://example.com"], allowMethods: ["GET", "POST", "PUT", "DELETE"], allowHeaders: ["Content-Type", "Authorization"], }, }); // More hardcoded settings } } ``` **Why it's bad**: Hardcoding configuration makes constructs inflexible and difficult to reuse in different contexts. Configuration should be parameterized through props. ### ❌ Not Using Proper Construct Hierarchy ```typescript // ❌ Bad: Not using proper construct hierarchy export class ApplicationStack extends Stack { constructor(scope: Construct, id: string, props?: StackProps) { super(scope, id, props); // Everything in one stack without proper construct hierarchy const bucket = new s3.Bucket(this, "AssetBucket"); const table = new dynamodb.Table(this, "Table", { /* ... */ }); const handler = new lambda.Function(this, "Function", { /* ... */ }); const api = new apigateway.RestApi(this, "Api", { /* ... */ }); const distribution = new cloudfront.Distribution(this, "Distribution", { /* ... */ }); // Direct references between resources handler.addEnvironment("TABLE_NAME", table.tableName); handler.addEnvironment("BUCKET_NAME", bucket.bucketName); table.grantReadWriteData(handler); bucket.grantReadWrite(handler); } } ``` **Why it's bad**: Not using proper construct hierarchy makes the code harder to understand, test, and maintain. Resources should be organized into logical constructs. ### ❌ Overusing Escape Hatches ```typescript // ❌ Bad: Overusing escape hatches export class DataBucket extends Construct { constructor(scope: Construct, id: string, props: DataBucketProps) { super(scope, id); const bucket = new s3.Bucket(this, "Bucket"); // Overusing escape hatches when L2 methods are available const cfnBucket = bucket.node.defaultChild as s3.CfnBucket; cfnBucket.versioning = { status: "Enabled", }; cfnBucket.publicAccessBlockConfiguration = { blockPublicAcls: true, blockPublicPolicy: true, ignorePublicAcls: true, restrictPublicBuckets: true, }; } } ``` **Why it's bad**: Overusing escape hatches bypasses the benefits of L2 constructs, such as sensible defaults and helper methods. Use L2 methods when available. ## References - [AWS CDK Construct Documentation](https://docs.aws.amazon.com/cdk/api/v2/docs/constructs.Construct.html) - [AWS CDK Best Practices](https://docs.aws.amazon.com/prescriptive-guidance/latest/best-practices-cdk-typescript-iac/constructs-best-practices.html) - [AWS CDK Patterns](https://cdkpatterns.com/) - [AWS Well-Architected Framework](https://aws.amazon.com/architecture/well-architected/)