Skip to content
7 min read·Lesson 4 of 8

CDK Construct Patterns and Best Practices

Building reusable constructs — the Library pattern, Escape Hatches, aspects, and the conventions the CDK community has settled on.

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. team and costCenter are mandatory — you can't create this bucket without cost allocation.
  • Expose the underlying construct. public readonly bucket lets callers access the full s3.Bucket if they need something not in the wrapper.
  • Convenience methods. grantWrite wraps 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:

  1. Be consistent with AWS services naming conventions.
  2. Properties are optional unless absolutely required. Provide sensible defaults.
  3. Do not expose internal constructs — use typed outputs (public readonly bucket: s3.IBucket).
  4. Accept interfaces, not implementation classes (ec2.IVpc not ec2.Vpc) — this enables test mocking and import scenarios.
  5. Support CDK tokens — never try to resolve or inspect token values.
  6. Use cdk.Names.uniqueId for 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.

Key Takeaways

  • Build custom L3 constructs to encode your organisation's security and operational standards.
  • Props interfaces extend cdk.StackProps or are plain interfaces — keep them flat and typed.
  • Escape hatches (cfnBucket.addPropertyOverride) let you access raw CloudFormation when the L2 doesn't expose what you need.
  • Publish internal constructs as npm packages — private npm registry or GitHub Packages.
  • Follow AWS CDK design guidelines: props are optional by default with sensible defaults; don't expose internal constructs; use grants for IAM.

Test your knowledge

Try exam-style practice questions to reinforce what you've learned.

Practice Questions →