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/)