Zac Fukuda
073

Composite Pattern over Inheritance for CDK Constructs

AWS Cloud Development Kit(CDK) is an open-source framework that lets you define cloud infrastructure in code (using programming languages like TypeScript, Python, and Java) and provision it through AWS CloudFormation. In CDK, there is a object called Construct. Constructs are the basic building blocks of CDK applications. A construct is a component within your application that represents one or more AWS CloudFormation resources and their configuration.

Whether your IaC tool is Terraform, CloudFormation, or CDK, you will soon find yourself overwhelmed by hundreds lines of code in a file. So you break things apart. In CDK, you will split a magnificent file into one-file for one-construct level.

I want to give a thought how to do that.

Composite v.s. Inheritance

CDK library is objected-oriented. That means variety of solutions are available. You can practice such as Composite and Factory patterns. You can also create inheritances of AWS managed Construct classes like Function. That brings up the issue you never need to face with Terraform or CloudFormation. Between composite and inheritance, which one is better for CDK application?

Generally accepted standard is this: prefer Composite to inheritance. When you search “composite v.s. inheritance” on the Internet, you can find tons of web pages on the topic. None prefers inheritance. Inheritance is old; inheritance is inflexible; inheritance is complex.

Is that so? I want to find out. Here is a code of an imaginary CDK stack.

class SomeStack extends Stack {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    // …other resources

    const targetFunction = new Function(this, 'SomeRuleToLambdaTargetFunction', {
      // …
    });
    const deadLetterQueue = new Queue(this, 'SomeRuleToLambdaDeadLetterQueue', {
      // …
    });
    const target = new LambdaFunction(targetFunction, { deadLetterQueue });
    new Rule(this, 'SomeRuleToLambda', {
      targets: [target],
      // …
    });

    // …other resources
  }
}

Besides resources not described in the example, the stack contains three resources: a Lambda function, an SQS queue, and an EventBridge rule. This is a typical AWS pattern. The EventBridge rule catches some events, be it payment, and bypasses the event to the Lambda function, which handles the additional business. If the invocation of the Lambda function fails, the EventBridge rule stores the failed event in an SQS queue.

By the time you define these resources, your stack is huge. It already loses the readability and maintainability.

So how can we split our classic EventBridge related resources into smaller files and classes with inheritance and composite?

Inheritance

Inheritance

The first thing you come up with to organize your EventBridge code is as follows. The Lambda function and the SQS queue are both associated to the EventBridge rule. So let’s make a custom Rule subclass that wraps the two resources.

 // lib/some-rule-to-lambda.ts
class SomeRuleToLambda extends Rule {
  constructor(scope: Construct, id: string) {
    super(scope, id, {
      // …,
    });

    const targetFunction = new Function(this, 'TargetFunction', {
      // …
    });
    const deadLetterQueue = new Queue(this, 'DeadLetterQueue', {
      // …
    });
    const target = new LambdaFunction(targetFunction, { deadLetterQueue });

    this.addTarget(target);
  }
}

// lib/some-stack.ts
class SomeStack extends Stack {
 constructor(scope: Construct, id: string) {
    super(scope, id);

    // …other resources
    new SomeRuleToLambda(this, 'SomeRuleToLambda');
    // …other resources
  } 
}

The detail how the rule associates with other resources is encapsulated from the stack. The stack became more manageable. Your successful experience pushes you more to make more of subclasses.


// lib/some-rule-to-lambda/some-rule-to-lambda-function.ts
class SomeRuleToLambdaFunction extends Function {
  constructor(scope: Construct, id: string) {
    super(scope, id, {
      // …
    });
  }
}

// lib/some-rule-to-lambda/some-rule-to-lambda-dead-letter-queue.ts
class SomeRuleToLambdaDeadLetterQueue extends Queue {
  constructor(scope: Construct, id: string) {
    super(scope, id, {
       // …
    });
  }
}

