💸 Catch expensive AWS mistakes before deployment! See cost impact in GitHub PRs for Terraform & CDK. Join the Free Beta!
What is an AWS CDK Construct? Complete Guide to L1, L2, and L3 [2026]

What is an AWS CDK Construct? Complete Guide to L1, L2, and L3 [2026]

Learn what AWS CDK constructs are, understand L1, L2, and L3 construct levels, and master scope, ID, and props with visual diagrams and practical examples.

January 6th, 2026
0 views
--- likes

If you're building infrastructure with the AWS CDK, understanding constructs is fundamental to everything you'll do. Constructs are the building blocks that let you define AWS resources using familiar programming languages instead of JSON or YAML templates.

In this guide, you'll learn what constructs are, how the three construct levels (L1, L2, L3) work, and when to use each one. By the end, you'll understand how constructs form a hierarchy, how to create your own custom constructs, and the best practices that separate well-designed CDK applications from tangled messes.

What is an AWS CDK Construct?

A construct is a component within your CDK application that represents one or more AWS CloudFormation resources and their configuration. Think of constructs as cloud components that encapsulate everything CloudFormation needs to create your infrastructure.

Every construct you work with is a class that extends the base Construct class from the constructs package. This might seem like a small detail, but it's actually important because this base class is the foundation of the entire CDK ecosystem.

The Building Blocks of Cloud Infrastructure

When you define infrastructure with the CDK, you're essentially composing constructs together like building blocks. A simple construct might represent a single S3 bucket. A more complex construct could represent an entire application architecture with load balancers, containers, databases, and the networking to connect them.

The real power comes from reusability. Once you create a construct, you can instantiate it multiple times across different CDK stacks, share it with your team, or even publish it for the community to use. Updates to your construct library automatically propagate to every application using it, just like any other code library.

How Constructs Relate to CloudFormation

Under the hood, every CDK application synthesizes into a CloudFormation template. When you run cdk synth, the CDK traverses your construct tree and generates the corresponding CloudFormation resources. This means constructs are ultimately an abstraction layer on top of CloudFormation, but one that gives you the full power of programming languages.

This relationship means you get the reliability of CloudFormation (state management, rollbacks, drift detection) combined with the flexibility of real programming languages (loops, conditionals, abstractions, type checking).

The Construct Programming Model (CPM)

Here's something that often surprises developers: the construct pattern isn't exclusive to AWS. The Construct Programming Model (CPM) makes constructs portable across different infrastructure tools:

  • CDK for Terraform (CDKtf): Use constructs to generate Terraform configurations
  • CDK for Kubernetes (CDK8s): Define Kubernetes manifests with constructs
  • Projen: Build project configurations using the same patterns

This means the skills you develop working with AWS CDK constructs transfer directly to other infrastructure-as-code contexts.

The Three Construct Levels: L1, L2, and L3

Constructs from the AWS Construct Library fall into three categories, each offering a different level of abstraction. Understanding these levels is essential for choosing the right tool for each job.

The higher the level, the easier to configure (requiring less expertise). The lower the level, the more customization available (requiring more expertise). Here's how they compare:

L1 Constructs (CFN Resources)

L1 constructs are the lowest-level constructs, providing no abstraction at all. Each L1 construct maps directly to a single CloudFormation resource type.

You can identify L1 constructs by their Cfn prefix. For example, CfnBucket corresponds to the AWS::S3::Bucket CloudFormation resource. These constructs are automatically generated from the CloudFormation resource specification, which is why new CloudFormation resources typically appear as L1 constructs within one week of release.

When to use L1 constructs:

  • You need access to properties not yet exposed by L2 constructs
  • You're migrating existing CloudFormation templates to CDK
  • You require precise control over every resource property
  • A service doesn't have an L2 construct available yet
import * as s3 from 'aws-cdk-lib/aws-s3';

// L1 construct - direct CloudFormation mapping
new s3.CfnBucket(this, 'MyL1Bucket', {
  bucketName: 'my-bucket-name',
  versioningConfiguration: {
    status: 'Enabled'
  },
  bucketEncryption: {
    serverSideEncryptionConfiguration: [{
      serverSideEncryptionByDefault: {
        sseAlgorithm: 'AES256'
      }
    }]
  }
});

