What is an identifier (id) in AWS CDK - Complete Guide

avatar
Borislav Hadzhiev

7 min

banner

# Identifiers in CDK

The purpose of identifiers in CDK is to create a unique resource ID, such as the Logical Identifier of resources provisioned using CloudFormation.

cloudformation logical ids Identifiers must be unique in the scope they are created in, however, they don't have to be globally unique in the entire CDK application.

Identifiers in CDK are of 3 types:

  1. Construct IDs
  2. Paths
  3. Unique IDs (Logical IDs)

# Construct IDs

A Construct in CDK is a cloud component. Constructs encapsulate logic for creating a component that could consist of one or more resources.

Constructs allow us to write useful abstractions on top of CloudFormation and reduce some of the duplications in resource definitions.

The code for this article is available on GitHub

For example, an S3 bucket construct could look similar to the following.

lib/cdk-starter-stack.ts
import * as cdk from 'aws-cdk-lib'; import {Bucket} from 'aws-cdk-lib/aws-s3'; import {Construct} from 'constructs'; export class UploadsBucketConstruct extends Construct { public readonly s3Bucket: Bucket; constructor(scope: Construct, id: string) { super(scope, id); this.s3Bucket = new Bucket(this, id); } }
If you still use CDK version 1, switch to the cdk-v1 branch in the GitHub repository.

The first parameter we specify in the constructor function is the scope. In JavaScript, we use the this keyword to denote the scope in CDK.

The second parameter the constructor method takes is the id, this is the Construct ID.

We can use this construct in the following way:

lib/cdk-starter-stack.ts
export class CdkIdentifiersStack extends cdk.Stack { constructor(scope: cdk.App, id: string, props?: cdk.StackProps) { super(scope, id, props); const {s3Bucket: s3BucketFirst} = new UploadsBucketConstruct( this, 's3-bucket', ); } }

Notice that the Construct ID we are passing to our UploadsBucketConstruct is s3-bucket.

Before we deploy, we must also instantiate the stack in the scope of our CDK App:

bin/cdk-starter.ts
import * as cdk from 'aws-cdk-lib'; import {CdkIdentifiersStack} from '../lib/cdk-starter-stack'; const app = new cdk.App(); new CdkIdentifiersStack(app, 'cdk-identifiers-stack-dev', { stackName: 'cdk-identifiers-stack-dev', });

If I deploy the CDK stack at this point we get the following CloudFormation stack:

Single s3 bucket result

Notice the Cloudformation Logical ID is s3bucket5E7B98C4, whereas our CDK Construct ID was just s3bucket - an 8-digit hash got appended to our CDK Construct ID to form the CloudFormation Logical ID, more on that later in the article.

Let's try to provision two S3 buckets with the same Construct ID at the same scope (the scope of our CdkIdentifiersStack):

lib/cdk-starter-stack.ts
export class CdkIdentifiersStack extends cdk.Stack { constructor(scope: cdk.App, id: string, props?: cdk.StackProps) { super(scope, id, props); const {s3Bucket: s3BucketFirst} = new UploadsBucketConstruct( this, 's3-bucket', ); + const {s3Bucket: s3BucketSecond} = new UploadsBucketConstruct( + this, + 's3-bucket', + ); new cdk.CfnOutput(this, 'region', {value: cdk.Stack.of(this).region}); new cdk.CfnOutput(this, 'bucketName', { value: s3BucketFirst.bucketName, }); } }

After we try to npx aws-cdk deploy, we get an error:

There is already a Construct with the name construct-id in stack-name.

two s3 buckets same scope

At this point we know that defining two Constructs with the same Construct ID in the same scope results in an error.

However, what would happen if we were to change the Construct ID of our first bucket?

lib/cdk-starter-stack.ts
export class CdkIdentifiersStack extends cdk.Stack { constructor(scope: cdk.App, id: string, props?: cdk.StackProps) { super(scope, id, props); const {s3Bucket: s3BucketFirst} = new UploadsBucketConstruct( this, - 's3-bucket', + 'new-s3-bucket', ); new cdk.CfnOutput(this, 'region', {value: cdk.Stack.of(this).region}); new cdk.CfnOutput(this, 'bucketName', { value: s3BucketFirst.bucketName, }); } }

Let's run the cdk diff command before we deploy the changes:

two s3 buckets same scope

In the screenshot, we see that our old bucket with the logical id of s3bucket5E7B98C4 would become an orphan, and we would just create a new bucket with a new logical id of news3bucketCF1C73C0.

The old s3 bucket is now an orphan, meaning the resource itself is retained in the account, but it has been orphaned from the stack.

The reason our bucket is orphaned and not deleted is the default behavior for the Bucket construct. By default when a bucket is removed from a stack it becomes orphaned but remains in the AWS account - docs.

However, relying on default behavior and not understanding CDK identifiers is not a good approach.

Let's go through with the deployment of the bucket with the changed Construct ID and see the result in our Cloudformation stack:

updated construct id

After the deployment of the stack, we can see that the logical ID of the bucket in our stack is now news3bucketCF1C73C which is a completely different s3 bucket than the one we had before.

Since no one really changes the Construct identifiers of resources in the way we just did, let's look at a more common way to change identifiers, by simply refactoring code.

# Paths

A Path is a collection of IDs starting with the ID, used to initialize the root Stack and going down the constructs. The Path joins these IDs with / characters.

