I've created an Advanced CDK course, you can check it out here!

BlogResumeTimelineGitHubTwitterLinkedInBlueSky

Putting code in the right places.

Cover Image for Putting code in the right places.

Last year I got the opportunity to go to re:Invent and had a great chance to meet a number of fantastic AWS community members in the flesh. The first night I was there I found myself sitting at a table with Ben Kehoe, Richard Boyd, Jeremy Daly and many others. I was surrounded by Heroes.

At one point the discussion tailed into the recently released Lambda Destinations functionality. I remember sitting there just soaking in a conversation about these various tools by some of the best in the field.

While talking Lambda Functions, something kept rattling around in my head.

Handlers are not reusable across different sources/triggers

If I write a Lambda Function, its handler is specific to the source. In other words, that handler is service-layer code. The handler's job is to transform the input event from the client to whatever the application logic needs and then register completion so the client can move on.

For example, I can write a handler that accepts messages from an SQS queue. It would transform the event into individual messages, process them, and then register its completion by a simple return (API Gateway works similarly). Compare that to a CodePipeline action handler that has to make an API call to CodePipeline to register it is complete, rather than just returning.
If you don't make this API call and just return the CodePipeline action will fail after a long timeout.

For each source, the handler has two places in code that is specific for that source, the event transform and the completion of the hanlder. Handlers are specific to the service that triggers it. It's service-layer code.

Building Handlers

Let's look at a concrete example. Recently my team decided to standardize on using Slack channels as a notification mechanism for various automation events. If a CodePipeline pipeline breaks, the #devops-automation channel will get a message. If a CloudWatch Alarm goes off, that notification goes to a channel setup for that specific product the alarm monitors; something like #ourcrm-automation.

The code itself was written in Node and deploying it was a matter of just wiring a simple handler up to a CDK NodejsFunction. It looks kinda like this:

Handler:

const notifier = require('our-notifier');
const channelId = process.env.CHANNEL_ID;
const getMessage = ({ to, from, subject, message }) => {
  return { to, from, subject, message };
}

export const handler = async (event) => {
  console.log({ event });

  const message = getMessage(event);
  notifier.notify(channelId, message);

}

CDK:

const notifierFunction = new NodejsFunction(this, 'notification', {
  environment: {
    CHANNEL_ID: props.channelId
  }
});

If you notice, I haven't shown you the CDK code yet that wires the source for the Lambda function. Depending on your source, your handler may have to change.

Let's say I want this notification to go out from a webhook call, then wiring it to an API Gateway may look something like this:

Handler:

const notifier = require('our-notifier');
const channelId = process.env.CHANNEL_ID;
const getMessage = ({ to, from, subject, message }) => {
  return { to, from, subject, message };
}

export const handler = async (event: any) => {
  console.log({ event });

  const message = getMessage(event);
  notifier.notify(channelId, message);

  return { statusCode: 200, body: JSON.stringify(message) };
}

CDK:

const notifierFunction = new NodejsFunction(this, 'notification', {
  environment: {
    CHANNEL_ID: props.channelId
  }
});

new HttpApi(this, 'webhook', {}).addRoutes({
  path: '/thing',
  integration:
    new LambdaProxyIntegration({
      handler: notifierFunction
    })
});

Contrast that to this Lambda used in a CodePipeline Action where it has to make a specific call back to CodePipeline's API to register completion:

Handler:

const notifier = require('our-notifier');
const codepipeline = require('aws-sdk').CodePipeline();
const channelId = process.env.CHANNEL_ID;
const getMessage = () => {
  return {
    to: 'all',
    from: 'codepipeline',
    subject: 'Pipeline completed',
    message: 'The pipeline has completed building a new version.'
  };
}

export const handler = async (event: any, context: any) => {
  console.log({ event });

  const message = getMessage();
  notifier.notify(channelId, message);

  const jobId = event["CodePipeline.job"].id;
  const params = {
    jobId: jobId
  };
  await codepipeline.putJobSuccessResult(params).promise();
  context.succeed("Notification sent");
}

