Layer 2 constructs - AWS Prescriptive Guidance

Layer 2 constructs

The AWS CDK open source repository is written primarily by using the TypeScript programming language and consists of numerous packages and modules. The main package library, called aws-cdk-lib, is roughly divided into one package per AWS service, although this is not always the case. As previously discussed, the L1 constructs are automatically generated during the build process, so what is all that code you see when you look inside the repository? Those are L2 constructs, which are abstractions of L1 constructs.

The packages also contain a collection of TypeScript types, enums, and interfaces as well as helper classes that add more functionality, but those items all serve L2 constructs. All L2 constructs call their corresponding L1 constructs in their constructors upon instantiation, and the resulting L1 construct that is created can be accessed from layer 2 like this:

const role = new Bucket(this, "my-bucket", {/*...BucketProps*/}); const cfnBucket = role.node.defaultChild;

The L2 construct takes the default properties, convenience methods, and other syntactic sugar and applies them to the L1 construct. This takes away much of the repetition and verbosity that is necessary to provision resources directly in CloudFormation.

All L2 constructs build their corresponding L1 constructs under the hood. However, L2 constructs don't actually extend L1 constructs. Both L1 and L2 constructs inherit a special class called Construct. In version 1 of the AWS CDK the Construct class was built into the development kit, but in version 2 it's a separate standalone package. This is so other packages such as the Cloud Development Kit for Terraform (CDKTF) can include it as a dependency. Any class that inherits the Construct class is an L1, L2, or an L3 construct. L2 constructs extend this class directly whereas L1 constructs extend a class called CfnResource, as shown in the following table.

L1 inheritance tree

L2 inheritance tree

L1 construct

→ class CfnResource

→→ abstract class CfnRefElement

→→→ abstract class CfnElement

→→→→ class Construct

L2 construct

→ class Construct

If both L1 and L2 constructs inherit the Construct class, why don't L2 constructs just extend L1? Well, the classes between the Construct class and layer 1 lock the L1 construct in place as a mirror image of the CloudFormation resource. They contain abstract methods (methods that downstream classes must include) like _toCloudFormation, which forces the construct to directly output CloudFormation syntax. L2 constructs skip over those classes and extend the Construct class directly. This gives them the flexibility to abstract much of the code needed for L1 constructs by building them separately within their constructors.

The previous section featured a side-by-side comparison of an S3 bucket from a CloudFormation template and that same S3 bucket rendered as an L1 construct. That comparison showed that the properties and syntax are nearly identical, and the L1 construct saves only three or four lines compared with the CloudFormation construct. Now let's compare the L1 construct with the L2 construct for the same S3 bucket:

L1 construct for S3 bucket

L2 construct for S3 bucket

new CfnBucket(this, "myS3Bucket", { bucketName: "my-s3-bucket", bucketEncryption: { serverSideEncryptionConfiguration: [ { serverSideEncryptionByDefault: { sseAlgorithm: "AES256" } } ] }, metricsConfigurations: [ { id: "myConfig" } ], ownershipControls: { rules: [ { objectOwnership: "BucketOwnerPreferred" } ] }, publicAccessBlockConfiguration: { blockPublicAcls: true, blockPublicPolicy: true, ignorePublicAcls: true, restrictPublicBuckets: true }, versioningConfiguration: { status: "Enabled" } });
new Bucket(this, "myS3Bucket", { bucketName: "my-s3-bucket", encryption: BucketEncryption.S3_MANAGED, metrics: [ { id: "myConfig" }, ], objectOwnership: ObjectOwnership.BUCKET_OWNER_PREFERRED, blockPublicAccess: BlockPublicAccess.BLOCK_ALL, versioned: true });

As you can see, the L2 construct is less than half the size of the L1 construct. L2 constructs use numerous techniques to accomplish this consolidation. Some of these techniques apply to a single L2 construct, but others can be reused across multiple constructs so they are separated into their own class for reusability. L2 constructs consolidate CloudFormation syntax in several ways, as discussed in the following sections.

Default properties

The simplest way to consolidate the code for provisioning a resource is to turn the most common property settings into defaults. The AWS CDK has access to powerful programming languages and CloudFormation doesn't, so these defaults are often conditional in nature. Sometimes several lines of CloudFormation configuration can be eliminated from the AWS CDK code because those settings can be inferred from the values of other properties that are passed to the construct.

Structs, types, and interfaces

Although the AWS CDK is available in several programming languages, it is written natively in TypeScript, so that language's type system is used to define the types that make up L2 constructs. Diving deep into that type system is beyond the scope of this guide; see the TypeScript documentation for details. To summarize, a TypeScript type describes what kind of data a particular variable holds. This could be basic data such as a string, or more complex data such as an object. A TypeScript interface is another way of expressing the TypeScript object type, and a struct is another name for an interface.

TypeScript doesn't use the term struct, but if you look in the AWS CDK API Reference, you'll see that a struct is actually just another TypeScript interface within the code. The API Reference also refers to certain interfaces as interfaces, too. If structs and interfaces are the same thing, why does the AWS CDK documentation make a distinction between them?

What the AWS CDK refers to as structs are interfaces that represent any object used by an L2 construct. This includes the object types for the property arguments that are passed to the L2 construct during instantiation, such as BucketProps for the S3 Bucket construct and TableProps for the DynamoDB Table construct, as well as other TypeScript interfaces that are used within the AWS CDK. In short, if it's a TypeScript interface within the AWS CDK and its name isn't prefixed by the letter I, the AWS CDK calls it a struct.

