The real value of CDK unlocks when you write constructs that encode your organisation's patterns — "production S3 bucket" that has versioning, encryption, lifecycle rules, and cost-allocation tags by default. This lesson covers how to build them well.
A Custom L3 Construct
import * as cdk from 'aws-cdk-lib'
import * as s3 from 'aws-cdk-lib/aws-s3'
import * as iam from 'aws-cdk-lib/aws-iam'
import { Construct } from 'constructs'
export interface SecureBucketProps {
/** human-readable name used as part of the resource id */
bucketPurpose: string
/** retention policy — defaults to RETAIN in prod, DESTROY otherwise */
removalPolicy?: cdk.RemovalPolicy
/** cost-allocation tags */
team: string
costCenter: string
}
export class SecureBucket extends Construct {
public readonly bucket: s3.Bucket
constructor(scope: Construct, id: string, props: SecureBucketProps) {
super(scope, id)
this.bucket = new s3.Bucket(this, 'Bucket', {
bucketName: cdk.PhysicalName.GENERATE_IF_NEEDED,
versioned: true,
encryption: s3.BucketEncryption.S3_MANAGED,
enforceSSL: true,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
removalPolicy: props.removalPolicy ?? cdk.RemovalPolicy.RETAIN,
autoDeleteObjects: (props.removalPolicy ?? cdk.RemovalPolicy.RETAIN) === cdk.RemovalPolicy.DESTROY,
lifecycleRules: [
{ enabled: true, noncurrentVersionExpiration: cdk.Duration.days(90) },
{
enabled: true,
transitions: [
{ storageClass: s3.StorageClass.INTELLIGENT_TIERING, transitionAfter: cdk.Duration.days(30) },
],
},
],
})
cdk.Tags.of(this.bucket).add('team', props.team)
cdk.Tags.of(this.bucket).add('cost-center', props.costCenter)
cdk.Tags.of(this.bucket).add('purpose', props.bucketPurpose)
}
/** Grant write access to a principal (wraps bucket.grantReadWrite) */
grantWrite(grantee: iam.IGrantable): iam.Grant {
return this.bucket.grantReadWrite(grantee)
}
}
Key design decisions in this construct:
- Opinionated defaults. Versioning, encryption, SSL enforcement, block-all-public — always on.
- Required props.
teamandcostCenterare mandatory — you can't create this bucket without cost allocation. - Expose the underlying construct.
public readonly bucketlets callers access the fulls3.Bucketif they need something not in the wrapper. - Convenience methods.
grantWritewraps IAM complexity behind the common operation.
Props Best Practices
- Use
interface XyzProps— never a class. Props are data, not behaviour. - Optional properties with defaults:
instanceType?: ec2.InstanceType. - Document with JSDoc — IDEs show docs in autocomplete.
- For sub-constructs you expose, use
*Override?: Partial<SomeConstructProps>to allow fine-grained overrides without re-exposing every property.
The Escape Hatch
Sometimes the L2 construct doesn't expose a property. Don't drop to raw CloudFormation — use the escape hatch to modify the underlying L1 (Cfn*) object:
const bucket = new s3.Bucket(this, 'B', { /* ... */ })
// Access the underlying L1
const cfnBucket = bucket.node.defaultChild as s3.CfnBucket
// Override a property not exposed by the L2
cfnBucket.addPropertyOverride('ObjectLockEnabled', true)
cfnBucket.addPropertyOverride('ObjectLockConfiguration', {
ObjectLockEnabled: 'Enabled',
Rule: { DefaultRetention: { Mode: 'COMPLIANCE', Years: 7 } },
})
// Add raw CloudFormation metadata
cfnBucket.cfnOptions.metadata = { compliance: 'SOC2' }
// Add a deletion policy
cfnBucket.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN)
Escape hatches are intentional and stable — they're a documented pattern, not a hack.
Conditional Resources
// Create a resource only in certain stages
if (props.stage === 'prod') {
new route53.ARecord(this, 'Alias', {
zone,
target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution)),
})
}
// Add properties based on stage
new s3.Bucket(this, 'Logs', {
...(props.stage === 'prod' ? {
versioned: true,
removalPolicy: cdk.RemovalPolicy.RETAIN,
} : {
removalPolicy: cdk.RemovalPolicy.DESTROY,
autoDeleteObjects: true,
}),
})
Conditional logic is just TypeScript — no special CDK syntax needed. This is one of the major advantages over templating systems.
Loops and Bulk Creation
const environments = ['dev', 'staging', 'prod'] as const
environments.forEach((env) => {
new WebStack(app, `WebStack-${env}`, { env: targetEnv, stage: env })
})
// Multiple queues
const services = ['payments', 'notifications', 'analytics']
const queues = services.map((name) =>
new sqs.Queue(this, `Queue-${name}`, {
queueName: `${name}-${props.stage}`,
visibilityTimeout: cdk.Duration.seconds(30),
})
)
Grants: IAM the CDK Way
CDK L2 constructs expose grant* methods that create minimal IAM policies:
const bucket = new s3.Bucket(this, 'Data')
const queue = new sqs.Queue(this, 'Jobs')
const lambda = new lambdaNodejs.NodejsFunction(this, 'Worker', { /* ... */ })
bucket.grantRead(lambda) // adds GetObject, ListBucket
bucket.grantReadWrite(lambda) // adds Put, Delete
queue.grantConsumeMessages(lambda) // receives + deletes
queue.grantSendMessages(lambda) // SendMessage only
// Also: addToRolePolicy for ad-hoc permissions
lambda.addToRolePolicy(new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['ssm:GetParameter'],
resources: [`arn:aws:ssm:${this.region}:${this.account}:parameter/app/*`],
}))
Use grant* over manual policies wherever possible — they are kept up to date by the CDK team as AWS expands action sets.
Publishing Constructs
Internal constructs live as npm packages in a private registry:
{
"name": "@acme/cdk-constructs",
"version": "1.4.2",
"peerDependencies": {
"aws-cdk-lib": "^2.0.0",
"constructs": "^10.0.0"
}
}
Distribute via:
- AWS CodeArtifact — private npm registry in AWS
- GitHub Packages
- Azure DevOps Artifacts
- Nexus / Artifactory
For open-source constructs: Constructs Hub is the discovery site. Many high-quality L3s are published there — don't write what already exists.
CDK Design Guidelines (selected)
AWS's official design guidelines are worth reading in full. The most impactful rules:
- Be consistent with AWS services naming conventions.
- Properties are optional unless absolutely required. Provide sensible defaults.
- Do not expose internal constructs — use typed outputs (
public readonly bucket: s3.IBucket). - Accept interfaces, not implementation classes (
ec2.IVpcnotec2.Vpc) — this enables test mocking and import scenarios. - Support CDK tokens — never try to resolve or inspect token values.
- Use
cdk.Names.uniqueIdfor unique strings that need to be repeatable.
Aspect Patterns
class EnforceEncryption implements cdk.IAspect {
visit(node: IConstruct): void {
if (node instanceof s3.Bucket) {
if (node.encryptionKey === undefined &&
node.encryption !== s3.BucketEncryption.KMS) {
cdk.Annotations.of(node).addError(
'All buckets must be KMS encrypted in production'
)
}
}
}
}
// Apply to the prod stack only
if (props.stage === 'prod') {
cdk.Aspects.of(this).add(new EnforceEncryption())
}
Annotations let you emit warnings and errors at synth time, before any CloudFormation ever runs. Use them to enforce organisation policies.
Solutions Constructs and the Construct Hub
Before writing a complex pattern, check:
- AWS Solutions Constructs — AWS-maintained L3s: ALB + Fargate, API Gateway + Lambda + DynamoDB, CloudFront + S3, and 50+ more.
- Constructs Hub — community and vendor constructs: Datadog CDK, Snyk CDK, Lumigo, Dynatrace.
With solid construct patterns, the next challenge is verifying they work — which requires unit testing. That is the next lesson.