CDK Custom Constructs and Testing
Build a reusable L3 construct, write CDK assertion tests, and enforce compliance with Aspects.
Introduction
30 Second Summary
Every CDK project starts simple. But as teams grow, so does the copy-paste problem. The same Lambda-plus-DynamoDB pattern shows up in five stacks, each with slightly different security settings and no automated way to enforce consistency.
In this project, you will build a reusable L3 construct with a typed interface, write a full unit test suite using CDK assertions, and create a compliance Aspect that automatically blocks deployment when resources are missing required tags.
What You'll Build
Run npm test and watch every assertion pass. Then run npx cdk synth and see your Aspect catch a missing tag before a single resource touches AWS. The entire project runs locally with zero cloud cost.
By the end of this project, you'll have:
- A reusable SecureApi construct you can drop into any stack to provision a Lambda function, API Gateway REST API, and DynamoDB table with production-ready security defaults.
- A Jest test suite that proves your construct generates the correct CloudFormation resources, catches regressions with snapshot tests, and runs in seconds without an AWS account.
- A RequiredTags Aspect that visits every resource during synthesis and blocks deployment when mandatory tags are missing.
- Secret Mission: a BucketSecurityAspect that validates encryption and versioning on S3 buckets at the L1 level.
Are there any prerequisites?
This project assumes you have basic CDK experience and have deployed at least one stack before. You also need Node.js 22.x installed on your machine.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters. You're going to create a production-grade CDK project that never touches AWS, proving you can write reusable constructs, test infrastructure code, and enforce compliance automatically.
Initialize the CDK Project
Every CDK construct, test, and Aspect you write in this project lives inside a single TypeScript project. Before you can build anything, you need that project scaffolded with the right tooling in place.
In this step, you will verify the CDK CLI is available, scaffold a new TypeScript CDK app, and confirm that both the Jest test suite and CloudFormation synthesis work out of the box.
In this step, get ready to:
- Verify the CDK CLI is available on your machine.
- Initialize a CDK TypeScript project with Jest testing pre-configured.
- Confirm the default test passes and synthesis produces a CloudFormation template.
Verify the CDK CLI
The CDK CLI is the command-line tool that synthesizes your construct tree into CloudFormation templates. You need it available before scaffolding a project.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Check that the CDK CLI is available by running this command:
npx cdk --version
✔️ I see a version number
You should see something like 2.x.x (build ...) printed to the terminal. The CDK CLI is ready to go.
ⓧ Command not found or error
If the command fails, install the CDK CLI globally by running:
npm install -g aws-cdk
Once installed, re-run the version check to confirm:
npx cdk --version
You should now see a version number printed.
Create and initialize the project
CDK projects need their own directory. The cdk init command scaffolds a full TypeScript app structure including test configuration, build scripts, and entry points.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir cdk-constructs-lab
cd cdk-constructs-lab
- Initialize a CDK TypeScript app by running:
cdk init app --language typescript
What does this command do?
This scaffolds a complete CDK project with everything pre-configured:
- bin/ contains the app entry point that instantiates your stack.
- lib/ contains your stack definition where you define resources.
- test/ contains a Jest test file ready for assertions.
- It also initializes a git repository and runs npm install automatically, which may take 30-60 seconds.
You should see output ending with All done! once the scaffold is complete.
Seeing an error about the directory not being empty?
The cdk init command requires a completely empty directory. If you see this error, make sure you created a fresh cdk-constructs-lab folder and are inside it.
help me fix the cdk init empty directory error
Confirm tests and synthesis pass
The scaffold includes a default test that verifies the stack synthesizes without error. Running both the test and synthesis now proves your project is correctly wired before you add any code.
- Run the scaffold's default test suite:
npm test
You should see Jest output with all tests passing.
Tests failing?
Make sure you are inside the cdk-constructs-lab directory. Also confirm that cdk init finished completely (look for the All done! message in its output).
help me debug failing CDK scaffold tests
Before you run the next command, what do you expect npx cdk synth to produce?
- Run CloudFormation synthesis by running:
npx cdk synth
You should see a YAML CloudFormation template printed to the terminal. The template also gets written to the cdk.out/ directory.
What just happened?
The cdk synth command executes your TypeScript code and converts the construct tree into a raw CloudFormation template. This is how you validate CDK code without ever deploying to AWS.
Right now the template is mostly empty because the stack has no resources yet. That changes in the next step when you add Lambda, DynamoDB, and API Gateway.
- Confirm the cdk.out/ directory was created by running:
ls cdk.out/
You should see files including CdkConstructsLabStack.template.json in the listing.
No cdk.out directory?
If ls cdk.out/ fails, the synthesis did not complete. Re-run npx cdk synth and check for error messages above the output.
help me troubleshoot cdk synth not producing output
Your CDK project is scaffolded and verified. Next up, you will add Lambda, DynamoDB, and API Gateway resources to the stack and see what raw CloudFormation they produce.
Build a Basic Stack and Synth
Your CDK project is initialized and synthesizing successfully. Now it's time to fill the stack with real infrastructure resources.
Before extracting reusable patterns, you need to see what raw resource definitions look like inside a stack. In this step, you'll define a Lambda function, a DynamoDB table, and an API Gateway directly in the stack file to produce a visible CloudFormation template.
In this step, get ready to:
- Create a minimal Lambda handler file.
- Define a DynamoDB table, Lambda function, and API Gateway REST API in the stack.
- Synthesize the stack and inspect the raw CloudFormation output.
Create the Lambda handler file
The Lambda function needs source code to bundle. You'll create a small TypeScript handler that returns a JSON response.
- Open your cdk-constructs-lab project in VS Code by pressing Cmd+Space (macOS) or the Windows key (Windows) to open your search bar, typing VS Code, and pressing Enter.
- In VS Code, click File in the top menu bar.
- Click Open Folder and select the cdk-constructs-lab folder on your Desktop.
- Open the integrated terminal by pressing Ctrl+` (backtick key, same shortcut on both macOS and Windows).
- Create the lambda directory and handler file by running these commands:
mkdir lambda
touch lambda/index.ts
- Confirm the directory was created by running:
ls lambda
You should see index.ts in the output.
- Open lambda/index.ts in the VS Code Explorer sidebar and paste the following code:
// Returns a simple JSON response
export const handler = async (event: any) => {
return {
statusCode: 200,
body: JSON.stringify({ message: 'Hello from SecureApi!' }),
};
};
What does this code do?
This is the simplest possible Lambda handler. It takes an event, ignores it, and returns a 200 response with a JSON body. The CDK bundles this file and deploys it as the Lambda's runtime code.
- Save lambda/index.ts.
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
export const handler = async (event: any) => {
return {
statusCode: 200,
body: JSON.stringify({ message: 'Hello from SecureApi!' }),
};
};
Define the DynamoDB table in your stack
With the handler ready, you can start building the infrastructure that uses it. First, define the DynamoDB table that will store your API's data.
- Open lib/cdk-constructs-lab-stack.ts in the Explorer sidebar.
- Replace the entire file contents with the following code:
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
export class CdkConstructsLabStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// DynamoDB table with on-demand billing and easy cleanup
const table = new dynamodb.Table(this, 'Table', {
tableName: 'Items',
partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
}
}
What does this code do?
- The three new imports bring in the CDK modules for Lambda, API Gateway, and DynamoDB.
- The Table construct creates a DynamoDB table with a string partition key called id.
- PAY_PER_REQUEST means you only pay for actual reads and writes, with no provisioned capacity.
- RemovalPolicy.DESTROY tells CloudFormation to delete the table when the stack is destroyed, which is ideal for development.
- Save lib/cdk-constructs-lab-stack.ts.
- Run the following command to synthesize your stack and confirm the table appears:
npx cdk synth
Scroll through the output. You should see an AWS::DynamoDB::Table resource in the synthesized CloudFormation template.
Seeing a synthesis error?
- Check that you saved lib/cdk-constructs-lab-stack.ts before running npx cdk synth.
- Make sure all five import lines are present at the top of the file.
- Verify the closing braces match. You should have two } at the end of the file, one for the constructor and one for the class.
Help me debug my synthesis error
Add the Lambda function and API Gateway
The table stores data, but you need a compute layer to handle requests and an API to expose it over HTTP.
- In lib/cdk-constructs-lab-stack.ts, find the closing }); of the DynamoDB table definition.
- Paste the following code below it, inside the constructor (before the final two closing braces):
// Lambda function that reads/writes the table
const handler = new lambda.Function(this, 'Handler', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'index.handler',
code: lambda.Code.fromAsset('lambda'),
environment: { TABLE_NAME: table.tableName },
});
// Grant Lambda permission to access the table
table.grantReadWriteData(handler);
// REST API proxying requests to the Lambda
const api = new apigateway.RestApi(this, 'Api', {
restApiName: 'ItemsApi',
});
const items = api.root.addResource('items');
items.addMethod('GET', new apigateway.LambdaIntegration(handler));
items.addMethod('POST', new apigateway.LambdaIntegration(handler));
What does this code do?
- The Lambda function uses the Node.js 20.x runtime and loads code from the lambda/ folder you just created.
- grantReadWriteData generates a least-privilege IAM policy letting only this function access the table.
- The RestApi creates an API Gateway REST API with two methods (GET and POST) on an /items resource, both integrated with the Lambda.
- Save lib/cdk-constructs-lab-stack.ts.
✔️ Awesome, I've got everything!
Great. Confirm you have the DynamoDB table at the top of the constructor, followed by the Lambda, the grant, and the API Gateway at the bottom.
ⓧ I'd like to double check the full code
Your lib/cdk-constructs-lab-stack.ts file should contain these sections in order. Scroll up and confirm each is present:
- Five import lines at the top (aws-cdk-lib, constructs, aws-lambda, aws-apigateway, aws-dynamodb).
- A dynamodb.Table with tableName: 'Items', a string partition key, PAY_PER_REQUEST billing, and DESTROY removal policy.
- A lambda.Function with NODEJS_20_X runtime, index.handler handler, and code from the lambda directory.
- A table.grantReadWriteData(handler) call.
- A RestApi with an items resource and GET/POST methods integrated with the Lambda.
Synth and inspect the output
Your stack now defines three distinct AWS resources inline. Before you run the synth, think about what you expect to see in the CloudFormation output.
- Predict: how many CloudFormation resources do you think a Lambda + DynamoDB + API Gateway pattern generates? More or fewer than three?
- Run the synthesis by executing:
npx cdk synth
Scroll through the YAML output. You should see AWS::Lambda::Function, AWS::DynamoDB::Table, and AWS::ApiGateway::RestApi among many other resources.
The CDK generated far more than three resources. IAM roles, policies, deployments, stages, and methods all appeared automatically from your handful of TypeScript lines.
Why so many resources?
CDK's L2 constructs handle the wiring that CloudFormation requires you to specify manually. A single RestApi construct generates the API, a deployment, a stage, and associated permissions. This is the abstraction power you'll lean on when building your own L3 construct.
Now observe the problem. Every resource definition sits directly in your stack class with no separation. If you needed this same Lambda + Table + API pattern in a second stack, you would have to copy-paste all of it and adjust each value by hand.
Why is copy-paste a problem?
Duplicating resource definitions means fixing a bug or changing a default requires updating every copy. In a team with ten services, that's ten places to forget. Extracting into a reusable construct gives you a single source of truth with a typed API that prevents misconfiguration.
Don't see all three resource types?
- Make sure you saved both lambda/index.ts and lib/cdk-constructs-lab-stack.ts before running synth.
- Check that lambda.Code.fromAsset('lambda') points to the correct directory. The lambda/ folder must exist at the project root with index.ts inside.
- Verify you pasted the Lambda and API Gateway code inside the constructor, not after the closing class brace.
Help me debug my synth output
Your stack synthesizes a full CloudFormation template with all three resource types. Next up, you'll extract these resources into a reusable L3 construct so you never have to copy-paste infrastructure again.
Extract a Reusable L3 Construct
Your stack works, but every resource lives inline in a single file. If you needed the same Lambda + API Gateway + DynamoDB pattern in a second stack, you would copy-paste the entire block and hope nothing drifts.
Extracting into an L3 construct solves this. You get a reusable, testable unit with a typed interface that other teams can instantiate with a few lines of config.
In this step, get ready to:
- Define a SecureApi construct with a typed props interface.
- Update the stack to use the new construct.
- Verify synthesis output is unchanged.
Create the construct directory and props interface
The construct needs its own directory inside lib/. This keeps reusable constructs separate from stack definitions.
- Create the constructs directory by running this command:
mkdir lib/constructs
- Create a new file called secure-api.ts inside lib/constructs/ and paste the imports and props interface:
import { Construct } from 'constructs';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as cdk from 'aws-cdk-lib';
// Props interface defines the construct's typed API
export interface SecureApiProps {
readonly tableName: string;
readonly lambdaHandlerPath: string;
readonly apiName?: string;
readonly partitionKeyName?: string;
}
What does this code do?
The SecureApiProps interface is the public API for your construct. Anyone who instantiates SecureApi will pass in these properties.
- tableName and lambdaHandlerPath are required because every API needs a database and handler code.
- apiName and partitionKeyName are optional with sensible defaults, keeping the interface minimal for common cases.
- Confirm the file was created by running:
ls lib/constructs/
You should see secure-api.ts listed in the output.
Add the class body and resource definitions
The class extends Construct (not Stack), following the composition-over-inheritance pattern recommended by AWS. It encapsulates the DynamoDB table, Lambda function, and API Gateway with production-grade defaults.
- Below the interface in lib/constructs/secure-api.ts, add the class declaration and DynamoDB table resource:
// L3 construct encapsulating Lambda + API Gateway + DynamoDB
export class SecureApi extends Construct {
public readonly table: dynamodb.Table;
public readonly handler: lambda.Function;
public readonly api: apigateway.RestApi;
constructor(scope: Construct, id: string, props: SecureApiProps) {
super(scope, id);
// DynamoDB table with production defaults
this.table = new dynamodb.Table(this, 'Table', {
tableName: props.tableName,
partitionKey: {
name: props.partitionKeyName ?? 'id',
type: dynamodb.AttributeType.STRING,
},
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
removalPolicy: cdk.RemovalPolicy.DESTROY,
pointInTimeRecoverySpecification: {
pointInTimeRecoveryEnabled: true,
},
});
What does this code do?
The three public readonly properties expose the underlying resources so consumers can reference them for IAM grants, outputs, or cross-stack references.
The DynamoDB table now includes pointInTimeRecoverySpecification as a production default that every consumer gets automatically. This is the kind of opinionated security setting an L3 construct should enforce.
- Below the DynamoDB table definition, add the Lambda function, IAM grant, and API Gateway to complete the constructor:
// Lambda function with table access
this.handler = new lambda.Function(this, 'Handler', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'index.handler',
code: lambda.Code.fromAsset(props.lambdaHandlerPath),
environment: {
TABLE_NAME: this.table.tableName,
},
timeout: cdk.Duration.seconds(30),
});
// Least-privilege IAM
this.table.grantReadWriteData(this.handler);
// API Gateway REST API with production stage
this.api = new apigateway.RestApi(this, 'Api', {
restApiName: props.apiName ?? `${props.tableName}Api`,
deployOptions: {
stageName: 'prod',
},
});
// Route GET and POST /items to the Lambda
const items = this.api.root.addResource('items');
items.addMethod('GET', new apigateway.LambdaIntegration(this.handler));
items.addMethod('POST', new apigateway.LambdaIntegration(this.handler));
}
}
What does this code do?
The Lambda function uses props.lambdaHandlerPath instead of a hardcoded path, so consumers control where their handler code lives. A 30-second timeout replaces the CDK default of 3 seconds.
grantReadWriteData generates a least-privilege IAM policy scoped to just this table. The API Gateway deploys to a prod stage by default instead of the unnamed default stage.
- Save lib/constructs/secure-api.ts.
- Check your editor for red squiggles or error underlines. If there are none, your TypeScript is valid.
Seeing red squiggles in your editor?
Make sure the closing braces align correctly. The constructor body ends with }, then the class ends with }. A missing brace will cause TypeScript to flag the entire file.
help me fix TypeScript errors in my SecureApi construct file
Update the stack to use SecureApi
With the construct extracted, the stack file becomes a thin orchestration layer. All it does is instantiate the construct and apply stack-wide tags.
- Open lib/cdk-constructs-lab-stack.ts and replace its entire contents with this code:
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { SecureApi } from './constructs/secure-api';
export class CdkConstructsLabStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// Instantiate the reusable construct
new SecureApi(this, 'MyApi', {
tableName: 'Items',
lambdaHandlerPath: 'lambda',
apiName: 'ItemsApi',
});
// Tag all resources in this stack
cdk.Tags.of(this).add('Project', 'cdk-constructs-lab');
cdk.Tags.of(this).add('Environment', 'dev');
cdk.Tags.of(this).add('Owner', 'platform-team');
}
}
What does this code do?
The stack dropped from 30+ lines of inline resources to a single SecureApi instantiation. If another team needs the same API pattern, they import SecureApi and pass their own props.
Tags.of(this).add() recursively tags every taggable resource in the stack. You are adding these tags now because a later step will enforce them with an Aspect.
- Save lib/cdk-constructs-lab-stack.ts.
Import error on SecureApi?
Confirm the relative path is './constructs/secure-api' (no .ts extension). Also check that lib/constructs/secure-api.ts exports the SecureApi class with the export keyword.
help me resolve an import error for SecureApi in my CDK stack
Verify synthesis output is unchanged
A correct refactor changes code structure but not behavior. The synthesized CloudFormation template should still contain the same resources with the same properties.
- Before you run this, think about what you expect to see. Should the output contain more resources, fewer resources, or the same resources as before?
- Run the synthesis command:
npx cdk synth
The output still contains AWS::Lambda::Function, AWS::DynamoDB::Table, and AWS::ApiGateway::RestApi. The refactor preserved behavior while dramatically improving code structure.
Why is the output identical?
CDK constructs are just code that generates CloudFormation. Whether you define resources inline or inside a construct class, the synthesized template is the same. The construct is an authoring abstraction, not a deployment abstraction.
Synthesis failing with an error?
If you see a TypeScript compilation error, check that secure-api.ts has matching opening and closing braces for the class and constructor. If the error mentions a missing module, confirm all five imports at the top of secure-api.ts are present.
help me fix a CDK synthesis error after extracting my SecureApi construct
✔️ Awesome, I've got everything!
Great. Double check you have saved both lib/constructs/secure-api.ts and lib/cdk-constructs-lab-stack.ts.
ⓧ I'd like to double check the full code
Your lib/constructs/secure-api.ts is over 30 lines, so confirm these chunks are present in order by scrolling through the file:
- Imports (5 lines) followed by the SecureApiProps interface with tableName, lambdaHandlerPath, apiName, and partitionKeyName.
- The SecureApi class declaration with three public readonly properties, the constructor calling super(scope, id), and the DynamoDB table with pointInTimeRecoverySpecification.
- The Lambda function with timeout: cdk.Duration.seconds(30), the grantReadWriteData call, and the API Gateway RestApi with stageName: 'prod' and two routes.
Here is the full lib/cdk-constructs-lab-stack.ts:
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { SecureApi } from './constructs/secure-api';
export class CdkConstructsLabStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
new SecureApi(this, 'MyApi', {
tableName: 'Items',
lambdaHandlerPath: 'lambda',
apiName: 'ItemsApi',
});
cdk.Tags.of(this).add('Project', 'cdk-constructs-lab');
cdk.Tags.of(this).add('Environment', 'dev');
cdk.Tags.of(this).add('Owner', 'platform-team');
}
}
Your construct is now a self-contained, reusable unit. Next up, you will write automated tests that prove it creates the right resources with the right properties.
Write Unit Tests with CDK Assertions
You just refactored your resources into a reusable construct. The synthesized template looks the same, but you have no automated proof.
Without tests, the next developer to touch SecureApi has no safety net. Infrastructure tests are what separate professional CDK usage from hobby projects. In this step, you write a full Jest test suite using the CDK assertions library to prove your construct creates the right CloudFormation resources.
In this step, get ready to:
- Write fine-grained assertions verifying Lambda and DynamoDB resource properties.
- Use matchers to check resource counts, key schemas, and environment variables.
- Add a snapshot test to catch unintended regressions automatically.
Write fine-grained assertions for Lambda and DynamoDB
The CDK assertions library lets you inspect synthesized CloudFormation templates without ever deploying. You create a stack in memory, synthesize it with Template.fromStack(), then assert on individual resource properties.
- Delete the scaffold test file by running this command:
rm test/cdk-constructs-lab.test.ts
- Create a new test file test/secure-api.test.ts and paste in the following code:
import * as cdk from 'aws-cdk-lib';
import { Template, Match } from 'aws-cdk-lib/assertions';
import { SecureApi } from '../lib/constructs/secure-api';
describe('SecureApi Construct', () => {
let template: Template;
beforeAll(() => {
const stack = new cdk.Stack();
new SecureApi(stack, 'TestApi', {
tableName: 'TestTable',
lambdaHandlerPath: 'lambda',
apiName: 'TestApi',
});
template = Template.fromStack(stack);
});
test('creates a Lambda function with correct runtime and handler', () => {
template.hasResourceProperties('AWS::Lambda::Function', {
Runtime: 'nodejs20.x',
Handler: 'index.handler',
});
});
});
What does this code do?
- beforeAll creates a fresh CDK stack, instantiates your SecureApi construct inside it, and synthesizes the result into a Template object. Every test in the describe block shares this template.
- hasResourceProperties performs a partial match against the synthesized CloudFormation. It passes if a resource of the given type exists with at least the properties you specify.
- The first test asserts the Lambda has runtime nodejs20.x and handler index.handler. Any extra properties on the resource are ignored.
- Add the DynamoDB billing test below the Lambda test, before the closing });:
test('creates a DynamoDB table with PAY_PER_REQUEST billing', () => {
template.hasResourceProperties('AWS::DynamoDB::Table', {
BillingMode: 'PAY_PER_REQUEST',
TableName: 'TestTable',
});
});
What does this test verify?
This assertion confirms the DynamoDB table uses on-demand billing (PAY_PER_REQUEST) and has the correct table name. If someone accidentally changes the billing mode to provisioned, this test catches it immediately.
- Save test/secure-api.test.ts.
- Run the test suite by running this command:
npm test
You should see 2 tests passing in the SecureApi Construct describe block.
Tests failing with a module error?
- Check that your import path matches '../lib/constructs/secure-api' exactly. A missing constructs/ directory in the path causes a module-not-found error.
- Confirm the lambda/ directory exists in your project root. The beforeAll block synthesizes the construct, which expects the Lambda asset path to resolve.
Still stuck? Why are my CDK assertions tests failing with a module not found error?
Add resource count and matcher assertions
Fine-grained assertions prove individual properties exist. The Match utilities let you go further. You can assert on nested structures, ignore irrelevant fields, and count resources without knowing their logical IDs.
- In test/secure-api.test.ts, add these three tests below the DynamoDB billing test (before the final });):
test('creates a DynamoDB table with correct key schema', () => {
template.hasResourceProperties('AWS::DynamoDB::Table', {
KeySchema: Match.arrayWith([
Match.objectLike({
AttributeName: 'id',
KeyType: 'HASH',
}),
]),
});
});
test('creates exactly one REST API', () => {
template.resourceCountIs('AWS::ApiGateway::RestApi', 1);
});
test('Lambda has TABLE_NAME environment variable', () => {
template.hasResourceProperties('AWS::Lambda::Function', {
Environment: {
Variables: {
TABLE_NAME: Match.anyValue(),
},
},
});
});
What do these matchers do?
- Match.arrayWith() asserts that the array contains at least the specified element. The key schema may have additional attributes, but it must contain the partition key.
- Match.objectLike() performs a deep partial match. It verifies AttributeName and KeyType without failing on extra CloudFormation properties you did not specify.
- resourceCountIs() asserts that exactly one REST API resource exists in the template. It catches accidental duplicates.
- Match.anyValue() confirms the TABLE_NAME variable is present without asserting its exact value (which is a CDK token at synth time).
- Save test/secure-api.test.ts and run the tests:
npm test
You should now see 5 tests passing in the SecureApi Construct describe block.
Seeing a Match.arrayWith failure?
- Make sure Match is imported alongside Template on line 2: import { Template, Match } from 'aws-cdk-lib/assertions'.
- If the key schema test fails, confirm your SecureApi construct uses 'id' as the default partition key name. The test expects AttributeName: 'id'.
Need help? Why is my CDK Match.arrayWith assertion failing on the key schema test?
Add a snapshot test for regression detection
Fine-grained assertions verify specific properties. A snapshot test captures the entire synthesized template as a JSON file. If any resource changes in the future, Jest flags the diff so you can approve or reject it.
- Add this final test below the TABLE_NAME test, before the closing });:
test('matches snapshot', () => {
expect(template.toJSON()).toMatchSnapshot();
});
What does a snapshot test do?
The first time this test runs, Jest writes the full JSON template to a __snapshots__ file. On every subsequent run, Jest compares the current output to the stored snapshot. Any unintended change triggers a failure.
If you intentionally change your construct later, run npm test -- -u to update the snapshot.
- Save test/secure-api.test.ts.
Before you run the tests, predict: how many tests do you expect to pass now?
- Run the full test suite:
npm test
All 6 tests pass. Jest also creates a __snapshots__ directory inside test/ containing the stored template JSON.
Snapshot test failing on first run?
- Snapshot tests should never fail on their first run because there is no existing snapshot to compare against. If you see a failure, check that no stale __snapshots__ directory exists from the scaffold test.
- Delete the test/__snapshots__ folder and run npm test again to regenerate a fresh snapshot.
Still stuck? Why is my Jest snapshot test failing on the first run in a CDK project?
✔️ Awesome, I've got everything!
Great. Double check you have saved test/secure-api.test.ts and that npm test reports 6 passing tests.
ⓧ I'd like to double check the full code
Your test/secure-api.test.ts file should contain these sections in order. Scroll up and confirm each one is present:
- Imports: cdk, Template, Match, and SecureApi.
- A describe('SecureApi Construct', ...) block wrapping everything.
- A beforeAll that creates a stack, instantiates SecureApi with tableName: 'TestTable', and calls Template.fromStack(stack).
- Test: Lambda function with Runtime: 'nodejs20.x' and Handler: 'index.handler'.
- Test: DynamoDB table with BillingMode: 'PAY_PER_REQUEST' and TableName: 'TestTable'.
- Test: DynamoDB key schema using Match.arrayWith and Match.objectLike.
- Test: resourceCountIs('AWS::ApiGateway::RestApi', 1).
- Test: Lambda TABLE_NAME environment variable with Match.anyValue().
- Test: snapshot using expect(template.toJSON()).toMatchSnapshot().
Your construct now has a full safety net. Any accidental change to the Lambda runtime, DynamoDB billing, or API Gateway count triggers a test failure before it ever reaches production. Next up, you will build a compliance Aspect that automatically blocks untagged resources from synthesizing.
Build a Compliance Aspect
Your SecureApi construct now has a full test suite proving it creates the right resources. But there is a gap: none of those resources have tags.
In a real organization, untagged resources fail compliance checks and get flagged by FinOps teams. Rather than trusting every developer to remember tags, you can build a CDK Aspect that blocks synthesis automatically. This shifts compliance left into the development loop.
In this step, get ready to:
- Create a RequiredTags Aspect that checks every resource for mandatory tag keys.
- Write tests proving the Aspect blocks non-compliant stacks and passes compliant ones.
- Wire the Aspect into your production stack so it enforces tags on every synthesis.
Create the RequiredTags Aspect file
A CDK Aspect implements the IAspect interface and defines a single visit method. During synthesis, CDK calls visit on every construct in the tree, giving your Aspect a chance to inspect or mutate each node.
- Create a new folder called aspects inside your lib/ directory by running:
mkdir lib/aspects
- Create a new file called lib/aspects/required-tags.ts and add the following code:
import { IAspect, Annotations, CfnResource, TagManager } from 'aws-cdk-lib';
import { IConstruct } from 'constructs';
export class RequiredTags implements IAspect {
private readonly requiredKeys: string[];
constructor(requiredKeys: string[]) {
this.requiredKeys = requiredKeys;
}
public visit(node: IConstruct): void {
if (node instanceof CfnResource) {
const tagManager = (node as any).tags as TagManager | undefined;
if (tagManager) {
const renderedTags = tagManager.renderTags() as Array<{ key: string; value: string }> | undefined;
const tagKeys = renderedTags?.map((t) => t.key) ?? [];
for (const required of this.requiredKeys) {
if (!tagKeys.includes(required)) {
Annotations.of(node).addError(
`Missing required tag: "${required}"`
);
}
}
}
}
}
}
What does this code do?
The visit method is called once for every construct in the tree during synthesis. It only acts on CfnResource nodes, which are the lowest-level (L1) representations of CloudFormation resources.
For each resource, it reads the rendered tags from the TagManager and checks whether every required key is present. If a key is missing, Annotations.of(node).addError() attaches an error annotation that blocks synthesis from completing.
- Save lib/aspects/required-tags.ts.
Why Aspects instead of manual checks?
You could add tags manually to every resource, but that relies on developers remembering. An Aspect runs automatically during synthesis against every resource in scope. If someone forgets a tag, the build fails before it ever reaches AWS.
This is the compliance-as-code pattern. Policy lives in the same repo as infrastructure, tested the same way.
Test that missing tags trigger errors
The Aspect only proves its value if you can demonstrate it catches violations. You will write a test that creates a stack with no tags and asserts the Aspect produces error annotations.
- Create a new file called test/required-tags.test.ts and add the following code:
import * as cdk from 'aws-cdk-lib';
import { Annotations } from 'aws-cdk-lib/assertions';
import { SecureApi } from '../lib/constructs/secure-api';
import { RequiredTags } from '../lib/aspects/required-tags';
describe('RequiredTags Aspect', () => {
test('adds error annotations when required tags are missing', () => {
const app = new cdk.App();
const stack = new cdk.Stack(app, 'TestStack');
new SecureApi(stack, 'TestApi', {
tableName: 'TestTable',
lambdaHandlerPath: 'lambda',
apiName: 'TestApi',
});
cdk.Aspects.of(stack).add(new RequiredTags(['Project', 'Environment']));
const annotations = Annotations.fromStack(stack);
annotations.hasError('*', 'Missing required tag: "Project"');
});
});
What does this test do?
The test creates a stack with a SecureApi construct but applies no tags. It then attaches the RequiredTags Aspect requiring Project and Environment keys.
When Annotations.fromStack(stack) runs, it triggers synthesis internally. The hasError('*', ...) assertion checks that at least one error annotation matching the message was attached to any resource in the stack.
- Save test/required-tags.test.ts.
- Run the test suite to confirm the new test passes:
npm test
You should see your new RequiredTags Aspect describe block with 1 passing test, alongside the existing SecureApi tests.
Test failing with a compilation error?
Make sure the import path ../lib/aspects/required-tags matches your file location exactly. The aspects folder must be inside lib/, not at the project root.
help me debug a TypeScript import error in my CDK test file
Test that compliant stacks pass cleanly
A compliance check is only useful if it also knows when resources ARE compliant. You need to prove the Aspect stays silent when all required tags are present.
- In test/required-tags.test.ts, add the following test inside the describe block, after the closing }); of the first test:
test('does not add errors when all required tags are present', () => {
const app = new cdk.App();
const stack = new cdk.Stack(app, 'TestStack');
new SecureApi(stack, 'TestApi', {
tableName: 'TestTable',
lambdaHandlerPath: 'lambda',
apiName: 'TestApi',
});
cdk.Tags.of(stack).add('Project', 'test-project');
cdk.Tags.of(stack).add('Environment', 'dev');
cdk.Aspects.of(stack).add(new RequiredTags(['Project', 'Environment']));
const annotations = Annotations.fromStack(stack);
annotations.hasNoError('*', 'Missing required tag: "Project"');
annotations.hasNoError('*', 'Missing required tag: "Environment"');
});
What does this test do?
This test uses Tags.of(stack).add() to apply the required tags to all resources in the stack before attaching the Aspect. The hasNoError() assertion confirms no error annotations exist for either required key.
Together, the two tests prove the Aspect both catches violations AND stays quiet for compliant stacks. This is the testing pattern for any CDK Aspect.
- Save test/required-tags.test.ts.
Before you run the tests, think about what you expect to see. Both tests should pass. Why? The first test creates a non-compliant stack and checks for errors. The second creates a compliant stack and checks for no errors.
- Run the full test suite:
npm test
You should see 2 passing tests in the RequiredTags Aspect describe block, plus all 6 SecureApi tests still passing.
Second test failing?
Make sure the second test is inside the existing describe('RequiredTags Aspect', () => { ... }) block, not outside it. Both tests share the same imports and describe wrapper.
help me fix my second RequiredTags test
✔️ Awesome, I've got everything!
Great. Make sure you have saved test/required-tags.test.ts with both tests inside the describe block.
ⓧ I'd like to double check the full code
Your test/required-tags.test.ts file should contain these sections in order:
- The four import statements (cdk, Annotations, SecureApi, RequiredTags).
- A describe('RequiredTags Aspect', ...) block wrapping both tests.
- First test: 'adds error annotations when required tags are missing' with no tags applied and hasError assertion.
- Second test: 'does not add errors when all required tags are present' with tags applied and hasNoError assertions.
Wire the Aspect into your production stack
The tests prove the Aspect works in isolation. Now you will attach it to your real stack so every future synthesis is automatically validated.
- Open lib/cdk-constructs-lab-stack.ts.
- Find the import lines at the top of the file:
import { SecureApi } from './constructs/secure-api';
- Add this import on the next line:
import { RequiredTags } from './aspects/required-tags';
- Scroll to the bottom of the constructor, after the three cdk.Tags.of(this).add(...) lines. Add this line:
cdk.Aspects.of(this).add(new RequiredTags(['Project', 'Environment', 'Owner']));
What does this do?
This attaches the RequiredTags Aspect to the entire stack scope. During synthesis, CDK visits every resource in the stack and checks for the three required tag keys. Because you already applied Project, Environment, and Owner tags earlier in the constructor, synthesis will succeed.
- Save lib/cdk-constructs-lab-stack.ts.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file with the new import and the Aspects line.
ⓧ I'd like to double check the full code
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { SecureApi } from './constructs/secure-api';
import { RequiredTags } from './aspects/required-tags';
export class CdkConstructsLabStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
new SecureApi(this, 'MyApi', {
tableName: 'Items',
lambdaHandlerPath: 'lambda',
apiName: 'ItemsApi',
});
cdk.Tags.of(this).add('Project', 'cdk-constructs-lab');
cdk.Tags.of(this).add('Environment', 'dev');
cdk.Tags.of(this).add('Owner', 'platform-team');
cdk.Aspects.of(this).add(new RequiredTags(['Project', 'Environment', 'Owner']));
}
}
Before you run the final check, predict what will happen. Your stack has all three required tags already applied. Will synthesis succeed or fail?
- Run the full test suite one more time to confirm everything still passes:
npm test
All 8 tests pass: 6 from SecureApi and 2 from RequiredTags. The Aspect catches missing tags and stays silent when tags are present.
- Run npx cdk synth to confirm the real stack synthesizes without errors:
npx cdk synth
Synthesis completes and prints the CloudFormation template. If any of the three required tags were missing, synthesis would fail with an error annotation. Your compliance gate is working.
Seeing Missing required tag errors during synth?
This means the Aspect is working but your tags are not reaching the resources. Make sure the three cdk.Tags.of(this).add(...) lines appear BEFORE the cdk.Aspects.of(this).add(...) line in your constructor. Tags must be applied to the scope before the Aspect inspects it.
help me fix Missing required tag errors during cdk synth
You now have automated compliance built into your CDK project. Any developer who adds resources without the required tags will get a clear error at synthesis time, long before anything reaches AWS. Next up, you will take this pattern further in the Secret Mission.
Secret mission
Encryption Validation Aspect
Your RequiredTags Aspect catches missing tags. But what about S3 buckets deployed without encryption or versioning? Build a second Aspect that inspects L1 bucket properties and blocks non-compliant buckets from ever reaching production.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- The cdk-constructs-lab project folder on your Desktop.
- The globally installed AWS CDK CLI (if you installed it with npm install -g aws-cdk).
Keep everything running
No action needed. Choose this if you plan to keep experimenting with CDK constructs, Aspects, or tests in this project.
- Your project folder stays on your Desktop at ~/Desktop/cdk-constructs-lab.
- The AWS CDK CLI remains globally available for future CDK projects.
Pause - I'll come back to this later
Nothing to pause. Since this project runs entirely locally with no servers or cloud resources, your files just sit on disk until you return.
- Your project folder remains at ~/Desktop/cdk-constructs-lab. Open it anytime and run npm test to pick up where you left off.
Delete - I don't want to use this again
Remove all project files and the global CDK CLI from your machine.
- Delete the project folder by running this command in your terminal:
macOS
rm -rf ~/Desktop/cdk-constructs-lab
Windows
rmdir /s /q %USERPROFILE%\Desktop\cdk-constructs-lab
- If you installed the CDK CLI globally, remove it by running:
npm uninstall -g aws-cdk
- Confirm both are gone by running:
ls ~/Desktop | grep cdk-constructs-lab
npx cdk --version
The first command should return no output. The second should either show nothing or prompt to install the package.
Nice Work!
Nice Work!
You just built a production-grade CDK project that demonstrates senior-level infrastructure patterns. Everything runs locally with npm test and cdk synth. No AWS account needed, no costs incurred.
You've learned how to:
- Build a custom L3 construct (SecureApi) that encapsulates Lambda, API Gateway, and DynamoDB with production defaults and a typed props interface.
- Write a full unit test suite using CDK assertions with fine-grained property checks, resource counts, matchers, and snapshot tests.
- Create a RequiredTags Aspect that blocks synthesis when resources lack mandatory tags, proving compliance-as-code with tests that verify correct rejection and acceptance of stacks.
- Secret Mission: Built a BucketSecurityAspect that validates encryption and versioning on S3 buckets at the L1 level.
Ready to quiz yourself?