Applied Standards through CDK L3 Constructs

Jason Conway-Williams
10 min readFeb 7, 2023
Photo by Call Me Fred on Unsplash

Infrastructure as Code (IAC) provides automated provisioning, deployment, configuration, orchestration and management of resources through code rather than manual process.

Many on-premise and cloud agnostic IAC frameworks have been prevalent and popular for many years such as Puppet, Chef, Terraform and Pulumi. Companies that have fully adopted the cloud and have gone all in with a single cloud provider such as Amazon Web Services (AWS) tend to opt for IAC tools provided by the cloud provider or have been specifically designed for that cloud provider. AWS provides the declarative CloudFormation IAC tool which allows resources to be defined as text within a JSON or YAML template file. CloudFormation can be used directly or through the Serverless Application Model (SAM) or The Cloud Development Kit which enable users to declare resources which later gets converted to CloudFormation at build/deploy time.

The AWS Cloud Development Kit (AWS CDK) is an open-source software development framework for defining cloud infrastructure as code with modern programming languages and deploying it through AWS CloudFormation.

The CDK allows the use of one of six popular programming languages providing developers with the option of writing their IAC in the same programming language as their services.

AWS CDK is generally available in JavaScript, TypeScript, Python, Java, C#, and Go (in Developer Preview). We are planning AWS CDK bindings for other languages in the future…

Implementing IAC as code also allows teams to add quality gates to their automation pipelines to test their IAC code pre deployment as well as ensure quality standards are met.

There are a number of benefits to using IAC over manually provisioning and managing resources. The most common being:

  • Process Automation — Automating the provisioning and management of resources reduces risk, removing risk that is usually present through human error. Automation can also speeds up the deployment process.
  • Repeatability — Once defined, a resource definition can be reused by other teams and team members reducing the time to create new resources.
  • Scaling — It is easier to scale resources up and down when defined as IAC providing time and cost benefits.
  • Declarative — All team members are trained to use the same declarative language and technique to create resources. This not only makes it easier to manage and change internal team resources but also hire new developers.
  • Collaboration — Teams using IAC can share previously created declarations and collaborate.

One benefit with IAC that is often overlooked by companies and teams is the ability to implement default configuration and standards ensuring that all teams are compliant with company policies as well as speeding up the development and deployment process.

The CDK provides a number of constructs that encapsulate everything required by CloudFormation to create a resource. These constructs are categorised by level where level 1 (L1) constructs can be viewed as direct references to CloudFormation resources where all properties are required. Level 2 (L2) constructs provide the same functionality as level 1 constructs with default configuration allowing developers to provide as little configuration as possible, speeding up the development process. Level 3 (L3) constructs are regarded as “patterns” where architectural patterns are implemented by encapsulating more than one resource and/or specific configuration within a construct. Further information about the AWS Construct levels can be found on the constructs guide page.

A nice feature of the CDK is the ability to create L3 constructs by extending an L2 construct. This feature allows teams to create their own architectural patterns and/or declare their own implementation of a resource and further abstract configuration. Through this functionality, teams can create their own L3 constructs that ensure company standards are implemented automatically ensuring that all deployed resources/services are compliant. Another benefit is development and deployment should be faster since configuration is abstracted further. Below demonstrates a couple of L3 construct that implement default, company/team specific configuration for AWS Lambda and AWS API Gateway. The code referenced below can be found on GitHub in a demo project that accompanies this blog post.

AWS Lambda L3 Construct

The below code snippet demonstrates a possible configuration that a company could define as default lambda configuration and could specify that all company lambdas should apply the below logic at all times. It would then make sense that since this configuration would be applied to all Lambda services, it would be inefficient for developers to have to define it every time they create a new Lambda.

let defaultEnvironment = {
LOG_LEVEL: LogLevel.DEBUG,
POWERTOOLS_LOGGER_LOG_EVENT: "true",
POWERTOOLS_LOGGER_SAMPLE_RATE: "1",
POWERTOOLS_TRACE_ENABLED: "enabled",
POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS: "captureHTTPsRequests",
POWERTOOLS_TRACER_CAPTURE_RESPONSE: "captureResult",
NODE_OPTIONS: "",
};