// lib/some-rule-to-lambda/some-rule-to-lambda.ts
class SomeRuleToLambda extends Rule {
  constructor(scope: Construct, id: string) {
    super(scope, id, {
      // …
    });

    const targetFunction = new SomeRuleToLambdaFunction(this, 'TargetFunction');
    const deadLetterQueue = new SomeRuleToLambdaDeadLetterQueue(this, 'DeadLetterQueue');
    const target = new LambdaFunction(targetFunction, { deadLetterQueue });

    this.addTarget(target);
  }
}

// lib/some-stack.ts
class SomeStack extends Stack {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    // …other resources
    new SomeRuleToLambda(this, 'SomeRuleToLambda');
    // …other resources
  }
}

The logical IDs of this infrastructure will be like this:

SomeStack
    SomeRuleToLambda
        TargetFunction
        DeadLetterQueue

At this moment my source code looks no problem.

Composite

Inheritance

Let’s try to refactor my first stack with a composite, although the composite class inherits Construct class.

// lib/some-rule-to-lambda-composite.ts
class SomeRuleToLambdaComposite extends Construct {
  private readonly rule: Rule;
  private readonly targetFunction: Function;
  private readonly deadLetterQueue: Queue;

  constructor(scope: Construct, id: string) {
    super(scope, id);

    this.rule = new Rule(this, 'Rule', {
      // …
    });
    this.targetFunction = new Function(this, 'TargetFunction', {
      // …
    });
    this.deadLetterQueue = new Queue(this, 'DeadLetterQueue', {
      // …
    });
    this.rule.addTarget(
      new LambdaFunction(this.targetFunction, {
        deadLetterQueue: this.deadLetterQueue
      })
    );
  }
}

// lib/some-stack.ts
class SomeStack extends Stack {
 constructor(scope: Construct, id: string) {
    super(scope, id);

    // …other resources
    new SomeRuleToLambdaComposite(this, 'SomeRuleToLambda');
    // …other resources
  }
}

This gives logical IDs like this:

SomeStack
    SomeRuleToLambda
        Rule
        TargetFunction
        DeadLetterQueue

Unlike with inheritance, the relationship between the rule and function is sibling, not parent-child. Same for the queue. The rule and the function have the same parent SomeRuleToLambda. Not all engineers would agree but this logical grouping makes more sense to many engineers.

Here comes the another problem though. SomeRuleToLambdaComposite class has yet around 100-line of code. You can manage it, but you see the future that the class will be more complicated as you run and expand the infrastructure over the years. You want to divide the three resources into other three classes. I have two options. The first option is to extends three resources from its original class just like you did in the Inheritance Pattern section.

// lib/some-rule-to-lambda/some-rule-to-lambda-rule.ts
class SomeRuleToLambdaRule extends Rule {
  constructor(scope: Construct, id: string) {
    super(scope, id, {
      // …
    });
  }
}

// lib/some-rule-to-lambda/some-rule-to-lambda-composite.ts
class SomeRuleToLambdaComposite extends Construct {
  private readonly rule: Rule;
  private readonly targetFunction: Function;
  private readonly deadLetterQueue: Queue;

  constructor(scope: Construct, id: string) {
    super(scope, id);

    this.rule = new SomeRuleToLambdaRule(this, 'Rule');
    this.targetFunction = new SomeRuleToLambdaFunction(this, 'TargetFunction');
    this.deadLetterQueue = new SomeRuleToLambdaDeadLetterQueue(this, 'DeadLetterQueue');
    this.rule.addTarget(
      new LambdaFunction(this.targetFunction, {
        deadLetterQueue: this.deadLetterQueue
      })
    );
  }
}

// lib/some-stack.ts
class SomeStack extends Stack {
 constructor(scope: Construct, id: string) {
    super(scope, id);

    // …other resources
    new SomeRuleToLambdaComposite(this, 'SomeRuleToLambda');
    // …other resources
  }
}

You didn’t have a subclass of Rule, so you created it here. You can reuse the same code for SomeRuleToLambdaFunction and SomeRuleToLambdaDeadLetterQueue from earlier. The logical ID didn’t change from the last version.