For example, in our application, the path of the s3BucketFirst resource would be cdk-identifiers-stack-dev/new-s3-bucket/new-s3-bucket.

So the path is stackName/topLevelId/nestedId. The stack name is cdk-identifiers-stack-dev, which we define, when we initialize the stack:

bin/cdk-starter.ts
const app = new cdk.App(); new CdkIdentifiersStack(app, 'cdk-identifiers-stack-dev', { stackName: 'cdk-identifiers-stack-dev', });

Then we pass the new-s3-bucket id to the UploadsBucketConstruct, which ends up passing it to the Bucket construct:

lib/cdk-starter-stack.ts
// Using the construct const {s3Bucket: s3BucketFirst} = new UploadsBucketConstruct( this, 'new-s3-bucket', ); // The construct implementation reuses // the passed in ID this.s3Bucket = new Bucket(this, id);

That's how our path ended up being cdk-identifiers-stack-dev/new-s3-bucket/new-s3-bucket.

Since we know that the identifiers must be unique at the same scope, we also know that the path, which is the combination of the identifiers, starting at the root stack will also be unique.

The way to get a path of a construct in CDK is by accessing the path property on the construct node:

lib/cdk-starter-stack.ts
const {s3Bucket: s3BucketFirst} = new UploadsBucketConstruct( this, 'new-s3-bucket', ); const s3BucketPath = s3BucketFirst.node.path; console.log('path is: ', s3BucketPath);

The stack name, in our case cdk-identifiers-stack-dev doesn't add any uniqueness for resources in the same stack so it isn't used to form the CloudFormation logical ID.

In fact, if we deploy a second stack named cdk-identifiers-stack-prod the Cloudformation logical id of the bucket would be the exact same as in our cdk-identifiers-stack-dev deployment:

prod stack

Notice how the path has the construct identifier new-s3-bucket twice. Once for our own UploadsBucketConstruct and once for the s3.bucket construct. However, if we look at the logical id in CloudFormation we only have news3bucket once and then an 8-character hash.

The reason is in the implementation of the allocateLogicalId function in the cdk code.

If the last components of the path are the same (i.e. L1/L2/Pipeline/Pipeline), they will be de-duplicated to make the resulting portion of the ID more readable: L1L2PipelineHASH

Since we passed in the same construct identifier to our own and the AWS Bucket constructs, the last components of the path were the same and they were de-duplicated, resulting in a more human-readable Logical ID in CloudFormation.

Now that we know how the Path is formed - starting at the root stack and going down the construct scopes, it is very important to note that refactoring your code and extracting duplicated logic into a construct might alter your path and end up altering your CloudFormation Logical IDs, which results in deleting resources and replacing them with new ones with the new logical ID.

# Unique IDs

CloudFormation only allows for identifiers that are alphanumeric - [a-zA-Z0-9], which means the Logical IDs in CloudFormation can't contain / characters.

However, as we saw our paths contain slash characters, i.e. cdk-identifiers-stack-dev/new-s3-bucket/new-s3-bucket.

If we were to just join on the slash characters of the path to form the unique IDs, we could have collisions if we named our constructs like:

  1. new-s3-bucket/new-s3-bucket
  2. new-s3-/bucketnew-s3-bucket

Just joining the components on the / character to make our identifiers conform to CloudFormation's requirement of only alphanumeric characters would not prove uniqueness.

There's an unlikely scenario that the combination of ids could still clash.

The solution the CDK team implemented is to append an 8-digit hash to the components from the path in order to create a unique identifier. So that's where the 5E7B98C4 part comes from in the appendix of the S3 bucket's Logical ID:

with hash

The unique IDs are used as Cloudformation Logical IDs.

# Identifiers in CDK - Discussion

Since CDK gets compiled into CloudFormation, a good starting point is to look at the result of a CDK deployment:

cloudformation logical ids

In the first column of the screenshot, we can see the Logical ID of the resources.

We can specify a resource's Logical ID in Cloudformation like so:

template.yml
Resources: MyLogicalId: Type: AWS::DynamoDB::Table Properties: # ... properties of the table
Changing the Logical ID of a resource is the same as deleting the resource and replacing it with a new one that has the new Logical ID. Any of the other resources that depend on the resource whose Logical ID you changed will also have to be updated and potentially replaced.

This means that if we were to rename MyLogicalId to NewLogicalId, our DynamoDB table would get deleted and a new table would get created, only without all the records we had stored up to that point.

template.yml
Resources: NewLogicalId: Type: AWS::DynamoDB::Table Properties: # ... properties of the table

If we change the ID of a Lambda function or an IAM Role, we might get some interruption, but it wouldn't be as bad as changing the logical ID of a data store like S3, Dynamodb, RDS, etc and potentially losing data (if we don't have a prevention mechanism in place).

# Conclusion

The most important thing to note about IDs in CDK is that simply refactoring your code and extracting logic into a Construct can end up altering the resource's path and logical ID.

If you change a resource's Logical ID, the resource gets deleted and a new resource with the new Logical ID gets created, which is especially important to consider when dealing with stateful components such as databases.

# Additional Resources

You can learn more about the related topics by checking out the following tutorials:

I wrote a book in which I share everything I know about how to become a better, more efficient programmer.
book cover
You can use the search field on my Home Page to filter through all of my articles.