Link

Purely Functional Cloud Components with AWS CDK

Let’s continue the discussion about composition in AWS CDK. AWS development kit do not implement a pure functional approach. The abstraction of cloud resources is exposed using class hierarchy, each type represents a β€œcloud component” and encapsulates everything AWS CloudFormation needs to create the component. A shift from category of classes to category of pure functions simplifies the development by scraping boilerplate. A pure function component of type IaaC<T> is a right approach to express semantic of Infrastructure as a Code. These function takes a scope cdk.Construct and creates a new element.

type IaaC<T> = (scope: cdk.Construct) => T

The composition becomes an issue of building efficiently parent-children relations between AWS CDK components. Previous post left the vague definition of composition in the domain of pure functions. Here, we considers important composition patterns and its comparison with classical approach.

Create a stack

Typically each application defines single or few CloudFormation stacks. These stacks are bundled into the application.

class MyStack extends cdk.Stack {
  constructor(parent: cdk.App, name: string, props: cdk.StackProps) {
    super(parent, name, props);
    // Non boilerplate code is here
  }
}

const app = new cdk.App();
new MyStack(app, 'MyStack');

Purely functional semantic defines a root operator. It attaches the pure stack components to the root of CDK application.

function MyStack(scope: cdk.Construct): cdk.Construct {
  // Non boilerplate code is here
}

const app = new cdk.App();
root(app, MyStack);

The spec of root operator is

function root<T>(root: App, fn: IaaC<T>, name?: string): App

Attach resource to stack

Stack constructor instantiates β€œcloud component”. AWS CDK defines entire stack by a labelled graph. All resource are created within the scope of another resources. The root of hierarchy is application with stack nodes under it.

class MyStack extends cdk.Stack {
  constructor(parent: cdk.App, name: string, props: cdk.StackProps) {
    super(parent, name, props);
    
    new ResourceA(this, 'ResourceA', {/* ... */})
    new ResourceB(this, 'ResourceB', {/* ... */})
  }
}

Purely functional semantic defines a join operator. It attaches the pure definition of resource to the graph nodes. The logical name of the attached resources is defined by the name of a function.

function ResourceA(): cdk.Construct {/* ... */}
function ResourceB(): cdk.Construct {/* ... */}

function MyStack(scope: cdk.Construct): cdk.Construct {
  join(scope, ResourceA)
  join(scope, ResourceB)
}

The spec of join operator is

function join<T>(scope: Construct, fn: IaaC<T>): T

Create a resource

The abstraction of cloud resources is exposed using class hierarchy, each type represents a β€œcloud component” and encapsulates everything AWS CloudFormation needs to create the component. These classes defines a common constructor pattern, which takes a graph scope, nodes logical name and property of component.

class Function extends ... {
  constructor(scope: Construct, id: string, props: FunctionProps)
}

function MyFunction(scope: Construct): Function {
  return new Function(scope, 'MyFunction', 
    {
      runtime: Runtime.NODEJS_10_X,
      code: new AssetCode('./src'),
      // ...
    }
  )
}

An overhead exists in class-based approach of resource definition. Firstly, the duplication of logical name - name of function and literal constant. Secondly, we can observe that category of cloud resource is bi-parted graph. The left side is β€œcloud components”, the right side is they properties (e.g. Function <-> FunctionProps). It is possible to infer a type of β€œcloud components” by type of its property and visa verse using ad-hoc polymorphism. For example, in Scala any one can use implicit.

def iaac[T](props: T)(implicit resource: Resource[A]) = resource.construct(props)

Usage of similar technique helps us to reduce a definition purely function component to the definition of properties only. The example below is beautiful, it is a pure function.

function MyFunction(scope: Construct): FunctionProps {
  return {
    runtime: Runtime.NODEJS_10_X,
    code: new AssetCode('./src'),
    // ...
  }
}

