AWS Lambdas with TypeScript: Improve the Dev Experience
Tags: typescript, javascript
Published: 2022-11-02 11:37:48

In part one of this series, we successfully built a TypeScript Lambda on the AWS cloud. But we left a lot of room for improvement in terms of the developer experience. For starters, the Lambda didn’t run on a local machine, which is cumbersome. The code we wrote is also not testable, which makes refactoring hard or, at least, dangerous.

In this take, let’s focus on improving the developer experience. The goal is to make the code more robust and easier to work with.

Ready? Let’s go!

## Run Your Lambda Locally

The endless loop of publishing changes up to the cloud to verify even a minor text change can easily drain developer productivity. Time wasted waiting on the latest upload can be better spent elsewhere, like on the code itself.

In programming, this is also called the _feedback loop_: the time that elapses when verifying that a change actually works. Right now, our feedback loop is dismal since every republish can take a while. So let’s fix that, shall we?

_Note: If you get lost while following along, feel free to check out the [GitHub repo](https://github.com/beautifulcoder/typescript-aws-lambda), which now has a part two._

First off, install the `claudia-local-api` NPM package as a dev dependency. Don’t forget to change the directory to your root folder, like `pizza-api`.

```shell
npm i claudia-local-api --save-dev
```

Open your `package.json` file and add this to the `scripts` section:

```json
{
  "preserver": "npm run type-check",
  "server": "claudia-local-api --api-module dist/api.js | bunyan --output short"
}
```

The technique used in `preserver` can be called script-chaining. This reuses predefined scripts, so you don’t have to repeat yourself. Try setting all other `pre` scripts like `precreate` and `preupdate` to the same value as `preserver`. This guarantees at least some consistency across all NPM scripts and makes them more predictable.

Next, run your local server. The `claudia-local-api` package uses Express to execute your Lambda locally, proxying all requests from the Express server to the Claudia API builder. This is very similar to how the AWS gateway fires events to your Lambda function on the cloud. The Lambda itself does not handle HTTP requests but depends on Express locally or the gateway in AWS to execute.

On the cloud, this also means the AWS gateway does more work. A '401 Unauthorized', for example, does not execute the Lambda, which lowers its cost since you only pay-per-use. Executing the Lambda locally also incurs no cost unless the code uses other AWS resources like S3 storage.

To run your Lambda locally, simply run `npm run server`. You should see some nice logging from `bunyan` telling you the app is listening on port 3000.

If you have been following along, you should have a `Pepperoni-Pizza` already pre-made. We can now taste our pizza locally.

```shell
curl -X GET -i -H "Accept-Type: application/json" -H "Content-Type: application/json" http://localhost:3000/pizzas/Pepperoni-Pizza
```

<Banner goal="node_banner_from_blog" />

If you run into any problems when executing your Lambda locally, please check the logs. Typically, the likely issue is that the Claudia API builder can’t find a route match, or Express can’t parse the request properly. Be sure to specify `Accept-Type` and `Content-Type` headers to minimize any confusion.

You may be wondering how we can still get Pizza data from DynamoDB. In fact, you can even make new pizzas and taste them locally. This is because the DynamoDB package uses your local AWS `credentials` file to authenticate on the cloud. The AWS CLI tool also uses this technique to talk to DynamoDB from your local machine.

Running code locally vastly improves the feedback loop. But can we do better?

## Hexagonal Architecture for Your TypeScript App

One way to tackle unwieldy code is to separate concerns. You may have noticed everything lives in the `api.ts` file, which can grow quite large and become harder to change over time.

The [hexagonal architecture pattern](https://alistair.cockburn.us/hexagonal-architecture/) breaks up the app into ports and adapters, and creates loosely coupled components that can easily connect to external dependencies. This technique works well with Lambdas because we want to keep the implementation minimal and as decoupled as possible.

Therefore, our Lambda function can be broken up into three distinct parts: the AWS gateway event, our Claudia API builder, and the DynamoDB repository.

For example:

![hexagonal architecture pattern](https://blog.appsignal.com/images/blog/2022-10/hexagonal-architecture.png)

Luckily, we get the gateway event for free, which keeps our code minimal. This means we can split the code into two separate concerns.

Create a folder named `repositories` from the root folder. Then, create a `PizzaDb.ts` file — this is where the DynamoDB code will go. Imports will not be included, as you can copy-paste this code from the existing `app.ts` file. For this refactor, you can also rely on the TypeScript compiler.

```typescript
export const makePizza = async (pizza: any) => {
  // interface
  if (
    !pizza ||
    !pizza.name ||
    !pizza.ingredients ||
    !Array.isArray(pizza.ingredients) ||
    pizza.ingredients.length === 0
  ) {
    throw new Error("To make a pizza include name and ingredients");
  }

  const { name, ingredients } = pizza;
  const pizzaItem = {
    url: { S: slugify(name) },
    name: { S: name },
    ingredients: { SS: ingredients },
  };

  await client.send(
    new PutItemCommand({
      TableName: "pizzas",
      Item: pizzaItem,
    })
  );

  console.log("Pizza is saved!", pizzaItem);
  return pizzaItem;
};

export const tastePizza = async (url: string) => {
  // interface
  const res = await client.send(
    new GetItemCommand({
      TableName: "pizzas",
      Key: { url: { S: url } },
    })
  );
  const pizzaItem = res.Item;

  if (pizzaItem === undefined) throw new Error("Pizza not found!");

  return pizzaItem;
};
```

This is classic ports and adapters code via an external interface. Note this code is no longer concerned with the raw AWS gateway event, but a pizza object with specific fields. The `makePizza` and `tastePizza` functions get exported to the consuming code without much ceremonial code.

On the Claudia API side, simply bring in repository dependencies and hook into the AWS gateway event. You can make this change to the `api.ts` file now.

```typescript
import { makePizza, tastePizza } from "./repositories/PizzaDb";

api.post("/pizzas", async (request: any) => await makePizza(request.body), {
  // port code
  success: 201,
  error: 400,
});

api.get(
  "/pizzas/{url}",
  async (request: any) => await tastePizza(request.pathParams.url),
  {
    // port code
    success: 200,
    error: 404,
  }
);
```

This simplifies our code quite a bit and makes things easier to refactor. The only concern here is the AWS gateway event and the HTTP response. Like before, errors get handled automatically with a proper response. The `request` event type gets ported over so the adapter code can use it. For example, `request.body` has the pizza object, and `request.pathParams` has the `url` parameter to taste our pizza.

Now our code is more robust and easier to change. One bonus is the ability to test our code. Turns out, _clean code_ and testable code go hand-in-hand. Let’s look at that next.

## TypeScript Unit Tests

Before we can start writing unit tests, install the NPM dev dependencies:

```shell
npm i @types/chai @types/chai-as-promised @types/mocha @types/sinon chai chai-as-promised mocha sinon ts-node --save-dev
```

Then, configure your `package.json` by adding this under `scripts`:

```json
{
  "test": "mocha --require ts-node/register ./test/*.ts"
}
```

Now create a `test` folder, then a `PizzaDb.test.ts` file inside this test folder.

```typescript
import chai, { expect } from "chai";
import chaiAsPromised from "chai-as-promised";
import sinon, { SinonStub } from "sinon";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { makePizza } from "../repositories/PizzaDb";

chai.use(chaiAsPromised);

describe("PizzaDb", () => {
  let send: SinonStub;

  let pizzaItem = {
    url: { S: "P" },
    name: { S: "P" },
    ingredients: { SS: ["A", "B"] },
  };

  beforeEach(() => {
    send = sinon.stub(DynamoDBClient.prototype, "send"); // stub
  });

  afterEach(() => {
    send.restore();
  });

  it("makePizza#success", async () => {
    send.resolves();

    const result = await makePizza({
      name: "P",
      ingredients: ["A", "B"],
    });

    expect(result).to.deep.equal(pizzaItem);
  });

  it("makePizza#fail", async () => {
    send.resolves();

    await expect(makePizza({})).to.eventually.be.rejected; // fail
  });
});
```

`makePizza` is the system under test, and here we check both the happy path and failure. The stub hijacks the prototype and takes control of the `send` method. This guarantees no network activity occurs on the AWS cloud or DynamoDB.

Feel free to add more unit tests as you see fit — you can start with `tastePizza` as a quick exercise. If you need help, check out the [GitHub repo](https://github.com/beautifulcoder/typescript-aws-lambda).

Go ahead and take a look at your feedback loop. Running `npm t` gives me results within milliseconds. By running this locally, a full end-to-end test via curl takes a few minutes.

I hope you see what’s happening here. Uploading our code to the AWS cloud and hitting the gateway took roughly ten minutes. Then, we waited a few minutes by running the lambda locally. Now, we are down to fractions of a single second.

This technique helps you keep your eyes on the code and nukes any cognitive load while in the middle of making some rad changes. The goal is to keep you focused and minimize any distractions that tempt your mind to wander.

## Local Debugging on the AWS Cloud

Lastly, debugging on the AWS cloud is possible via `console.log`. `console.log` works on both your local machine and the cloud.

AWS comes with CloudWatch, which lets you view the logs. You simply upload the latest code, test the app, then check the logs on CloudWatch. The service can take a few seconds to ingest log data, so it requires patience. It can be slow, and one can easily spend hours chasing even trivial bugs.

An alternative is to use your unit tests for local debugging. In an IDE — say, VSCode or WebStorm — you can set a breakpoint anywhere there is test coverage, then check the call stack and local variables. You can freely inspect assumptions in the code and verify your algorithm works as expected. The feedback loop here is much faster because you no longer need to rely on AWS.

One problem with using `console.log` to debug AWS Lambdas is that it tends to litter unit tests’ output. A simple solution is to install `mocha-suppress-logs` which only shows logs with failing tests.

```shell
npm i mocha-suppress-logs --save-dev
```

Be sure to make mocha aware of this dependency by adding it to the list in `package.json`:

```json
{
  "test": "mocha --require ts-node/register,mocha-suppress-logs ./test/*.ts"
}
```

This works automatically and filters out those pesky logs that add noise to your workflow.

## Next Time: Optimize Your AWS Lambdas

The dev experience is all about the feedback loop and how quickly one can iterate through changes. Luckily, the techniques we’ve outlined here will enable you to build a loop that is both fast and effective.

Next up in this series, we’ll look at ways to optimize the Lambda function, so that you can owe even less to Papa Bezos.

Happy coding!

**P.S. If you liked this post, [subscribe to our JavaScript Sorcery list](https://blog.appsignal.com/javascript-sorcery) for a monthly deep dive into more magical JavaScript tips and tricks.**

**P.P.S. If you need an APM for your Node.js app, go and [check out the AppSignal APM for Node.js](https://www.appsignal.com/nodejs).**