The trade-off is clear: L1 constructs give you complete control, but you're responsible for configuring everything correctly, including security settings that L2 constructs handle automatically.

L2 Constructs (Curated Constructs)

L2 constructs are the workhorses of CDK development. They're thoughtfully designed by the CDK team to provide a higher-level abstraction while still mapping to single CloudFormation resources.

The key advantages of L2 constructs:

  • Sensible defaults: Security best practices are built in
  • Grant methods: Easily manage IAM permissions with grantRead(), grantWrite(), etc.
  • Boilerplate generation: Glue logic and configuration handled automatically
  • Intent-based API: Focus on what you want, not how to configure it
import * as s3 from 'aws-cdk-lib/aws-s3';

// L2 construct - curated abstraction with sensible defaults
new s3.Bucket(this, 'MyL2Bucket', {
  versioned: true,
  encryption: s3.BucketEncryption.S3_MANAGED,
  blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, // Security by default
  enforceSSL: true
});

Notice how the L2 version is cleaner and automatically includes security features like blockPublicAccess. The CDK team has already made the secure choice the default choice.

For a practical example, see how to create an Amazon S3 bucket with AWS CDK using L2 constructs with proper encryption and access controls.

L3 Constructs (Patterns)

L3 constructs, also called patterns, represent the highest level of abstraction. Each L3 construct contains a collection of resources configured to work together, enabling you to deploy entire architectures with minimal configuration.

import * as ecsPatterns from 'aws-cdk-lib/aws-ecs-patterns';

// L3 construct - complete architecture pattern
new ecsPatterns.ApplicationLoadBalancedFargateService(this, 'MyService', {
  taskImageOptions: {
    image: ecs.ContainerImage.fromRegistry('amazon/amazon-ecs-sample')
  },
  publicLoadBalancer: true
});

This single construct creates:

  • A Fargate service
  • An Application Load Balancer
  • Target groups and listeners
  • Security groups with appropriate rules
  • IAM roles and policies
  • CloudWatch log groups

You can explore this pattern in detail with the Application Load Balanced Fargate Service example or learn about scheduled workloads with the Scheduled Fargate Task guide.

When to Use L1 vs L2 vs L3: A Decision Framework

Choosing the right construct level depends on your specific needs. Use this decision framework:

Start with L2 constructs for most resources. They provide the best balance of convenience and flexibility. Drop down to L1 when you need properties not yet exposed, or rise to L3 when you need a complete architectural pattern.

Understanding Scope, ID, and Props

Every construct takes three parameters when initialized. Understanding these parameters is essential for working effectively with the CDK.

Scope: Your Construct's Place in the Tree

The scope parameter determines where your construct sits in the construct tree. It's the parent or owner of the construct you're creating.

In practice, you almost always pass this (or self in Python) as the scope, representing the current construct where you're defining the new one.

export class MyStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // 'this' is the scope - this bucket belongs to MyStack
    new s3.Bucket(this, 'DataBucket');
  }
}

The scope creates a parent-child relationship that affects how resources are named, how dependencies are tracked, and how the construct tree is traversed during synthesis.

ID: The Unique Identifier

The ID is a string that must be unique within its scope. It serves as a namespace for everything defined within the construct and is used to generate:

  • CloudFormation logical IDs
  • Resource physical names (when not explicitly set)
  • Construct tree paths

Important: Changing a construct's ID changes its CloudFormation logical ID, which CloudFormation interprets as deleting the old resource and creating a new one. This can cause unexpected data loss for stateful resources like databases or S3 buckets with data.

// These two buckets can have the same ID because they're in different scopes
new s3.Bucket(stackA, 'SharedBucket');
new s3.Bucket(stackB, 'SharedBucket'); // OK - different scope

IDs only need to be unique within their scope, allowing you to reuse the same ID across different parent constructs.

Props: Configuration Made Simple

Props are the configuration options for your construct, passed as a single object (TypeScript/JavaScript) or keyword arguments (Python). Each construct type defines its own props interface.

The beauty of L2 constructs is that most props are optional with sensible defaults:

// Minimal - uses all defaults
new s3.Bucket(this, 'SimpleBucket');

