This post is the fourth part of a ‘Let’s CDK’ series. You can catch up on part 1 here, part 2 here and part 3 here. This post is part of a series that will introduce you to the AWS Cloud Development Kit (CDK) and how it can be used to create an application. This series will introduce you to the core concepts of AWS CDK, how you can deploy to your AWS account, and how services like AWS Lambda Functions, DynamoDB, Step Functions and more can all be used when working with CDK.
Our Previous Steps
In the last episode, we learned about AWS Step Functions and how we can create them using CDK. We created our own custom L3 construct for an express workflow Step Function and then created the body definition chain. We linked it all together nicely in a stack and then integrated it with our main app stack. Feel free to have a read-through of the previous episode if you need any refresher before you continue on.
There’s nothing wrong with returning!
Now that we’ve refreshed our memories of the last episode, you may be aware that there were a couple of things that we added as placeholders that we promised to return to. These ‘things’ were the Lambda Functions that we use to perform some logic on our payload in our Express Step Function flow.
Let’s take a quick look at how we implemented these Lambda functions via CDK in the previous episode:
const sugarFreeLambdaFunction = new NodejsFunction(this, 'SugarFreeLambdaFunction', {
entry: path.join(__dirname, '../src/functions/sugarFree/sugarFree.ts'),
runtime: Runtime.NODEJS_18_X,
architecture: Architecture.ARM_64,
handler: 'sugarFree',
bundling: {
sourceMap: true,
minify: true,
tsconfig: path.join(__dirname, '../tsconfig.json'),
},
});
We used the NodejsFunction
construct to build a Node.js Lambda function bundled using esbuild. However, if we look a little closer at the implementation, there are some things that we will likely keep the same between all our Lambda functions we end up using. Things like the runtime
, architecture
and bundling
probably won’t change. Could we come up with a way to implement something that would handle this for us?
Let’s Construct Something…
Terrible pun aside, you probably guessed that we can create a custom L3 construct for our Lambda functions. In this custom construct we can set sensible defaults that we want all of our Lambda functions to use. We can also make use of a props
object so things like the entry
, handler
and tsconfig
can be dynamic.
To get started with our custom Lambda function construct let’s create a LambdaFunctions
directory in our constructs
directory. Inside there, let’s now create a TSLambdaFunction.ts
file to house our construct
code. Your constructs
directory structure should look a little something like this:
src/
│
└───constructs/
│
├───APIGateway/
│
└───LambdaFunctions/
│
└───StepFunctions/
In our new TSLambdaFunctions.ts
file, let’s take a look at what we’ll need to be importing:
import { Stack } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import { Architecture, Runtime } from "aws-cdk-lib/aws-lambda";
Hopefully, you can see some familiar faces in the imports above. The trusty Stack
and Construct
imports have been used throughout the series. The NodejsFunction
import provides us with the construct we are going to use to create the actual Lambda function. You may be wondering why we don’t just use the Function
construct to create our Lambda functions.
As we’re working on a Node.js application, the NodejsFunction
construct makes things a lot simpler for us by handling the code bundling. As we’re also working in Typescript, the construct makes things easier for us again by handling the compilation from Typescript to Javascript. In a nutshell, as we’re working in Typescript, using the NodejsFunction
construct saves us time and effort and helps us get up and running quicker.
The Architecture
and Runtime
imports allow us to specify, as you can probably guess, what architecture and runtime our Lambda function should use. Although these two imports aren’t completely necessary, as they are optional props for the NodejsFunction
construct, we can specify our own values to override the defaults.
Next, let’s have a think about the props that we will want to be able to pass into our TSLambdaFunction
construct:
type LambdaFunctionProps = {
serviceName: string;
stage: string;
entryPath: string;
handlerName?: string;
tsConfigPath: string;
}
Breaking down the type above, we can see:
- serviceName: A string we will use to create the logical ID for our Lambda function
- stage: A string we will use to create the logical ID for our Lambda function. This could also be used to handle deployment stage-specific needs if needed.
- entryPath: A string that represents the path to the Lambda function handler code
- handlerName: An optional string that represents that name of the exported function from the entry file
- tsConfigPath: A string that represents the path to the tsconfig file. We will pass this explicitly so the construct knows exactly where our tsconfig file is rather than it having to try to work it out itself.
Now we have our imports and our props decided. Let’s get cracking on actually creating the construct. We can make use of the skeleton we’re familiar with:
export class TSLambdaFunction extends Construct {
public readonly tsLambdaFunction: NodejsFunction;
constructor(scope: Stack, id: string, props: LambdaFunctionProps) {
super(scope, id);
// This is where the fun stuff will live!
}
}
In the code above, we have the skeleton for our TSLambdaFunction
that extends the Construct
class. We set a public readonly property called tsLambdaFunction
so we are able to access the Lambda function created inside this construct when used inside our stacks.
It’s time to have some fun by adding some meat to the bones of our skeleton. This ‘meat’ looks a little something like this:
const {
serviceName,
stage,
entryPath,
handlerName = 'handler',
tsConfigPath
} = props;
this.tsLambdaFunction = new NodejsFunction(this, `${serviceName}-${id}-${stage}`, {
entry: entryPath,
runtime: Runtime.NODEJS_18_X,
architecture: Architecture.ARM_64,
handler: handlerName,
bundling: {
sourceMap: true,
minify: true,
tsconfig: tsConfigPath,
},
});
In the above code, we first destructure parameters from our props object and then use the NodejsFunction
construct to create a Lambda function. This Lambda function is then set as the value of the readonly property we created.
For our architecture, we went with Architecture.ARM_64
for the benefits like cost efficiency, performance and energy efficiency over the default of Architecture.X86_64
that the NodejsFunction
runs with. We also went with Runtime.NODEJS_18_X
as opposed to the default of Runtime.NODEJS_LATEST
out of caution, as I’ve been burned by Node version issues in the past. But in reality, for our app, the default would have been fine.
With the snippets above, we have everything we need to be able to successfully create Lambda functions. The above can also be easily extended to include things like log groups, deployment options, alarms and much more. If we combine the above snippets we get this:
import { Stack } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import { Architecture, Runtime } from "aws-cdk-lib/aws-lambda";
type LambdaFunctionProps = {
serviceName: string;
stage: string;
entryPath: string;
handlerName?: string;
tsConfigPath: string;
}
export class TSLambdaFunction extends Construct {
public readonly tsLambdaFunction: NodejsFunction;
constructor(scope: Stack, id: string, props: LambdaFunctionProps) {
super(scope, id);
const {
serviceName,
stage,
entryPath,
handlerName = 'handler',
tsConfigPath
} = props;
this.tsLambdaFunction = new NodejsFunction(this, `${serviceName}-${id}-${stage}`, {
entry: entryPath,
runtime: Runtime.NODEJS_18_X,
architecture: Architecture.ARM_64,
handler: handlerName,
bundling: {
sourceMap: true,
minify: true,
tsconfig: tsConfigPath,
},
});
}
}
Time to Handle Our Handlers
With our custom Lambda function construct now constructed, we can turn our attention to the placeholder handlers we created in the previous episode. As a refresher, this is what we currently have:
export const sugar = async () => {
return {
statusCode: 200,
body: {
message: "Hello, Sugar World!"
},
};
}
This extremely basic placeholder won't fulfil the mission we need it to:
Decide which energy drink we should drink
We will stick to a basic approach in how we’re going to figure out which drink we’ll be given so we don’t get too far away from the mission of this post:
To learn how to create and deploy a Lambda function using CDK
To contain everything to the Lambda handler, we can use a very basic implementation as follows:
type sugarEvent = {
sugar: boolean
}
export const sugar = async (event: sugarEvent) => {
const {sugar} = event;
if(sugar) {
return {
drinkName: 'Relentless'
}
}
throw new Error('Sugar boolean is false, should be true');
}
The above snippet shows the Lambda function handler receives an event that contains a sugar
parameter that is a boolean. There is then a simple check in the code to detect if the sugar
boolean is true or not. If it is true we return the drinkName
of Relentless
. Otherwise, we throw an error. If you think back to our Step Function, we will have two separate handlers for the sugar and the sugar-free path. This means that if our sugar
Lambda Function receives a boolean that is false, something has gone wrong somewhere. For our sugarFree
Lambda Function we can use the same structure:
type sugarEvent = {
sugar: boolean
}
export const sugarFree = async (event: sugarEvent) => {
const {sugar} = event;
if(!sugar) {
return {
drinkName: 'Relentless Sugar Free'
}
}
throw new Error('Sugar boolean is true, should be false');
}
Step Back to Our Definition
Custom Lambda function construct, check. Updated Lambda handlers, check. We’re making good progress so far, so next up is updating our Step Function definition. Don’t be scared! There isn’t much we actually need to update. Let’s first have a refresher on what our current definition looks like:
const definition = storeRawItem.addCatch(failState).next(
validatePayload
.when(Condition.isPresent('$.sugar'), isSugarFree
.when(Condition.booleanEquals('$.sugar', true), new LambdaInvoke(stack, 'Sugar Logic', {
lambdaFunction: sugarLambdaFunction,
}).addCatch(sugarPassState, { errors: ['States.ALL'] }))
.otherwise(new LambdaInvoke(stack, 'Sugar Free Logic', {
lambdaFunction: sugarFreeLambdaFunction,
}).addCatch(sugarFreePassState, { errors: ['States.ALL'] }))
)
.otherwise(failState)
);
Looking at the above definition, we can see that the Lambda functions are being invoked depending on the presence of and the value of the sugar
parameter in the Step Function input. To work out what updates we need to make to the Step Function definition, let’s consider the updates made to our Lambda Function handlers.
The handlers now return an object with a drinkName
parameter. This parameter contains the value of the drink we have been assigned. With this in mind, we should get that drinkName
parameter from the Lambda Function so we can return it to the user that triggered the Step Function. We can do this by updating the definition like this:
const definition = storeRawItem.addCatch(failState).next(
validatePayload
.when(Condition.isPresent('$.sugar'), isSugarFree
.when(Condition.booleanEquals('$.sugar', true), new LambdaInvoke(stack, 'Sugar Logic', {
lambdaFunction: sugarLambdaFunction,
resultSelector: {
drinkName: JsonPath.stringAt('$.Payload.drinkName')
}
}).addCatch(sugarPassState, { errors: ['States.ALL'] }))
.otherwise(new LambdaInvoke(stack, 'Sugar Free Logic', {
lambdaFunction: sugarFreeLambdaFunction,
resultSelector: {
drinkName: JsonPath.stringAt('$.Payload.drinkName')
}
}).addCatch(sugarFreePassState, { errors: ['States.ALL'] }))
)
.otherwise(failState)
);
On the whole, the definition hasn’t changed much at all. The use of resultSelector
enables us to get the result from the Lambda Function and then set it as the Step Function data. So when we run the Step Function now, the result would be formatted like this:
{
"drinkName": "Relentless Sugar Free"
}
For our needs, that’s all we need to update when considering the Step Function definition. Not so scary after all, right?
Stack Up Those Updates
Our Lambda Function construct is ready to go, the handler code has been updated and the Step Function definition is all good, so what next? Let’s take a look at our EnergyDrinkSelectorStepFunctionStack
and see what updates we may need there.
The stack should currently look like this:
import path = require("path");
import { Construct } from "constructs";
import { NestedStack, NestedStackProps } from "aws-cdk-lib";
import { StateMachine } from "aws-cdk-lib/aws-stepfunctions";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import { Architecture, Runtime } from "aws-cdk-lib/aws-lambda";
import { ITable } from "aws-cdk-lib/aws-dynamodb";
import { ExpressStepFunction } from "../constructs/StepFunctions/ExpressStepFunction";
import { energyDrinkSelectorDefinition } from "../src/stepFunctions/definitions/energyDrinkSelector";
type EnergyDrinkSelectorStepFunctionStackProps = NestedStackProps & {
table: ITable;
}
export class EnergyDrinkSelectorStepFunctionStack extends NestedStack {
public readonly stepFunction: StateMachine;
constructor(scope: Construct, id: string, props: EnergyDrinkSelectorStepFunctionStackProps) {
super(scope, id, props);
const { table } = props;
const sugarFreeLambdaFunction = new NodejsFunction(this, 'SugarFreeLambdaFunction', {
entry: path.join(__dirname, '../src/functions/sugarFree/sugarFree.ts'),
runtime: Runtime.NODEJS_18_X,
architecture: Architecture.ARM_64,
handler: 'sugarFree',
bundling: {
sourceMap: true,
minify: true,
tsconfig: path.join(__dirname, '../tsconfig.json'),
},
});
const sugarLambdaFunction = new NodejsFunction(this, 'SugarLambdaFunction', {
entry: path.join(__dirname, '../src/functions/sugar/sugar.ts'),
runtime: Runtime.NODEJS_18_X,
architecture: Architecture.ARM_64,
handler: 'sugar',
bundling: {
sourceMap: true,
minify: true,
tsconfig: path.join(__dirname, '../tsconfig.json'),
},
});
const energyDrinkSelectorStepFunction = new ExpressStepFunction(this, 'EnergyDrinkSelectorExpress', {
serviceName: 'energy-drink-selector',
stage: 'dev',
definition: energyDrinkSelectorDefinition({
stack: this,
energyDrinkTable: table,
sugarLambdaFunction: sugarLambdaFunction,
sugarFreeLambdaFunction: sugarFreeLambdaFunction
}),
});
this.stepFunction = energyDrinkSelectorStepFunction.stateMachine;
}
};
Hopefully, you can see a few places where we can make some updates to make use of our new TSLambdaFunction
construct and maybe a little housekeeping, too.
The first thing we can do is take the tsconfig
path we’re creating and store that in a variable before we create our Lambda functions. Let’s add that underneath the destructuring of the table
parameter from the props object:
const tsConfigPath = path.join(__dirname, '../tsconfig.json');
Next, we can update how we’re creating the Lambda functions in the stack. We can use our shiny new L3 construct but we’ll first need to import it, let’s add the import at the top of the file with the rest of the imports and remove the NodejsFunction
import:
import path = require("path");
import { Construct } from "constructs";
import { NestedStack, NestedStackProps } from "aws-cdk-lib";
import { StateMachine } from "aws-cdk-lib/aws-stepfunctions";
import { ITable } from "aws-cdk-lib/aws-dynamodb";
import { ExpressStepFunction } from "../constructs/StepFunctions/ExpressStepFunction";
import { energyDrinkSelectorDefinition } from "../src/stepFunctions/definitions/energyDrinkSelector";
import { TSLambdaFunction } from "../constructs/LambdaFunction/TSLambdaFunction";
With our imports updated, we can now use the TSLambdaFunction
construct in place of the NodejsFunction
construct. We can go from this:
const sugarFreeLambdaFunction = new NodejsFunction(this, 'SugarFreeLambdaFunction', {
entry: path.join(__dirname, '../src/functions/sugarFree/sugarFree.ts'),
runtime: Runtime.NODEJS_18_X,
architecture: Architecture.ARM_64,
handler: 'sugarFree',
bundling: {
sourceMap: true,
minify: true,
tsconfig: path.join(__dirname, '../tsconfig.json'),
},
});
const sugarLambdaFunction = new NodejsFunction(this, 'SugarLambdaFunction', {
entry: path.join(__dirname, '../src/functions/sugar/sugar.ts'),
runtime: Runtime.NODEJS_18_X,
architecture: Architecture.ARM_64,
handler: 'sugar',
bundling: {
sourceMap: true,
minify: true,
tsconfig: path.join(__dirname, '../tsconfig.json'),
},
});
To this:
const sugarFreeLambdaFunction = new TSLambdaFunction(this, 'SugarFreeLambdaFunction', {
serviceName: 'energy-drink-selector',
stage: 'dev',
handlerName: 'sugarFree',
entryPath: path.join(__dirname, '../src/functions/sugarFree/sugarFree.ts'),
tsConfigPath
});
const sugarLambdaFunction = new TSLambdaFunction(this, 'SugarLambdaFunction', {
serviceName: 'energy-drink-selector',
stage: 'dev',
handlerName: 'sugar',
entryPath: path.join(__dirname, '../src/functions/sugar/sugar.ts'),
tsConfigPath
});
Looks a bit neater and nicer, right? It remains somewhat familiar to the implementation of the NodejsFunction
construct but becomes a little easier to read and has our defaults for bundling and other parameters handled. With these updates our EnergyDrinkSelectorStepFunctionStack
should look like this:
import path = require("path");
import { Construct } from "constructs";
import { NestedStack, NestedStackProps } from "aws-cdk-lib";
import { StateMachine } from "aws-cdk-lib/aws-stepfunctions";
import { ITable } from "aws-cdk-lib/aws-dynamodb";
import { ExpressStepFunction } from "../constructs/StepFunctions/ExpressStepFunction";
import { energyDrinkSelectorDefinition } from "../src/stepFunctions/definitions/energyDrinkSelector";
import { TSLambdaFunction } from "../constructs/LambdaFunction/TSLambdaFunction";
type EnergyDrinkSelectorStepFunctionStackProps = NestedStackProps & {
table: ITable;
}
export class EnergyDrinkSelectorStepFunctionStack extends NestedStack {
public readonly stepFunction: StateMachine;
constructor(scope: Construct, id: string, props: EnergyDrinkSelectorStepFunctionStackProps) {
super(scope, id, props);
const { table } = props;
const tsConfigPath = path.join(__dirname, '../tsconfig.json');
const sugarFreeLambdaFunction = new TSLambdaFunction(this, 'SugarFreeLambdaFunction', {
serviceName: 'energy-drink-selector',
stage: 'dev',
handlerName: 'sugarFree',
entryPath: path.join(__dirname, '../src/functions/sugarFree/sugarFree.ts'),
tsConfigPath
});
const sugarLambdaFunction = new TSLambdaFunction(this, 'SugarLambdaFunction', {
serviceName: 'energy-drink-selector',
stage: 'dev',
handlerName: 'sugar',
entryPath: path.join(__dirname, '../src/functions/sugar/sugar.ts'),
tsConfigPath
});
const energyDrinkSelectorStepFunction = new ExpressStepFunction(this, 'EnergyDrinkSelectorExpress', {
serviceName: 'energy-drink-selector',
stage: 'dev',
definition: energyDrinkSelectorDefinition({
stack: this,
energyDrinkTable: table,
sugarLambdaFunction: sugarLambdaFunction.tsLambdaFunction,
sugarFreeLambdaFunction: sugarFreeLambdaFunction.tsLambdaFunction
}),
});
this.stepFunction = energyDrinkSelectorStepFunction.stateMachine;
}
};
Go Forth and Deploy!
With those last updates, we now have everything in place to have the full working end-to-end flow! We should now have a fully working app that can fulfil the mission of suggesting what energy drink we should drink based on a sugar/sugar-free preference.
Aaaaand relax
With this episode, we have covered quite a few things, with our main focus on how we can create Lambda functions in CDK.
We created our own custom L3 construct that contains sensible defaults we want all our Lambda functions to use. Having this custom construct will also help with any updates we may make going forward. We would only have to update this construct rather than track down every use of the NodejsFunction
construct we were using in our initial implementation.
We also looked at our Step Function definition and how it needed to be updated to work with our updated Lambda handler code. We used resultSelector
to extract the data we needed from the Lambda function and used it in our Step Function data.
Updates to the EnergyDrinkSelectorStepFunctionStack
showed us how we could make use of our shiny new custom L3 construct in our stacks and replace the calls to the NodejsFunction
construct.
Thank You! Yes, You!
This episode is the last in the series, as we now have a fully working app. Hopefully, you have been able to learn something during the process, even if it’s something small; I’ll take that as a win.
This series was never meant to be the ‘Let’s CDK’ series to end all ‘Let’s CDK’ series. The aim was to introduce you to AWS CDK and guide you through how to provision, create and deploy AWS resources and create a little app in the process. There is plenty of scope to build upon what we have built so far. It would be really cool to see anything you may want to add. Feel free to fork the Github repo and let me know about anything you create/update!
Thank you for taking the time to read this series of posts and for putting up with my terrible puns!
TL;DR
If you want to look at the code and don’t want to read my ramblings, navigate to this Github repo and spy on everything we’ll be creating in the series.
Top comments (0)