if (deploymentUtils.isTestEnvironment()) {
defaultEnvironment.NODE_OPTIONS = "--enable-source-maps";
}

/**
* A set of fixed properties to be applied to a Lambda Function
* that cannot be overridden.
* @type { NodejsFunctionProps }
*/
const fixedProps: NodejsFunctionProps = {
runtime: lambda.Runtime.NODEJS_16_X,
architecture: Architecture.ARM_64,
awsSdkConnectionReuse: true,
layers: [],
tracing: deploymentUtils.isTestEnvironment()
? Tracing.ACTIVE
: Tracing.PASS_THROUGH,
environment: defaultEnvironment,
currentVersionOptions: {
removalPolicy: RemovalPolicy.RETAIN,
description: `Version deployed on ${new Date().toISOString()}`,
},
bundling: {
minify: true,
sourceMap: deploymentUtils.isTestEnvironment(),
sourceMapMode: SourceMapMode.INLINE,
sourcesContent: false,
target: "node16",
externalModules: ["aws-sdk"],
},
};

Specifying that all company lambda’s implement the above configuration would mean the following:

  • All Lambdas should use the Node.js 16 runtime with the ARM_64 architecture.
  • All Lambda services should be setup to reuse SDK connections.
  • Tracing should be set as ACTIVE if the service is being deployed to a test environment, set as PASS_THROUGH otherwise.
  • A default set of environment variables should be provided with the Lambda that; set the log level to DEBUG in all environments, enable source maps for test environments, enable tracing via power tools and provide a number of environment variables required by Lambda power tools.
  • Version options are applied where versions are set as to be retained and a standard description including the version date is stated.
  • Bundling: All deployments are minified, source maps are implemented for test environments and the “aws-sdk” package is excluded from deployment.

In the same vein, as well as defining configuration that should be applied to every Lambda, we can also set default configuration that can be overridden like so.

/**
* A set of overridable properties to be applied to a Lambda Function
* @type { NodejsFunctionProps }
*/
export const defaultProps: NodejsFunctionProps = {
handler: "main",
timeout: Duration.minutes(1),
};

In the above code block, a default handler name of “main” and timeout of one minute is provided for Lambda functions but this configuration can be overridden by developers. We can then use the above configuration in an L3 Lambda construct as below.

/**
* ILambdaProps provides all properties required to create a Lambda.
* @extends { NodejsFunctionProps }
*/
export interface ILambdaProps extends NodejsFunctionProps {
entry: string;
description: string;
serviceName: string;
}

/**
* Lambda represents an AWS Lambda Resource.
* @extends { NodejsFunction }
*/
export class Lambda extends NodejsFunction {
private static resourceType = "lmb";

/**
* A private constructor allowing only the static
* create function to create an instance of Lambda.
* @constructor
* @param {Construct} scope - CDK Construct.
* @param {string }id - Resource id.
* @param {NodejsFunctionProps} props - Properties to be applied to the Lambda.
*/
private constructor(
scope: Construct,
id: string,
props: NodejsFunctionProps
) {
super(scope, id, props);
}

/**
* Create an instance of Lambda setting fixed and default configuration.
* @param {Construct} scope - CDK Construct.
* @param {string} id - ID of the resource.
* @param {ILambdaProps} props - Properties to be applied when creating the Lambda.
* @returns {Lambda} - A new instance of Lambda.
*/
static create(scope: Construct, id: string, props: ILambdaProps): Lambda {
const lambda = new Lambda(scope, id, {
...props,
...defaultProps,
...fixedProps,
environment: {
...(props.environment ?? {}),
...fixedProps.environment,
POWERTOOLS_METRICS_NAMESPACE: props.serviceName,
},
functionName: namingUtils.createResourceName(
props.serviceName,
this.resourceType
)
});
return lambda;
}
}