Unfortunately, implicit is not available in TypeScript and methods of implicit simulation is not acceptable for AWS CDK because it requires maintenance of giant type mapping dictionaries.

Instead, purely functional semantic defines iaac operator - type safe factory. It takes a class constructor of β€œcloud component” as input and returns another function, which builds a type-safe association between β€œcloud component” and its property.

// type of lambda is (iaac: IaaC<FunctionProp>) => IaaC<Function>
const lambda = iaac(Function)
lambda(MyFunction)

A shift from category of classes to category of pure functions simplifies the development by scraping boilerplate. The spec of iaac operator is

type Node<Prop, Type> = new (scope: Construct, id: string, props: Prop) => Type

function iaac<Prop, Type>(f: Node<Prop, Type>): (fn: IaaC<Prop>) => IaaC<Type>

Integrations and Targets

Often, AWS CDK development requires integration of β€œcloud components”. As an example, usage of Lambda within API Gateway requires a packaging of resource into another class.

const restapi = new new RestApi(parent, 'MyApi', {/* ... */})
const lambda  = new Function(scope, 'MyFunction', {/* ... */})
const method  = new LambdaIntegration(lambda)

restapi.root.addResource('test').addMethod('GET', method)

So far, each β€œcloud components” is pure function then special composition techniques is required. Purely functional semantic defines a wrap operator. Its behavior almost identical to iaac. Its input is the constructor of integration category, the output is type safe factory.

const use = wrap(LambdaIntegration)
const method = use(lambda(MyFunction))

The spec of warp operator is

type Wrap<Prop, TypeA, TypeB> = new (scope: TypeA, props?: Prop) => TypeB

function wrap<Prop, TypeA, TypeB>(f: Wrap<Prop, TypeA, TypeB>): (fn: IaaC<TypeA>) => IaaC<TypeB> {
  return (iaac) => (scope) => new f(iaac(scope))
}

Effects

iaac and wrap are primary composition operator used for application development. There is a challenge to use these operators along with native AWS CDK API because operators works with IaaC<T> category.

namespace cloud {
  export const restapi = iaac(RestApi)
  export const lambda  = iaac(Function)
  export const method  = wrap(LambdaIntegration)
}

const restapi = cloud.restapi(MyApi)
const method  = cloud.method(cloud.lambda(MyFunction))

restapi.root.addResource('test').addMethod('GET', method)

For example, the code fails to compile - addMethod requires an Integration type. Purely functional semantic resolves this issue with concept of effects which are applicable over IaaC<T>.

use({ restapi, method })
  .effect(x => x.restapi.root.addResource('test').addMethod('GET', x.method))
  .yield('restapi')

The effect is a type-class that operates with product of individual IaaC<T>. It implements methods to apply effects to product of β€œcloud components” and yields the result back. The effect function operates with pure types T. The effect returns always IaaC<T>. You have to flatten it before attach it to the stack with flat: IaaC<IaaC<T>> => IaaC<T>.

function MyApi(): IaaC<RestApi> {/* ... */}

function MyStack(stack: cdk.Construct) {
  join(stack, flat(MyApi))
}

The spec of effects is

type Product<T> = {[K in keyof T]: IaaC<T[K]>}
type Pairs<T> = {[K in keyof T]: T[K]}

class Effect<T extends Pairs<T>> {
  value: IaaC<T>
  constructor(x: IaaC<T>){this.value = x}

  effect(f: (x:T) => void): Effect<T> {/* ... */}

  yield<K extends keyof T>(k: K): IaaC<T[K]> {/* ... */} 
}

function use<T extends Pairs<T>>(resources: Product<T>): Effect<T>

Afterwords

The discussed notations complements AWS CDK with the purely functional composition. This style of development builds a new things from small reusable β€œcloud components”. A shift from category of classes to category of pure functions simplifies the development by scraping boilerplate.

The features discussed here are implemented at aws-cdk-pure library.

Feel free to share your comments and thought on Twitter or raise and issue to GitHub.