// Configured - override specific defaults
new s3.Bucket(this, 'ConfiguredBucket', {
  versioned: true,
  lifecycleRules: [{
    expiration: cdk.Duration.days(90)
  }]
});

Different languages handle props differently:

LanguageProps Pattern
TypeScript/JavaScriptObject with interface (e.g., BucketProps)
PythonKeyword arguments with snake_case naming
JavaBuilder pattern with method chaining
C#Props object with inner classes
GoStruct with pointer syntax

The Construct Tree Hierarchy

Constructs form a hierarchy known as the construct tree. Understanding this structure helps you design maintainable CDK applications and troubleshoot issues when they arise.

App, Stack, and Construct Relationship

The construct tree has a specific structure:

  • App: The root of the construct tree, representing your entire CDK application
  • Stack: The unit of deployment, mapping to a CloudFormation stack
  • Constructs: The resources and components within stacks

All constructs representing AWS resources must be defined within a Stack, either directly or as children of other constructs within that stack. The App and Stack constructs are special - they provide context rather than directly configuring AWS resources.

You can learn more about how stacks work in the AWS CDK Stack guide, and explore patterns for sharing resources across stacks when your architecture spans multiple deployment units.

Logical IDs and Physical Names

The CDK generates CloudFormation logical IDs based on the construct's ID and its path in the tree. Understanding this helps you predict resource behavior:

  • Logical ID: Generated from construct path + hash (e.g., ProductionVpcConstruct8F42B11)
  • Physical Name: The actual name visible in AWS Console (e.g., production-vpc-8f42b11-xyz)

Every construct has a node attribute that provides access to the tree:

const bucket = new s3.Bucket(this, 'MyBucket');

console.log(bucket.node.id);       // 'MyBucket'
console.log(bucket.node.path);     // 'MyStack/MyBucket'
console.log(bucket.node.scope);    // Reference to parent (the stack)

Warning: Moving a construct or changing its ID changes the logical ID, causing CloudFormation to replace the resource. For stateful resources, this means data loss unless you handle the migration carefully.

How to Create a Custom Construct

Creating custom constructs is where the CDK truly shines. You can encapsulate complex configurations, enforce organizational standards, and share reusable components.

Basic Construct Structure

Every custom construct follows the same pattern: extend the Construct base class and call super(scope, id) in the constructor.

import { Construct } from 'constructs';

export interface MyConstructProps {
  // Define your construct's configuration options
}

export class MyConstruct extends Construct {
  constructor(scope: Construct, id: string, props?: MyConstructProps) {
    super(scope, id);

    // Define resources and configuration here
  }
}

Before you start building constructs, make sure you have the CDK installed. Check out the guide on how to install AWS CDK if you haven't already set up your environment. For organizing your custom constructs in production projects, see our CDK project structure guide.

Adding Properties with an Interface

Define a props interface to expose configuration options for your construct:

import { Construct } from 'constructs';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as sns from 'aws-cdk-lib/aws-sns';
import * as s3n from 'aws-cdk-lib/aws-s3-notifications';

export interface NotifyingBucketProps {
  /**
   * The SNS topic to notify when objects are created
   * @default - A new topic is created
   */
  readonly topic?: sns.ITopic;

  /**
   * The S3 event types that trigger notifications
   * @default - s3.EventType.OBJECT_CREATED
   */
  readonly eventTypes?: s3.EventType[];
}

export class NotifyingBucket extends Construct {
  public readonly bucket: s3.Bucket;
  public readonly topic: sns.Topic;

  constructor(scope: Construct, id: string, props: NotifyingBucketProps = {}) {
    super(scope, id);

    this.bucket = new s3.Bucket(this, 'Bucket');
    this.topic = props.topic ?? new sns.Topic(this, 'Topic');

    const eventTypes = props.eventTypes ?? [s3.EventType.OBJECT_CREATED];

    for (const eventType of eventTypes) {
      this.bucket.addEventNotification(eventType, new s3n.SnsDestination(this.topic));
    }
  }
}

This pattern demonstrates several best practices:

  • Props interface with JSDoc documentation
  • Default values using nullish coalescing (??)
  • Exposing created resources as public properties for reference