Conversely, the AWS CDK uses the term interface to represent the base elements a plain object would need to be considered a proper representation of a particular construct or helper class. That is, an interface describes what an L2 construct's public properties must be. All AWS CDK interface names are the names of existing constructs or helper classes prefixed by the letter I. All L2 constructs extend the Construct class, but they also implement their corresponding interface. So the L2 construct Bucket implements the IBucket interface.

Static methods

Every instance of an L2 construct is also an instance of its corresponding interface, but the reverse isn't true. This is important when looking through a struct to see which data types are required. If a struct has a property called bucket, which requires the data type IBucket, you could pass either an object that contains the properties listed in the IBucket interface or an instance of an L2 Bucket. Either one would work. However, if that bucket property called for an L2 Bucket, you could pass only a Bucket instance in that field.

This distinction becomes very important when you import pre-existing resources into your stack. You can create an L2 construct for any resource that's native to your stack, but if you need to reference a resource that was created outside the stack, you have to use that L2 construct's interface. That's because creating an L2 construct creates a new resource if one doesn't already exist within that stack. References to existing resources must be plain objects that conform to that L2 construct's interface.

To make this easier in practice, most L2 constructs have a set of static methods associated with them that return that L2 construct's interface. These static methods usually start with the word from. The first two arguments passed to these methods are the same scope and id arguments required for a standard L2 construct. However, the third argument isn't props but a small subset of properties (or sometimes just one property) that defines an interface. For this reason, when you pass an L2 construct, in most cases only the elements of the interface are required. This is so you can use imported resources as well, where possible.

// Example of referencing an external S3 bucket const preExistingBucket = Bucket.fromBucketName(this, "external-bucket", "name-of-bucket-that-already-exists");

However, you shouldn't rely heavily on interfaces. You should import resources and use interfaces directly only when absolutely necessary, because interfaces don't provide many of the properties—such as helper methods—that make an L2 construct so powerful.

Helper methods

An L2 construct is a programmatic class rather than a simple object, so it can expose class methods that allow you to manipulate your resource configuration after instantiation has taken place. A good example of this is the AWS Identity and Access Management (IAM) L2 Role construct. The following snippets show two ways to create the same IAM role by using the L2 Role construct.

Without a helper method:

const role = new Role(this, "my-iam-role", { assumedBy: new FederatedPrincipal('my-identity-provider.com'), managedPolicies: [ ManagedPolicy.fromAwsManagedPolicyName("ReadOnlyAccess") ], inlinePolicies: { lambdaPolicy: new PolicyDocument({ statements: [ new PolicyStatement({ effect: Effect.ALLOW, actions: [ 'lambda:UpdateFunctionCode' ], resources: [ 'arn:aws:lambda:us-east-1:123456789012:function:my-function' ] }) ] }) } });

With a helper method:

const role = new Role(this, "my-iam-role", { assumedBy: new FederatedPrincipal('my-identity-provider.com') }); role.addManagedPolicy(ManagedPolicy.fromAwsManagedPolicyName("ReadOnlyAccess")); role.attachInlinePolicy(new Policy(this, "lambda-policy", { policyName: "lambdaPolicy", statements: [ new PolicyStatement({ effect: Effect.ALLOW, actions: [ 'lambda:UpdateFunctionCode' ], resources: [ 'arn:aws:lambda:us-east-1:123456789012:function:my-function' ] }) ] }));

The ability to use instance methods to manipulate resource configuration after instantiation gives L2 constructs a lot of additional flexibility over the previous layer. L1 constructs also inherit some resource methods (such as addPropertyOverride), but it's not until layer two that you get methods that are specifically designed for that resource and its properties.

Enums

CloudFormation syntax often requires you to specify many details in order to provision a resource properly. However, the majority of use cases are often covered by only a handful of configurations. Representing those configurations by using a series of enumerated values can vastly reduce the amount of code needed.

For example, in the S3 bucket L2 code example from earlier in this section, you have to use the CloudFormation template's bucketEncryption property to provide all the details, including the name of the encryption algorithm to be used. Instead, the AWS CDK provides the BucketEncryption enum, which takes the five most common forms of bucket encryption and lets you express each by using single variable names.

What about the edge cases that aren't covered by the enums? One of the goals of an L2 construct is to simplify the task of provisioning a layer 1 resource, so certain edge cases that are less commonly used might not be supported in layer 2. To support these edge cases, the AWS CDK lets you manipulate the underlying CloudFormation resource properties directly by using the addPropertyOverride method. For more about property overrides, see the Best practices section of this guide and the section Abstractions and escape hatches in the AWS CDK documentation.

Helper classes

Sometimes an enum can't accomplish the programmatic logic needed to configure a resource for a given use case. In these situations, the AWS CDK often offers a helper class instead. An enum is a simple object that offers a series of key-value pairs, whereas a helper class offers the full capabilities of a TypeScript class. A helper class can still act like an enum by exposing static properties, but those properties could then have their values internally set with conditional logic in the helper class constructor or in a helper method.

So although the BucketEncryption enum can reduce the amount of code needed to set an encryption algorithm on an S3 bucket, that same strategy would not work for setting time durations because there are simply too many possible values to choose from. Creating an enum for each value would be far more trouble than it's worth. For this reason, a helper class is used for an S3 bucket's default S3 Object Lock configuration settings, as represented by the ObjectLockRetention class. ObjectLockRetention contains two static methods: one for compliance retention and the other for governance retention. Both methods take an instance of the Duration helper class as an argument to express the amount of time that the lock should be configured for.

Another example is the AWS Lambda helper class Runtime. At first glance, it might appear that the static properties associated with this class could be handled by an enum. However, under the hood, each property value represents an instance of the Runtime class itself, so the logic performed in the class's constructor could not be achieved within an enum.