CDK:

const notifierFunction = new NodejsFunction(this, 'notification', {
  environment: {
    CHANNEL_ID: props.channelId
  }
});
new Pipeline(this, 'example', {
  stages: [
    // removed the rest for bervity
    {
      stageName: 'Deploy',
      actions: [
        new LambdaInvokeAction({
          actionName: "Notify",
          lambda: notifierFunction
        })
      ]
    }
  ]
});

Each handler is written specifically for the source and application code. While I could write one handler that could handle both sources by combing the transformers and completers, it's fragile and probably unnecessary.

By the way, I'm purposely leaving these examples brief, this is not production-worthy code.

Code Separation

One of the values of AWS Serverless technologies is they allow you to move code that used to reside in your hands and move it to infrastructure-level services managed by AWS. Instead of using Express for API endpoints, we use API Gateway. Instead of writing an API that manages users in your application, you use Cognito. If you need a service bus, you've got EventBridge. Developers are required to manage less code and that increases productivity. That's the long term benefit of serverless technologies.

But Lambda Functions are special, they need to contain service-layer code and application-layer code. Can we bring the transform and completion work out of the handler and into the infrastructure? EventBridge can help but is heavy and taking it on just to save you a little translation doesn't always make sense. It's also an event bus, so every handler would need to handle writing a message back to EventBridge to register the completion. While this is a sound architectural pattern for a lot of things, it's not always a good choice.

So, what are we to do? Can we move this code out of the handlers and into the service layers with everything else?

What if we moved the transformation and completion code (service-level code) with the rest of our service-level code:

CDK:

new UniversalNodejsFunction(this, 'webhook-notification', {
  transform: `const transform = ({to, from, subject, message}) => ({to, from, subject, message});`,
  completion: `const completion = (event, context) => ({statusCode: 200});`,
  environment: {
    CHANNEL_ID: props.channelId
  }
});

Handler:

This new construct is now responsible for generating the appropriate handler, from the parts:

Given this handler:

const notifier = require('our-notifier');
const channelId = process.env.CHANNEL_ID;

export const handler = async (event, context) => {
  console.log({ event });

  const message = transform(event);
  notifier.notify(channelId, message);
  completion(event, context);
}

The construct would generate a new handler to use:

const notifier = require('our-notifier');
const channelId = process.env.CHANNEL_ID;

// added by generation ---
const transform = ({ to, from, subject, message }) => {
  return { to, from, subject, message };
}

const completion = (event, context) => ({ statusCode: 200 });
// ---


export const handler = async (event, context) => {
  console.log({ event });

  const message = transform(event);
  notifier.notify(channelId, message);
  completion(event, context);
}

Wow!

I know, right?

Ok, so it's not going to blow anyone's socks off. Honestly, I agree with the general sentiment in the replies of this post, that a handler is written specifically for the service it's being leveraged. Thanks to the CDK that is a much simpler thing to do and keep related pieces of code close together:

close-together

I love the NodejsFunction construct because it allows me to easily keep everything involved with an architecture in one place: my CDK stack.

I've wondered for a while if this would actually be valuable. Going to try implementing this in a few places and see if it's worth using.

I'm probably overthinking this...

I'll be honest, I almost didn't publish this because the more I wrote the more it sounded ridiculous to be trying to separate this.

Thanks to things like SAM and the CDK, the infrastructure code ( the NodejsFunction construct above) and the handler are typically really close.

I'm probably being nitpicky, but this is just an itch I've been trying to scratch for a while.

I believe, in most situations, it's entirely reasonable to write the handlers described in the beginning where the handler is tightly coupled to the source event. As I recently asked on twitter , having a generic handler is traditionally kinda ugly and as Michael said:

Why overcomplicate it?

Why? Because I'm an engineer and after spending a lot of effort trying to simplify implementations I sometimes like thinking of ways to make it harder. =-}

As always, I'd love to hear your feedback. Please use the Twitter button below to write me.