The second option to make the composite class smaller is to use more composites.

// lib/some-rule-to-lambda/some-rule-to-lambda-rule-composite.ts
class SomeRuleToLambdaRuleComposite extends Construct {
  public readonly rule: Rule;

  constructor(scope: Construct, id: string) {
    super(scope, id);
    this.rule = new Rule(scope, 'Rule', {
      // …
    });
  }
}

// lib/some-rule-to-lambda/some-rule-to-lambda-target-function-composite.ts
class SomeRuleToLambdaTargetFunctionComposite extends Construct {
  public readonly function: Function;

  constructor(scope: Construct, id: string) {
    super(scope, id);
    this.function = new Function(scope, 'Function', {
      // …
    });
  }
}

// lib/some-rule-to-lambda/some-rule-to-lambda-dead-letter-queue-composite.ts
class SomeRuleToLambdaDeadLetterQueueComposite extends Construct {
  public readonly queue: Queue;

  constructor(scope: Construct, id: string) {
    super(scope, id);
    this.queue = new Queue(scope, 'Queue', {
      // …
    });
  }
}

// lib/some-rule-to-lambda/some-rule-to-lambda-composite.ts
class SomeRuleToLambdaComposite extends Construct {
  private readonly rule: Rule;
  private readonly targetFunction: Function;
  private readonly deadLetterQueue: Queue;

  constructor(scope: Construct, id: string) {
    super(scope, id);

    this.rule = new SomeRuleToLambdaRuleComposite(this, 'Rule').rule;
    this.targetFunction = new SomeRuleToLambdaTargetFunctionComposite(this, 'TargetFunction').function;
    this.deadLetterQueue = new SomeRuleToLambdaDeadLetterQueueComposite(this, 'DeadLetterQueue').queue;
    this.rule.addTarget(
      new LambdaFunction(this.targetFunction, {
        deadLetterQueue: this.deadLetterQueue
      })
    );
  }
}

// lib/some-stack.ts
class SomeStack extends Stack {
 constructor(scope: Construct, id: string) {
    super(scope, id);

    // …other resources
    new SomeRuleToLambdaComposite(this, 'SomeRuleToLambda');
    // …other resources
  }
}

Your logical IDs have more nestings than before.

SomeStack
    SomeRuleToLambda
        Rule
            Rule
        TargetFunction
            Function
        DeadLetterQueue
            Queue

You never know what future holds but this depth of logical IDs looks silly. You want to split the code, want to use composite, and want to have logical IDs more flat. It is as easy as switching folk to spoon to change IDs back to the previous ones. Just do not extends Construct.

// lib/some-rule-to-lambda/some-rule-to-lambda-rule-composite.ts
class SomeRuleToLambdaRuleComposite {
  public readonly rule: Rule;

  constructor(scope: Construct, id: string) {
    this.rule = new Rule(scope, id, {
      // …
    });
  }
}

// lib/some-rule-to-lambda/some-rule-to-lambda-target-function-composite.ts
class SomeRuleToLambdaTargetFunctionComposite {
  public readonly function: Function;

  constructor(scope: Construct, id: string) {
    this.function = new Function(scope, id, {
      // …
    });
  }
}

// lib/some-rule-to-lambda/some-rule-to-lambda-dead-letter-queue-composite.ts
class SomeRuleToLambdaDeadLetterQueueComposite {
  public readonly queue: Queue;

  constructor(scope: Construct, id: string) {
    this.queue = new Queue(scope, id, {
      // …
    });
  }
}

// lib/some-rule-to-lambda/some-rule-to-lambda-composite.ts
class SomeRuleToLambdaComposite extends Construct {
  private readonly rule: Rule;
  private readonly targetFunction: Function;
  private readonly deadLetterQueue: Queue;

