Did I just invent CDK Level 4 Constructs?!?

At work we use a Multi-bus, multi-account Event Driven Architecture pattern. It works great, but a pain point is making event interfaces match across multiple git repos.

CDK Constructs are great even though they don't span multiple stacks... but what if they did? 🧐 Did I just invent CDK Level 4 Constructs?!? 💡

Let's do a quick review of the existing constructs...

  • 👶 L1: (Single Stack) Cfn* Constructs. I remember this like "Level 1 is 1:1 with cloudformation"
  • 🧒 L2: (Single Stack) Opinionated / improved L1 Constructs
  • 🧑 L3: (Single Stack!) Combinations of Constructs
  • 🦸 L4: (Multi-Stack) Multi-Stack L3 Constructs

⚡️ This "Level 4 Construct" could also apply to stacks in different accounts or regions!

Code: GitHub: CDK L4 Construct Example

What are we going to do?

This is a very basic example. We're going to create a lambda that puts an event on its bus, the bus forwards the event to another bus where a different lambda gets invoked. Like this:

L4 Construct Example.png

We're going to create two stacks that are initially identical... they just have an event bus and that bus is exposed publicly on the class.

The Stack

export class BlogL4ConstructStack extends Stack {
  bus: EventBus;
  constructor(scope: Construct, id: string, props: BlogL4ConstructStackProps) {
    super(scope, id, props);

    // could also be a lookup
    this.bus = new EventBus(this, 'Bus', {
      eventBusName: `${props.namespace}-bus`
    });
  }
}

🚫This is where the stack would normally differentiate and you'd have two custom stacks.

  • TransmitStack has a TransmitLambda
  • TransmitStack has a rule on the Bus to forward to the Receive Stack Bus
  • ReceiveStack has the Receive Lambda
  • ReceiveStack has a rule that invokes the Receive Lambda from the event
  • Grant all appropriate permissions

Things to keep in mind:

  • TransmitLambda needs putEvent access to its own bus
  • TransmitBus needs putEvent access to ReceiveBus (ReceiveBus needs to know TransmitStack's account)
  • The Transmit rule needs to know the target bus
  • Event pattern matches between Transmit and Receive Stacks

There's some chicken 🐓 and egg 🥚 stuff going on here... The ReceiveStack needs to grant putEvent access to the TransmitStack's account and the TransmitStack's rule needs to know the address of the bus on the ReceiveStack.

How are we going to do it?

We'll create a L4 Construct (which is really just a function, for now...) that takes two stacks as the input and the "source" for the event pattern.

L4 Construct

export interface L4ConstructProps {
  receiveEntry?: string;
  receiveStack: BlogL4ConstructStack;
  source: string;
  transmitEntry?: string;
  transmitStack: BlogL4ConstructStack;
}

Then we'll create the transmit lambda and rule. Normally this would be in a TransmitStack class, possibly in a separate git repo.

  const transmitFn = new NodejsFunction(transmitStack, `transmit-${source}-fn`, {
    functionName: `transmit-${source}-fn`,
    entry: transmitEntry,
    runtime: Runtime.NODEJS_14_X,
    environment: {
      EVENT_SOURCE: source,
      EVENT_BUS: transmitStack.bus.eventBusName,
    },
  });

  // allow transmitFn to put events to transmit bus
  transmitStack.bus.grantPutEventsTo(transmitFn);

  const transmitRule = new Rule(transmitStack, `transmit-${source}-rule`, {
    ruleName: `transmit-${source}-rule`,
    eventPattern: {
      source: [source],
    },
    eventBus: transmitStack.bus,
  });
  transmitRule.addTarget(new EventBusTarget(receiveStack.bus));

And then we'll create the Receive stuff (again, this would probably be in a separate git repo).

  // allow transmit bus to send events to receive bus
  // this part is a little inefficient
  new CfnEventBusPolicy(receiveStack, `receive-${source}-policy`, {
    eventBusName: receiveStack.bus.eventBusName,
    principal: transmitStack.account,
    action: 'events:PutEvents',
    statementId: `allow_put_events_for_${source}`,
  });

  const receiveFn = new NodejsFunction(receiveStack, `receive-${source}-fn`, {
    functionName: `receive-${source}-fn`,
    entry: receiveEntry,
    runtime: Runtime.NODEJS_14_X,
  });
  // invoke receiveFn from same source as transmit
  const rule = new Rule(receiveStack, `receive-${source}-rule`, {
    description: "this is some rule for the eventbus...",
    eventPattern: {
      source: [source],
    },
    eventBus: receiveStack.bus,
  });
  rule.addTarget(new LambdaFunction(receiveFn));

To call it all we need to do is call the method within the bin file:

L4Construct({ receiveStack, transmitStack, source: 'someSource' });

... which we can now do multiple times...

L4Construct({ receiveStack, transmitStack, source: 'someSource' });
L4Construct({ receiveStack, transmitStack, source: 'anotherSource' });

💪 Which would result in two lambdas in transmitStack and two lambdas in receiveStack with their own coordinated rules!

Why do this?

A big benefit of this is for simple architectures with a reusable pattern (like this example) the entire end-to-end of this pub/sub setup is self-contained in one file, so it's easy to understand. I can see everything related to this one workflow in a single file as opposed to jumping around between files/repos.

You can also cdk deploy --all to the multiple stacks (including across accounts if you have the perms for it) from one project. In this examples the lambdas were trivially basic but you could share typescript interfaces for the putEvent and the handler event. If you make this property driven (like all good CDK Constructs you could swap out the lambda handlers similar to what I did here

I think it's a fairly elegant solution for setting up repeatable patterns like this.

  • Where else could you use this?
  • How would you expand this?
  • Why wouldn't you use this?
  • Is this a great idea? A horrible idea?

I'd love to hear your thoughts. Hit me up on Twitter @martzcodes

Unfortunately I came up with this idea just after the CDK Day 2022 deadline... but I highly recommend attending for the other great talks 🚀