Composition Over Inheritance

The CDK strongly favors composition over inheritance. Custom constructs should extend the base Construct class and compose other constructs within, rather than extending specific resource classes.

Do this:

// Composition - preferred pattern
export class SecureBucket extends Construct {
  public readonly bucket: s3.Bucket;

  constructor(scope: Construct, id: string) {
    super(scope, id);

    this.bucket = new s3.Bucket(this, 'Bucket', {
      encryption: s3.BucketEncryption.S3_MANAGED,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      enforceSSL: true
    });
  }
}

Avoid this:

// Inheritance - not recommended
export class SecureBucket extends s3.Bucket {
  constructor(scope: Construct, id: string, props?: s3.BucketProps) {
    super(scope, id, {
      encryption: s3.BucketEncryption.S3_MANAGED,
      ...props // User could override security settings
    });
  }
}

Composition gives you full control over what's configurable and ensures your security defaults can't be accidentally overridden.

Complete Custom Construct Example

Here's a practical example of a construct that creates a Lambda function with associated resources:

import { Construct } from 'constructs';
import * as cdk from 'aws-cdk-lib';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as iam from 'aws-cdk-lib/aws-iam';

export interface ProcessorFunctionProps {
  readonly handler: string;
  readonly code: lambda.Code;
  readonly environment?: Record<string, string>;
  readonly timeout?: cdk.Duration;
  readonly memorySize?: number;
}

export class ProcessorFunction extends Construct {
  public readonly function: lambda.Function;
  public readonly logGroup: logs.LogGroup;

  constructor(scope: Construct, id: string, props: ProcessorFunctionProps) {
    super(scope, id);

    // Create log group with retention
    this.logGroup = new logs.LogGroup(this, 'LogGroup', {
      retention: logs.RetentionDays.TWO_WEEKS,
      removalPolicy: cdk.RemovalPolicy.DESTROY
    });

    // Create Lambda function
    this.function = new lambda.Function(this, 'Function', {
      runtime: lambda.Runtime.NODEJS_20_X,
      handler: props.handler,
      code: props.code,
      timeout: props.timeout ?? cdk.Duration.seconds(30),
      memorySize: props.memorySize ?? 256,
      environment: props.environment,
      logGroup: this.logGroup
    });
  }

  /**
   * Grant read permissions to another construct
   */
  public grantInvoke(grantee: iam.IGrantable): iam.Grant {
    return this.function.grantInvoke(grantee);
  }
}

This example shows how to expose helper methods (like grantInvoke) that proxy to internal resources, making your construct feel native to the CDK ecosystem.

For more complex scenarios involving IAM roles, see how to assign a custom role to a Lambda function.

Working with Constructs: Methods and Attributes

After instantiating a construct, you interact with it through methods and properties. The AWS Construct Library follows consistent patterns that make this intuitive.

Grant Methods for IAM Permissions

Most L2 constructs provide grant methods that automatically create the correct IAM permissions. This is one of the biggest productivity gains from using L2 over L1 constructs.

import * as s3 from 'aws-cdk-lib/aws-s3';
import * as lambda from 'aws-cdk-lib/aws-lambda';

const bucket = new s3.Bucket(this, 'DataBucket');
const processor = new lambda.Function(this, 'Processor', { /* ... */ });

// Grant read permissions - CDK generates the correct IAM policy
bucket.grantRead(processor);

// If the bucket uses KMS encryption, CDK automatically grants
// the necessary KMS decrypt permissions too!

Common grant methods include:

  • grantRead() - Read-only access
  • grantWrite() - Write access
  • grantReadWrite() - Full access
  • grantPut(), grantDelete() - Specific operation access
  • Service-specific methods like grantSendMessages() for SQS

The grant methods implement least-privilege permissions based on intent, saving you from manually crafting IAM policies.

Resource Attributes and Cross-References

Constructs expose attributes that you can reference from other constructs. This is how you connect resources together:

import * as sqs from 'aws-cdk-lib/aws-sqs';
import * as lambda from 'aws-cdk-lib/aws-lambda';

const queue = new sqs.Queue(this, 'JobQueue');

