AWS DevOps & Developer Productivity Blog
Leverage L2 constructs to reduce the complexity of your AWS CDK application
The AWS Cloud Development Kit (AWS CDK) is an open-source software development framework to define your cloud application resources using familiar programming languages. AWS CDK uses the familiarity and expressive power of programming languages for modeling your applications. Constructs are the basic building blocks of AWS CDK apps. A construct represents a “cloud component” and encapsulates everything that AWS CloudFormation needs to create the component. Furthermore, AWS Construct Library lets you ease the process of building your application using predefined templates and logic. Three levels of constructs exist:
- L1 – These are low-level constructs called Cfn (short for CloudFormation) resources. They’re periodically generated from the AWS CloudFormation Resource Specification. The name pattern is CfnXyz, where Xyz is name of the resource. When using these constructs, you must configure all of the resource properties. This requires a full understanding of the underlying CloudFormation resource model and its corresponding attributes.
- L2 – These represent AWS resources with a higher-level, intent-based API. They provide additional functionality with defaults, boilerplate, and glue logic that you’d be writing yourself with L1 constructs. AWS constructs offer convenient defaults and reduce the need to know all of the details about the AWS resources that they represent. This is done while providing convenience methods that make it simpler to work with the resources and as a result creating your application.
- L3 – These constructs are called patterns. They’re designed to complete common tasks in AWS, often involving multiple types of resources.
In this post, I show a sample architecture and how the complexity of an AWS CDK application is reduced by using L2 constructs.
Overview of the sample architecture
This solution uses Amazon API Gateway, AWS Lambda, and Amazon DynamoDB. I implement a simple serverless web application. The application receives a POST request from a user via API Gateway and forwards it to a Lambda function using proxy integration. The Lambda function writes the request body to a DynamoDB table.
The sample code can be found on GitHub.
Walkthrough
You can follow the instructions in the README file of the GitHub repository to deploy the stack. In the following walkthrough, I explain each logical unit and the differences when implementing it using L1 and L2 constructs. Before each code sample, I’ll show the path in the GitHub repository where you can find its source.
Create the DynamoDB table
First, I create a DynamoDB table to store the request content.
L1 construct
With L1 constructs, I must define each attribute of a table separately. For the DynamoDB table, these are keySchema
, attributeDefinitions
, and provisionedThroughput
. They all require detailed CloudFormation
knowledge, for example, how a keyType
is defined.
lib/level1/database/infrastructure.ts
L2 construct
The corresponding L2 construct lets me use the default values for readCapacity (5) and writeCapacity (5). To further reduce the complexity, I define the attributes and the partition key simultaneously. In addition, I utilize the dynamodb.AttributeType.STRING enum.
lib/level2/database/infrastructure.ts
Create the Lambda function
Next, I create a Lambda function which receives the request and stores the content in the DynamoDB table. The runtime code uses Node.js.
L1 construct
When creating a Lambda function using L1 construct, I must specify all of the properties at creation time – the business logic code location, runtime, and the function handler. This includes the role for the Lambda function to assume. As a result, I must provide the Attribute Resource Name (ARN) of the role. In the “Granting permissions” sections later in this post, I show how to create this role.
lib/level1/api/infrastructure.ts
L2 construct
I can achieve the same result with less complexity by leveraging the NodejsFunction L2 construct for Lambda function. It sets a default version for Node.js runtime unless another one is explicitly specified. The construct creates a Lambda function with automatic transpiling and bundling of TypeScript or Javascript code. This results in smaller Lambda packages that contain only the code and dependencies needed to run the function, and it uses esbuild under the hood. The Lambda function handler code is located in the runtime
directory of the API logical unit. I provide the path to the Lambda handler file in the entry
property. I don’t have to specify the handler function name, because the NodejsFunction
construct uses the handler name by default. Moreover, a Lambda execution role isn’t required to be provided during L2 Lambda construct creation. If no role is specified, then a default one is generated which has permissions for Lambda execution. In the section ‘Granting Permissions’, I describe how to customize the role after creating the construct.
lib/level2/api/infrastructure.ts
Create API Gateway REST API
Next, I define the API Gateway REST API to receive POST requests with Cross-origin resource sharing (CORS) enabled.
L1 construct
Every step, from creating a new API Gateway REST API, to the deployment process, must be configured individually. With an L1 construct, I must have a good understanding of CORS and the exact configuration of headers and methods.
Furthermore, I must know all of the specifics, such as for the Lambda integration type I must know how to construct the URI.
lib/level1/api/infrastructure.ts
L2 construct
Creating an API Gateway REST API with CORS enabled is simpler with L2 constructs. I can leverage the defaultCorsPreflightOptions
property and the construct builds the required options method. To set origins and methods, I can use the apigateway.Cors
enum. To configure the Lambda proxy option, all I need to do is to set the proxy variable in the method to true
. A default deployment is created automatically.
lib/level2/api/infrastructure.ts
Granting permissions
In the sample application, I must give permissions to two different resources:
- API Gateway REST API to invoke the Lambda function.
- Lambda function to write data to the DynamoDB table.
L1 construct
For both resources, I must define AWS Identity and Access Management (IAM) roles. This requires in-depth knowledge of IAM, how policies are structured, and which actions are required. In the following code snippet, I start by creating the policy documents. Afterward, I create a role for each resource. These are provided at creation time to the corresponding constructs as shown earlier.
lib/level1/api/infrastructure.ts
The database construct exposes a function to grant write access to any IAM role. The function creates a policy, which allows dynamodb:PutItem
on the database table and adds it as an additional policy to the role.
lib/level1/database/infrastructure.ts
At this point, all permissions are in place, except that Lambda function doesn’t have permissions to write data to the DynamoDB table yet. To grant write access, I call the grantWriteData
function of the Database
construct with the IAM role of the Lambda function.
lib/deployment.ts
L2 construct
Creating an API Gateway REST API with the LambdaIntegration
construct generates the IAM role and attaches the role to the API Gateway REST API method. Giving the Lambda function permission to write to the DynamoDB table can be achieved with the following single line:
lib/deployment.ts
Using L3 constructs
To reduce complexity even further, I can leverage L3 constructs. In the case of this sample architecture, I can utilize the LambdaRestApi
construct. This construct uses a default Lambda proxy integration. It automatically generates a method and a deployment, and grants permissions. As a result, I can achieve the same with even less code.
Cleanup
Many services in this post are available in the AWS Free Tier. However, using this solution may incur costs, and you should tear down the stack if you don’t need it anymore. Cleanup steps are included in the RADME file of the GitHub repository.
Conclusion
In this post, I highlight the difference between using L1 and L2 AWS CDK constructs with an example architecture. Leveraging L2 constructs reduces the complexity of your application by using predefined patterns, boiler plate, and glue logic. They offer convenient defaults and reduce the need to know all of the details about the AWS resources they represent, while providing convenient methods that make it simpler to work with the resource. Additionally, I showed how to reduce complexity for common tasks even further by using an L3 construct.
Visit the AWS CDK documentation to learn more about building resilient, scalable, and cost-efficient architectures with the expressive power of a programming language.
About the author: