PeteScript logo PeteScript

How to build AWS State Machines using AWS CDK - Part I

5 minutes read

PeteScript - How to build AWS State Machines using AWS CDK - Part I

AWS Step Functions are a critical service when tying a bunch of serverless operations together. Step Functions was its own independent service developed by AWS in order to orchestrate pieces of serverless functionality together in a cost-efficient manner.

One of the most important constructs within this service are state machines. State machines are a fairly well understood concept within software engineering, and an incredibly efficient way to thinking about how your processes interact together - particularly in an event-driven architecture.

AWS Step Function state machines can be defined in a number of different ways (JSON, YAML etc.) - which is the way I’ve always defined them myself. However, defining these state machines as infrastructure as code to take advantage of all the things that come along with doing so, sounds like a much better approach to me!

❓ What

For those that haven’t utilised AWS Step Function state machines before, they take the concept of state machines and add additional processing logic that deeply integrates with AWS serverless services in order to provide an incredibly easy interface for orchestrating complex workloads.

Think of it as a way to actually facilitate event-driven architecture using the native AWS serverless offerings, that have been enhanced to a level that can be tailored to your specific use case.

If anyone reading has ever daisy-chained lambda function invocations, state machines are something you can utilise and consider to avoid that anti-pattern

✍️ Define some CDK

Let’s set up a basic state machine to perform a couple of typical actions to showcase how easy it is to define your state machine in CDK. We’ll include a task state (that invokes a lambda function), a wait state, another task state (that writes to a DynamoDB table) before finishing the state machine and setting the final state.

The outcome will look something like the following:

State machine definition

For the purposes of this example, we’ll just reference an existing lambda function and DynamoDB table that are already deployed in the AWS account you are targeting with this stack.

import * as cdk from "aws-cdk-lib";
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
import * as lambda from "aws-cdk-lib/aws-lambda";

const mockLambdaFunctionArn =
  "arn:aws:lambda:us-east-1:12345:function:my-shiny-lambda-function";
const mockDdbTableArn =
  "arn:aws:dynamodb:us-east-1:12345:table/my-shiny-dynamodb-table";

const lambdaFunction = lambda.Function.fromFunctionArn(
  this,
  "lambda-function",
  mockLambdaFunctionArn
);
const dynamodbTable = dynamodb.Table.fromTableArn(
  this,
  "dynamo-db-table",
  mockDdbTableArn
);

Now that we have our resources that we are going to trigger and reference, let’s first define the lambda invocation in the state machine:

import * as stepFunctionsTasks from "aws-cdk-lib/aws-stepfunctions-tasks";

const processJob = new stepFunctionsTasks.LambdaInvoke(
  this,
  "state-machine-process-job-fn", {
    lambdaFunction: lambdaFunction,
  }
);

You may be wondering, but we’re just defining a task within the state machine - where do we actually create the definition and use this task within it? Well, we’ll come to that whenever we’re chaining all of our actions together.

Next, we want a wait state in our definition - we can achieve this in a similar way using the CDK API:

import * as cdk from "aws-cdk-lib";
import * as stepFunctions from "aws-cdk-lib/aws-stepfunctions";

const wait10MinsTask = new stepFunctions.Wait(
  this,
  "state-machine-wait-job", {
    time: stepFunctions.WaitTime.duration(cdk.Duration.minutes(10)),
  }
);

Finally, we want to create our DynamoDB operation to round off the state machine:

import * as stepFunctionsTasks from "aws-cdk-lib/aws-stepfunctions-tasks";

const ddbWrite = new stepFunctionsTasks.DynamoPutItem(
  this,
  "ddb-write-job", {
    item: {
      uuid: stepFunctionsTasks.DynamoAttributeValue.fromString(
        crypto.randomUUID()
      ),
      timestamp: stepFunctionsTasks.DynamoAttributeValue.fromString(
        new Date().toISOString()
      ),
    },
    table: dynamodbTable,
  }
);

You’ll notice that we’re using the aws-stepfunctions-tasks specifically library. This has been developed in order to provide a clean interface for defining the sorts of operations that can be performed within a Task block inside the state machine (which includes operations like invoke lambda function, put item in DynamoDB table, push message to SQS queue etc.)

Now that we’ve defined each stage within our state machine, we can create the definition using the chainable methods before finally provisioning the construct.

import * as stepFunctions from "aws-cdk-lib/aws-stepfunctions";

const stateMachineDefinition = processJob
  .next(wait10MinsTask)
  .next(ddbWrite);

const stateMachine = new stepFunctions.StateMachine(this, "state-machine", {
  definitionBody: stepFunctions.DefinitionBody.fromChainable(
    stateMachineDefinition
  ),
  timeout: cdk.Duration.minutes(5),
  stateMachineName: "ProcessAndReportJob",
});

In the above snippet, you can see that we are chaining various stages in the state machine together using the .next() method. It consumes a reference to the next block/stage in the state machine and builds up the definition dynamically.

We then finally provision the new state machine construct and pass it this definition.

Note that the official CDK docs are slightly out of date and definition is now deprecated in favour of definitionBody. Although, this does require the additional fromChainable parsing method to be applied.

And that should be good to deploy!

🧠 Advantages

  • Facilitate the serverless operations in a single place, with executions of a static state machine that just processes inputs/outputs
  • Monitor the executions of a state machine
  • Fully-managed service, so don’t worry about lower-level issues such as memory and CPU.

Conclusion

  • CDK makes it incredibly easy to define your state machines and reference all of the functionality that you require the tasks in it to perform.
  • Defining your state machines with CDK means that you can validate the props and references much easier than when using JSON or YAML.
  • CDK has a couple of supporting libraries for state machines and specifically, for the actions that task items within the state machine can support. This makes the DX super smooth and intuitive.
  • This is going to be the first post in a series of how we can use CDK to perform different state machine operations and define the various operations within state machines.