In the above code block, an L3 construct that extends the “NodejsFunction” L2 Construct called “Lambda” is defined with a private constructor and a static factory method named “create”. The static “create” method receives the props object which implements the “ILambaProps” interface which extends the “NodejsFunctionProps” interface providing the additional “description” and “serviceName” values. The “create” function then creates an instance of “Lambda” providing an object containing the provided ILambaProps, defaultProps and fixedProps in order respectively ensuring that the fixed properties are not overridden, and default props can be overridden which is achieved using the spread operator multiple times. The environment property is defined in the same way ensuring the environment variables are defined in priority order.

A utility function has also been used from within the “namingUtils” Class called “createResourceName”. The “createResourceName” function receives the service name and the resourceType name, and returns the name to be applied to the function based on the companies resource naming convention. An example of the utility function is provided below.

/**
* NamingUtils is a utility class providing utility functions
* for standard naming conventions.
*/
class NamingUtils {
readonly config: ICoreConfig;

/**
* Create an instance of NamingUtils.
* @constructor
* @param {ICoreConfig} config - code configuration
*/
constructor(config: ICoreConfig) {
this.config = config;
}

/**
* Create a resource name based on standard naming convention
* {code}<region>-<environment>-<domain name>-<stage>-<service name>-<service type>{code}
* @param {string} serviceName - The name of the service.
* @param {string} serviceType - The type of service i.e lmb for lambda
* @returns generated resource name
*/
createResourceName(serviceName: string, serviceType: string): string {
return `${this.config.region}-${this.config.appEnv}-${this.config.domain.name}-${this.config.stage}-${serviceName}-${serviceType}`;
}
}

The snippet above provides another example of how L3 Constructs can help implement company specific policies and standards. This now ensures that all Lambda’s created by developers not only use a specific configuration but also follow the companies resource naming convention.

The above “Lambda” L3 Construct can then be used within CDK stacks to create AWS Lambda resources in the following way. As shown, a Lambda can now be created implementing all required, standard configuration with a couple of lines of code.

const getProductLambda = Lambda.create(this, "GetProductLambda", {
entry: path.join(
__dirname,
"../src/handler/get-product-function/index.ts"
),
description: "Get a product by product id",
serviceName: "getProductFunction",
});

The above code snippet demonstrates the creation of the get product lambda function providing the minimum required configuration of entry, description and serviceName. The code snippet below demonstrates how overridable and extra configuration can be provided.

const getProductLambda = Lambda.create(this, "GetProductLambda", {
entry: path.join(
__dirname,
"../src/handler/get-product-function/index.ts"
),
description: "Get a product by product id",
serviceName: "getProductFunction",
handler: "handler",
timeout: Duration.seconds(10),
environment: {
TABLE_NAME: props.productTable.tableName,
INDEX_NAME: props.productIdIndexName,
},
});

The above code snippet creates the same get product Lambda with the same required configuration but also provides the extra TABLE_NAME and INDEX_NAME environment variables as well as overrides the “main” handler name with “handler”, and sets the timeout value as 10 seconds rather than one minute.

AWS API Gateway L3 Construct

We can take this example further by creating an L3 AWS API Gateway Construct that implements company specific fixed and default configuration.

const fixedProps: Omit<apiGateway.LambdaRestApiProps, "handler"> = {
minimumCompressionSize: 0,
endpointConfiguration: { types: [apiGateway.EndpointType.REGIONAL] },
cloudWatchRole: true,
proxy: false,
};

The above fixed configuration specifies that APIs should be configured with the following.

  • Have a minimum compression size of 0.
  • Are regional APIs
  • Configured to have a CloudWatch role.
  • Do not proxy to integrations.

The below code snippet contains the L3 API Construct.

/**
* Representation of an APIEndpoint configuration.
*/
export type APIEndpoint = {
resourcePath: string;
method: HttpMethod;
function: NodejsFunction;
};

/**
* Redefined API Props for the API L3 Construct implementation.
*/
export interface APIProps
extends Omit<apiGateway.LambdaRestApiProps, "handler"> {
apiName: string;
description: string;
deploy?: boolean;
}

