The Builder Pattern in CDK

Photo by Henry & Co. on Unsplash

The Builder Pattern in CDK

Simplifying our classes

One problem I did struggle with when extending constructs in CDK is how to handle the growing number of parameters.

So we start with a fairly simple construct in CDK like lambda.

  1. various optional components should be added like:

    1. networking access to our private VPC (Data Center in the cloud).

    2. SQS read access

    3. Encryption permissions to decrypt SQS/S3 objects

    4. Kafka access

This easily expands to 4 to 8 parameters in the construct so a new lambda call may look something like this:

import { Function } from "aws-cdk/@aws-lambda";

// scope is required so typically we use a Stack/Construct to pass it in
...
const func = new lambda.Function(this, "MyFunction", {
    vpc, 
    securityGroups,

});
queue.grantRead(func.role);
key.grantDecrypt(func.role);
topic.grantRead(func.role);

There is some missing details there we can gloss over for now. We can now split this into constructs that extend each other

import { Function } from "aws-cdk/@aws-lambda";

// scope is required so typically we use a Stack/Construct to pass it in
export default class LambdaFunction extends Function{
    constructor(scope, name, props){
        super(scope, name, props);
    }
}

Now we have a construct extending the default function. That's great and now we can extend for each functionality. KafkaFunction, NetworkFunction and QueueFunction.

Inheritance vs Composition

This is where Inheritance in CDK can lead you down a nasty path of ever-growing properties. Instead, if we do a composition pattern where we build up a class that has the constructs underneath it we can add these properties.

Builder pattern

On to the builder pattern. What if instead of making an instance of function up front which requires we have the properties upfront or do some modification to the raw Cfn Construct underneath we build it up and then at the end return an instance of function.

//Lets have a look how this works.
import * as cdk from "aws-cdk/@aws-core";
import { FunctionProps } from "aws-cdk/@aws-lambda";
import { Vpc, SecurityGroup } from "aws-cdk/@aws-ec2";

export default class LambdaBuilder {
    props: FunctionProps;    
    constructor(private scope: cdk.Stack, private name: string){
        this.props = {
        };
    }

    addNetworking(vpc: Vpc, securityGroup: SecurityGroup) : LambdaBuilder {
        props.vpc = vpc;
        props.securityGroup = securityGroup;
        return this;
    }
    // we can further improve this by splitting up the builder if it makes sense
    addQueueRead(queue: Queue) : LambdaBuilder {
         queue.grantRead(role);   
        return this;
    }

    build() : Function {
        const func = new Function(this.scope, this.name, this.props);
        return func;
    }
}

The problem now is we don't have a role to grant permissions to each resource before the lambda function is created.
We can:

  1. Create a role before creating the lambda function.

  2. Or keep a reference to the queue.

  3. Or create a policy to attach.

//Lets have a look how this works.
import * as cdk from "aws-cdk/@aws-core";
import { FunctionProps } from "aws-cdk/@aws-lambda";
import { Vpc, SecurityGroup } from "aws-cdk/@aws-ec2";
import { Role, ServicePrincipal } from "aws-cdk/@aws-iam";

export default class LambdaBuilder {
    props: FunctionProps;   
    role: Role; 
    constructor(private scope: cdk.Stack, private name: string){
        this.props = {
        };
        this.role = new Role(this.scope, `${name}Role`, {
            assumedBy: new ServicePrincipal("lambda.amazon.com");    
        });
    }

    addNetworking(vpc: Vpc, securityGroup: SecurityGroup) : LambdaBuilder {
        props.vpc = vpc;
        props.securityGroup = securityGroup;
        return this;
    }
    // we can further improve this by splitting up the builder if it makes sense
    addQueueRead(queue: Queue) : LambdaBuilder {
         queue.grantRead(this.role);   
        return this;
    }

    build() : Function {
        const func = new Function(this.scope, this.name, this.props);
        return func;
    }
}

With this, we can now method chain our calls when calling the builder to resolve our function. This is now much more extensible where we can create a builder, extend it and inherit all the parent methods. the objects may need to become protected so the child class can see those props and methods.

The role is created by default when creating a function. So we'll see another way to write this.

Another way of doing the builder pattern is to provide a Construct props builder. Java CDK has this feature out of the box.


Bucket bucket = new Bucket(this, "MyBucket", new BucketProps.Builder()
                           .versioned(true)
                           .encryption(BucketEncryption.KMS_MANAGED)
                           .build());

Now if we move the methods into a PropsBuilder we get something like this

//Lets have a look how this works.
import * as cdk from "aws-cdk/@aws-core";
import { FunctionProps } from "aws-cdk/@aws-lambda";
import { Vpc, SecurityGroup } from "aws-cdk/@aws-ec2";
import { IQueue } from "aws-cdk/@aws-sqs";
import { IKey } from "aws-cdk/@aws-kms";
import { Role, ServicePrincipal } from "aws-cdk/@aws-iam";

export default class FunctionPropsBuilder {
    props: FunctionProps;   

    constructor(){
        this.props = {
        };
    }

    addNetworking(vpc: Vpc, securityGroup: SecurityGroup) : LambdaBuilder {
        props.vpc = vpc;
        props.securityGroup = securityGroup;
        return this;
    }

    addEnvironmentEncryption(key: IKey){
        props.environmentEncryption = key;
    }

    addDeadLetterQueue(dlq: IQueue){
        props.deadLetterQueue = dlq;
    }

    build() : Function {
        const func = new Function(this.scope, this.name, this.props);
        return func;
    }
}

And with the builder props the extra steps that complicate and slow down our tests.

What did we gain?

We're able to create multiple lambda constructs with similarities. We've avoided creating a multitude of subclasses from the base Function class with a single method in each subclass. We finally resolve the properties at the end and pass props into a vanilla construct. This in turn allows us to speed up testing, move the methods into a common builder and make it more readable.

Java CDK Builder pattern

Design patterns in Typescript