Building a Serverless Architecture with AWS Lambda and API Gateway

Using component-driven development to build a serverless architecture

Lakindu Hewawasam
Bits and Pieces

--

Serverless computing has taken the software industry by storm ever since the launch of AWS Lambda, all the way back in 2014. It completely redefined the way developers approach software development. It brought about a new development paradigm where developers no longer needed to worry about managing the underlying server or any infrastructure but rather provide business logic that adds value to them.

This meant that teams were no longer required to spend hours setting up virtual machines (EC2s) on the cloud and could launch a single serverless function and provide some business logic that would work right out of the box.

On top of that, the cloud vendor takes care of scaling your resources up and down, providing high availability while charging the user on a pay-per-use model.

How does serverless help development teams?

Well, serverless does not only mean AWS Lambda. In fact, serverless consists of a broader range of services such as:

  1. Compute services: AWS Lambda, AWS Fargate
  2. Storage services: Amazon S3
  3. Database services: Amazon Aurora Serverless, Amazon DynamoDB
  4. Other services: Amazon SNS for notifications, Amazon SQS for queues

And it isn’t only limited to AWS. Most prominent cloud vendors like Google Cloud and Microsoft Azure offer serverless tooling that developers can use in their day-to-day workflow. However, as depicted by Gartner, AWS has been dominating the cloud landscape as a leader over the past ten years and is ahead of its competitors, and, therefore, is a good stepping stone into serverless development.

Figure: Garner Magic Quadrant

How can you build a serverless application?

It doesn’t take much effort to build serverless apps. A simple serverless API will consist of three core services:

  1. Amazon API Gateway: The Amazon API Gateway is a serverless API Gateway that acts as a gateway to a collection of resources in your backend and is often accessed through URNs (Paths) that map to a particular resource (AWS Lambda). It supports automatic scaling and can handle a request surge with no throttling.
  2. AWS Lambda: AWS Lambda will ideally be the compute service that handles the incoming requests to your API Gateway. It will process a request and return a response to the client.
  3. Amazon DynamoDB: DynamoDB is a serverless database that can serve requests at single-digit millisecond latency and will often be the database you use to back your serverless API.

You’ll generally use these services because all of these services are serverless and can scale up and out on an on-demand basis to ensure that no service causes a bottleneck.

And you’ll ideally create a serverless architecture as shown below:

Figure: A simple serverless architecture

For starters, you can rely on your trusted Infrastructure as Code tooling such as Pulumi, AWS CDK, and AWS SAM to get started with a simple app. However, one of the critical issues you get when working with these IaC tools is managing dependencies in your serverless functions (AWS Lambda).

By default, AWS Lambda bundles up all the dependencies it finds in its package.json and deploys the function. This creates a set of issues:

  1. Tools like Pulumi maintain one project for all of its Lambda functions (unless you make one project per Lambda function, which will not scale for sure), so this means that heavy cold starts often impact your Lambda functions and have large bundle sizes. This means that your Lambda function gets dependencies that are not even used by it.
  2. Suppose you manage libraries (helper libraries) used in your Lambda functions. In that case, you’ll need to manually bump the package version in each Lambda function to ensure you’re running on the latest version.

As you can see, the more you work with these tools, the more complex your development workflow gets, especially if you’re working with 100s of Lambda functions in your production environment.

What is the best way to build serverless applications?

Well, this is a perfect use case for Bit. Bit is a tool that lets you build anything in components. Every component is designed, developed, tested, and versioned in its isolated environment and is utilized by its consumers as independent packages.

This means that when Bit identifies that a child component has changed a version, it will automatically propagate these changes to its parent and update all of its usages across its tree. Regardless of your component being used in 1000s of projects, every single one of them will be updated in a single build by Bit.

You might think, “But this sounds too good to be true.”

Yes, you’re right. I’ve been working with serverless for over four years and have never encountered a tool that solves these problems until Bit. Let’s explore this further and see how we can provision a basic HTTP API using AWS Lambda and API Gateway using Bit.

Step 01 — Pre-requisites

First, you must install Bit. To do so, ensure you’re running on Node v18. If so, run the following command to install Bit:

npx @teambit/bvm install

To verify your installation, run the command:

bit --version

Figure: Installing Bit successfully

Next, you’ll need to create an account on Bit, and you’ll need to create a Scope on Bit Cloud. A Bit Scope is a remote server that you can use to host your components. Doing so allows others to use your components and even contribute to them.

Figure: Creating a scope on Bit Cloud

Step 02 — Creating a Bit workspace

Next, you’ll need to create a workspace. A workspace is a temporary space that you can use to develop your components locally. Delete the workspace once you export your components to the Bit Cloud.

To do so, run the following command:

mkdir serverless-workspace && cd serverless-workspace && bit init

This will create a workspace.jsonc file in the directory serverless-workspace. This is where all of your workspace configurations will live. Open that file and edit the following entries:

  1. defaultScope: Update the default scope to use the following - username.scope-name. For me, it will be lakinduhewa.serverless

Next, run bit start to launch the development server. You should see the output below:

Figure: Launching the Bit server

Step 03 — Creating an AWS Lambda component on Bit

Next, you can create a Lambda function on Bit using a single command:

bit fork learnbit.apps/deployments/aws/lambda-function

This command will fork a copy of a boilerplate Lambda function onto our workspace that we can use to develop a simple function.

Next, you’ll need to provide an environment for the Lambda function to run on. It’s important to understand that the Bit workspace is not platform-dependent. You can run anything on a Bit workspace if you provide an environment.