/**
* API L3 CDK Construct implementing fixed and default API Configuration.
* @extends {RestApi}
*/
export class API extends apiGateway.RestApi {
private static resourceType = "api";

/**
* Private API Constructor only allowing instances to be created in the factory method.
* @constructor
* @param {Construct} scope
* @param {string} id
* @param {APIProps} props
*/
private constructor(scope: Construct, id: string, props: APIProps) {
super(scope, id, props);
}

/**
* Create an instance of API implementing default and fixed values.
* @param {Construct} scope
* @param {string} id
* @param {APIProps} props
* @returns {API}
*/
static create(scope: Construct, id: string, props: APIProps): API {
const deployOptions = {
stageName: coreConfig.stage,
loggingLevel: apiGateway.MethodLoggingLevel.INFO,
metricsEnabled: true,
accessLogDestination: new apiGateway.LogGroupLogDestination(
new logs.LogGroup(
scope,
`/aws/api-gateway/${coreConfig.stage}/${props.apiName}-access-logs`
)
),
accessLogFormat: apiGateway.AccessLogFormat.jsonWithStandardFields({
caller: true,
httpMethod: true,
ip: true,
protocol: true,
requestTime: true,
resourcePath: true,
responseLength: true,
status: true,
user: true,
}),
};

const api = new API(scope, id, {
...props,
...fixedProps,
deploy: props.deploy ?? false,
...(props.deploy && { deployOptions }),
restApiName: namingUtils.createResourceName(
props.apiName,
this.resourceType
),
});

new CfnOutput(scope, `${props.apiName}APIGatewayRestApiId`, {
value: api.restApiId,
});
return api;
}

/**
* Add an endpoint to the API.
* @param {APIEndpoint} resourceToAdd - The resource to create.
* @returns {API}
*/
addEndpoint(resourceToAdd: APIEndpoint): API {
const apiResource = this.root.resourceForPath(resourceToAdd.resourcePath);
apiResource.addMethod(
resourceToAdd.method,
new apiGateway.LambdaIntegration(resourceToAdd.function, {
proxy: true,
})
);
return this;
}
}

The above API L3 Construct extends the “apiGateway.RestApi” L2 Construct implementing a private constructor and static “create” factory method as with the “Lambda” L3 construct. The constructor and “create” method receive an object implementing the “APIProps” interface which extends an omitted version of the “apiGateway.LambdaRestApiProps” interface removing the original “handler” property.

The static “create” factory function creates a new instance of “API” providing an object of type “APIProps” using the spread operator to apply the provided “props” and “fixedProps” in priority order in a way that ensures the “fixedProps” are not overridden. A default “deployOptions” configuration is also applied if the provided “props” option contains a “deploy” property boolean value of true. Again, the “namingUtils.createResourceName” utility function is used to ensure the resource complies with the company resource naming convention.

You will also notice the API L3 Construct contains an “addEndpoint” method that allows users of the construct to add resources to the API easily via the “resourceForPath” method. The below code snippet demonstrates how to use both L3 constructs to create the create product POST API.

// Create the product API.
const productApi = API.create(this, "ProductApi", {
apiName: "ProductApi",
description: "Product API",
deploy: true,
});

// Create the create product lambda
const createProductLambda = Lambda.create(this, "CreateProductLambda", {
entry: path.join(
__dirname,
"../src/handler/create-product-function/index.ts"
),
description: "Create a product",
serviceName: "createProductFunction",
environment: {
TABLE_NAME: props.productTable.tableName,
},
});

// Grant the lambda permission to read and write to/from the DynamoDB table.
props.productTable.grantReadWriteData(createProductLambda);

// Create the create product api endpoint
// associating the path, HTTP Verb and function.
productApi.addEndpoint({
resourcePath: "/product",
method: HttpMethod.POST,
function: createProductLambda,
});

In Closing

As shown above, both constructs require little configuration on instantiation and inherit all company enforced standards, configuration and convention. The idea with constructs like these is that they would be shared amongst teams via shared npm library packages. Teams would implement the shared package utilising the L3 constructs to benefit from the applied standards and configuration.

In my opinion, teams should endeavour to build custom L3 constructs to at least encapsulate company standards and configuration in an effort to not only ensure standards are applied, and services and deployments meet a minimum quality, but also to empower teams to deliver value faster.

Thank you for taking the time to read my post. If you are interested in being notified of my future posts, feel free to follow me on Linkedin and Twitter.

--

--