const processor = new lambda.Function(this, 'Processor', {
  // ... other config
  environment: {
    QUEUE_URL: queue.queueUrl,     // Reference the queue URL
    QUEUE_ARN: queue.queueArn      // Reference the ARN
  }
});

queue.grantSendMessages(processor);

Common attributes include:

  • ARNs: bucket.bucketArn, function.functionArn
  • Names: bucket.bucketName, table.tableName
  • URLs: queue.queueUrl, api.url
  • Service-specific: distribution.distributionDomainName

When you reference an attribute from a construct in a different stack, CDK automatically creates the CloudFormation exports and imports needed for cross-stack references. Learn more about this in how to share resources across stacks.

The Construct Ecosystem

Beyond the constructs you create yourself, there's a rich ecosystem of pre-built constructs available.

AWS Construct Library

The AWS Construct Library is included in the aws-cdk-lib package and contains constructs developed and maintained by AWS. It's organized into modules for each AWS service:

  • aws-cdk-lib/aws-s3 - S3 buckets and related resources
  • aws-cdk-lib/aws-lambda - Lambda functions, layers, and event sources
  • aws-cdk-lib/aws-ecs - ECS clusters, services, and tasks
  • aws-cdk-lib/aws-ec2 - VPCs, subnets, security groups

This library is your primary source for L2 and L3 constructs. The AWS team continuously adds new constructs and improves existing ones.

Construct Hub: Discover and Share

Construct Hub is an online registry where you can discover constructs from AWS, third parties, and the open-source community. You can browse by:

  • CDK version compatibility
  • Download statistics
  • AWS service categories
  • Publisher verification status

It's also where you can publish your own constructs to share with the community.

Third-Party Constructs: Cautions and Best Practices

The open nature of the construct ecosystem means anyone can publish constructs. While this enables innovation, it also requires caution:

Before using third-party constructs:

  1. Review the source code - Understand what resources are created
  2. Verify the publisher - Check if they're a trusted source
  3. Pin exact versions - Prevent supply chain attacks from malicious updates
  4. Test in isolation - Deploy to sandbox environments first
  5. Check maintenance status - Abandoned constructs become security liabilities

AWS Solutions Constructs is a notable third-party library that provides vetted, well-architected patterns combining multiple AWS services. These are built using AWS best practices and reviewed by AWS architects.

Best Practices for Constructs

Following these practices will help you build maintainable, scalable CDK applications. For production-ready patterns including testing, security, and CI/CD, see our complete CDK best practices guide.

Model with Constructs, Deploy with Stacks

This is the most important principle for CDK architecture:

  • Constructs represent logical units of your application (API, database, processing pipeline)
  • Stacks represent deployment boundaries (what gets deployed together)

Don't create one stack per resource or one massive stack for everything. Instead, compose your application from constructs, then organize those constructs into stacks based on deployment requirements.

// Good: Logical units as constructs
class WebsiteConstruct extends Construct {
  // S3, CloudFront, Route53 - all related to the website
}

class ApiConstruct extends Construct {
  // API Gateway, Lambda, DynamoDB - all related to the API
}

// Stacks for deployment organization
class ProductionStack extends Stack {
  constructor(scope: Construct, id: string) {
    super(scope, id);
    new WebsiteConstruct(this, 'Website');
    new ApiConstruct(this, 'Api');
  }
}

For guidance on organizing larger projects, see how to optimize your AWS CDK project structure.

If you want to see these patterns in action with production-ready defaults, check out the AWS CDK Starter Kit. It demonstrates how to organize constructs, structure stacks for multi-environment deployments, and includes secure CI/CD with GitHub Actions.

Infrastructure and Runtime Code Together

The CDK bundles runtime assets (Lambda code, Docker images) alongside infrastructure definitions. Use this to your advantage by keeping infrastructure and runtime code in the same constructs:

export class DataProcessor extends Construct {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    // Infrastructure and runtime code together
    new lambda.Function(this, 'Processor', {
      runtime: lambda.Runtime.NODEJS_20_X,
      handler: 'index.handler',
      code: lambda.Code.fromAsset('lambda/processor'), // Runtime code
    });
  }
}

This approach enables:

  • Testing infrastructure and logic together
  • Versioning both in sync
  • Sharing complete functionality as a single unit
  • Easier reasoning about what a construct does

Escape Hatches: When L2 Isn't Enough

Sometimes L2 constructs don't expose the property you need, or a new CloudFormation feature isn't available yet. The CDK provides escape hatches for these situations.

Accessing the L1 Construct

Every L2 construct provides access to its underlying L1 construct through a property like node.defaultChild:

import * as s3 from 'aws-cdk-lib/aws-s3';

const bucket = new s3.Bucket(this, 'MyBucket');

// Access the underlying L1 CfnBucket
const cfnBucket = bucket.node.defaultChild as s3.CfnBucket;

// Modify properties not exposed by L2
cfnBucket.addPropertyOverride('AnalyticsConfigurations', [{
  Id: 'EntireBucket',
  StorageClassAnalysis: {
    DataExport: {
      Destination: {
        BucketArn: analyticsBucket.bucketArn,
        Format: 'CSV'
      }
    }
  }
}]);

Going Back Up: Un-Escape Hatches

You can also convert an L1 construct to an L2 construct using static methods like .fromCfnXxxxx():

import * as s3 from 'aws-cdk-lib/aws-s3';

// Start with L1 for full control
const cfnBucket = new s3.CfnBucket(this, 'MyBucket', {
  bucketName: 'my-specific-bucket-name'
});

// Convert to L2 to use convenience methods
const bucket = s3.Bucket.fromCfnBucket(cfnBucket);

// Now you can use grant methods
bucket.grantRead(myLambdaFunction);

This is useful when you need L1 control for some properties but still want access to L2 convenience methods.

Frequently Asked Questions

What is the difference between a construct and a stack?
A construct is a cloud component that represents one or more AWS resources. A stack is a special construct that represents a CloudFormation stack - the unit of deployment. All resource constructs must live within a stack, but constructs themselves are reusable building blocks that can be composed and shared across stacks.
When should I use L1 vs L2 vs L3 constructs?
Use L2 constructs as your default choice - they provide sensible defaults and convenience methods. Use L1 when you need access to properties not yet exposed by L2, or when migrating from CloudFormation. Use L3 patterns when deploying complete architectural patterns like load-balanced Fargate services.
Can I mix different construct levels in the same stack?
Yes, you can freely mix L1, L2, and L3 constructs in the same stack. This is common when you need L2 convenience for most resources but L1 control for specific configurations. You can even use escape hatches to access L1 properties from L2 constructs.
How do I create a reusable construct?
Create a class that extends the Construct base class, define a props interface for configuration options, and compose other constructs within your constructor. Use composition over inheritance - don't extend specific resource classes like s3.Bucket. Expose created resources as public properties for reference by other constructs.
What happens if I change a construct's ID?
Changing a construct's ID changes its CloudFormation logical ID, which CloudFormation interprets as deleting the old resource and creating a new one. For stateful resources like databases or S3 buckets with data, this causes data loss. Always be careful when renaming constructs or moving them in the tree.
How do constructs relate to CloudFormation?
Every CDK application synthesizes into CloudFormation templates. When you run 'cdk synth', the CDK traverses your construct tree and generates the corresponding CloudFormation resources. Constructs are an abstraction layer that gives you programming language features while maintaining CloudFormation's deployment reliability.

Wrapping Up

Constructs are the foundation of everything you build with the AWS CDK. Understanding the three levels (L1, L2, L3), how they form a hierarchy, and how to create your own opens up powerful patterns for infrastructure development.

Start with L2 constructs for most work, use L3 patterns for common architectures, and drop to L1 only when you need complete control. As your CDK applications grow, invest in creating custom constructs that encapsulate your organization's patterns and standards.

The skills you develop working with constructs will serve you well, whether you're building AWS infrastructure, exploring CDK for Terraform, or contributing to the open-source construct ecosystem.

What patterns are you building with constructs? I'd love to hear about your CDK architecture in the comments below.

Build Scalable CDK Apps That Are Easy to Maintain

Transform your complex CDK codebase into a structured, reusable architecture. Get real-world expertise from someone who's built production CDK at scale.

Share this article on ↓

Subscribe to our Newsletter

Join ---- other subscribers!