So, in our case, we will use the AWS Lambda environment to provide our component a runtime to maintain it independently. To do so, run the command:

bit use teambit.cloud-providers/aws/lambda

Well, this next part is essential to grasp. If you think about it, a Lambda function is an application. It’s an ephemeral app that performs one thing. So, this needs to be modeled in Bit as well. To do so, run the following command:

bit use aws/lambda/hello-world-lambda

This will ensure that your Lambda function is loadable as an app.

Finally, run the command bit install --add-missing-deps to ensure that all peer dependencies are added.

Next, you should see a directory structure as shown below:

Figure: Lambda directory on Bit

If you’ve been working with AWS CDK, AWS SAM, or Serverless Framework, this may look unorthodox to you. With Bit, your component is structured as follows:

  1. You have a docs file that includes documentation for your Lambda function.
  2. You have a spec file that lets you build test cases for your Lambda function and ensure that you ship out error-free code.
  3. You have a root file that defines the Lambda handler.
  4. You have an index file that exports the function for external use.

With this, you can ensure that your components are maintained in complete isolation and tested in their environment, ensuring that you decouple Lambda functions from each other and treat them as their entities.

Step 04 — Adding Business Logic to the Lambda Function

Now, we can start adding business logic to our Lambda function. Open the hello-root.tsx to start adding business logic. Update the code with the snippet shown below:

export async function handler(
event: {
numbers: number[],
operation: '+' | '-' | '*' | '/'
},
context?: undefined
) {
const { numbers, operation } = event;

if (operation === '+') {
const sum = numbers.reduce((acc, num) => acc + num, 0);
return {
statusCode: 200,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sum }),
};
} else if (operation === '-') {
const sum = numbers.reduce((acc, num) => acc - num);
return {
statusCode: 200,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sum }),
};
} else if (operation === '*') {
const sum = numbers.reduce((acc, num) => acc * num, 1);
return {
statusCode: 200,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sum }),
};
} else if (operation === '/') {
const sum = numbers.reduce((acc, num) => acc / num);
return {
statusCode: 200,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sum }),
};
} else {
return {
statusCode: 400,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: "Invalid operation" }),
};
}
}

The Lambda function depicted above solely applies an operation to a set of numbers and returns that sum.

Next, if you open up hello-world-lambda.ts, you'll see the snippet:

This essentially shows how the handler and function are decoupled from each other. The handler code is bound to the function through entry.

Step 05 — Testing the Lambda Function

At this point, you don’t test the Lambda function but rather test the handler code you’re executing. There’s no point in testing AWS Services. You have to write a unit test that tests your codebase, and therefore, open the spec file and the snippet:

import { handler } from "./hello-root";

it("Should retrieve correct sum when added", async () => {
expect.hasAssertions();
const event: { numbers: number[], operation: '+' | '-' | '*' | '/' } = {
numbers: [1, 2, 3, 4, 5],
operation: '+'
};
const sum = 15;
const expected = {
statusCode: 200,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sum }),
};
const response = await handler(event);
expect(response).toStrictEqual(expected);
});

it("Should retrieve correct sum when subtracted", async () => {
expect.hasAssertions();
const event: { numbers: number[], operation: '+' | '-' | '*' | '/' } = {
numbers: [1, 2, 3, 4, 5],
operation: '-'
};
const sum = -13;
const expected = {
statusCode: 200,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sum }),
};
const response = await handler(event);
expect(response).toStrictEqual(expected);
});

it("Should retrieve correct sum when multipled", async () => {
expect.hasAssertions();
const event: { numbers: number[], operation: '+' | '-' | '*' | '/' } = {
numbers: [1, 2, 3, 4, 5],
operation: '*'
};
const sum = 120;
const expected = {
statusCode: 200,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sum }),
};
const response = await handler(event);
expect(response).toStrictEqual(expected);
});

it("Should retrieve correct sum when divided", async () => {
expect.hasAssertions();
const event: { numbers: number[], operation: '+' | '-' | '*' | '/' } = {
numbers: [1, 2, 3, 4, 5],
operation: '/'
};
const sum = 0.008333333333333333;
const expected = {
statusCode: 200,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sum }),
};
const response = await handler(event);
expect(response).toStrictEqual(expected);
});

Next, run bit test. This will test your handler code and ensure your business logic works as expected. You should be able to see the following test log:

Step 06 — Sharing the Lambda function

Finally, you can even share the Lambda function for anyone to use through Bit Cloud. All you have to do is run the following command:

bit tag && bit export

This will independently build your component and will version it. Next, it will directly push your component to the remote scope we created at this tutorial’s start.

Wrapping up

And voila! We successfully built reusable serverless components using Bit with no overhead. Its development philosophy to treat everything as composable units makes it the perfect tool for creating modern serverless apps.

Doing so significantly improves distributed development and enables better collaboration in your serverless ecosystem.

I hope that you found this article helpful.

Thank you for reading!

Build composable CSS styling for your apps with reusable components, just like Lego

Bit is an open-source toolchain for the development of composable software.

With Bit, you can develop any piece of software — a modern web app, a UI component, a backend service or a CLI script — as an independent, reusable and composable unit of software. Share any component across your applications to make it easier to collaborate and faster to build.

Join the 100,000+ developers building composable software together.

Get started with these tutorials:

→ Micro-Frontends: Video // Guide

→ Code Sharing: Video // Guide

→ Modernization: Video // Guide

→ Monorepo: Video // Guide

→ Microservices: Video // Guide

→ Design System: Video // Guide

--

--