  constructor(scope: Construct, id: string) {
    super(scope, id);

    this.rule = new SomeRuleToLambdaRuleComposite(this, 'Rule').rule;
    this.targetFunction = new SomeRuleToLambdaTargetFunctionComposite(this, 'TargetFunction').function;
    this.deadLetterQueue = new SomeRuleToLambdaDeadLetterQueueComposite(this, 'DeadLetterQueue').queue;
    this.rule.addTarget(
      new LambdaFunction(this.targetFunction, {
        deadLetterQueue: this.deadLetterQueue
      })
    );
  }
}

// lib/some-stack.ts
class SomeStack extends Stack {
 constructor(scope: Construct, id: string) {
    super(scope, id);

    // …other resources
    new SomeRuleToLambdaComposite(this, 'SomeRuleToLambda');
    // …other resources
  }
}

You’re back to your previous logical ID structure.

SomeStack
    SomeRuleToLambda
        Rule
        TargetFunction
        DeadLetterQueue

And the second thought pops in. What’s the point of extending Construct for SomeRuleToLambdaComposite? Yes, you want to have a medium logical ID, grouping three resources bound together. But that does’t have to be an inheritance. The composite can compose Construct.

// lib/some-rule-to-lambda/some-rule-to-lambda-composite.ts
class SomeRuleToLambdaComposite {
  private readonly construct: Construct;
  private readonly rule: Rule;
  private readonly targetFunction: Function;
  private readonly deadLetterQueue: Queue;

  constructor(scope: Construct, id: string) {
    this.construct = new Construct(scope, id);
    this.rule = new SomeRuleToLambdaRuleComposite(this.construct, 'Rule').rule;
    this.targetFunction = new SomeRuleToLambdaTargetFunctionComposite(this.construct, 'TargetFunction').function;
    this.deadLetterQueue = new SomeRuleToLambdaDeadLetterQueueComposite(this.construct, 'DeadLetterQueue').queue;
    this.rule.addTarget(
      new LambdaFunction(this.targetFunction, {
        deadLetterQueue: this.deadLetterQueue
      })
    );
  }
}

SomeRuleToLambdaComposite can choose whether to group logical IDs of three resources, or to bind them to the scope passed. With this implementation, the composite have fully control over logical ID grouping. You can move authority of logical ID grouping from the composite to the stack.

// lib/some-rule-to-lambda/som-rule-to-lambda-composite.ts
class SomeRuleToLambdaComposite {
  private readonly construct: Construct;
  private readonly rule: Rule;
  private readonly targetFunction: Function;
  private readonly deadLetterQueue: Queue;

  constructor(scope: Construct, id: string) {
    this.rule = new SomeRuleToLambdaRuleComposite(scope, 'Rule').rule;
    this.targetFunction = new SomeRuleToLambdaTargetFunctionComposite(scope, 'TargetFunction').function;
    this.deadLetterQueue = new SomeRuleToLambdaDeadLetterQueueComposite(scope, 'DeadLetterQueue').queue;
    this.rule.addTarget(
      new LambdaFunction(this.targetFunction, {
        deadLetterQueue: this.deadLetterQueue
      })
    );
  }
}

// lib/some-stack.ts
class SomeStack extends Stack {
 constructor(scope: Construct, id: string) {
    super(scope, id);

    // …other resources
    new SomeRuleToLambdaComposite(new Construct(this, 'SomeRuleToLambda'));
    // …other resources
  }
}

Conclusion

The hardest thing in CDK app development is how to manage CloudFormation logical IDs. Once you deploy your infrastructure, your logical IDs are fixed. It requires a hard work to change them. It is ideal that your logical ID structure matches your file/folder structure. But that’s just idealistic. The real life is different. Let it go. Have your source code flexible, independent from the logical IDs. Composites without extending Construct help you to do that.

Even when you use composites, it is each developer’s duty how to compose resources. Objected-oriented programming has a long history. Be active to practice the art.

Here is a list of advice I’d like to give to myself, and to new CDK developers.

  • Use composite pattern. It gives more flexibility to how to organize your CDK source code and logical IDs.
  • Keep logical IDs flatten at below 2-level depth from the stack.
  • Do not extend existing constructs unless you want to publish your constructs to Construct Hub. When you extend construct, you have no choice but nest logical IDs.