javascript

AWS Lambdas with TypeScript: Improve the Dev Experience

Camilo Reyes on

This post is part ofAWS Lambdas with TypeScript Series

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.

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, 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.

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

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

{
"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.

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

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 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:

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.

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;

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.

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:

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:

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

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

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);

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.

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.

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:

{
"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 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.