eliasbrange.dev
An opinionated approach to building serverless APIs

An opinionated approach to building serverless APIs

2024-05-15
| #AWS #Serverless

There are countless ways to build serverless APIs. You have the single-purpose Lambda functions, each responsible for a single endpoint. You also have the monolithic approach, where one Lambda function handles all endpoints. Such functions are commonly referred to as Lambdaliths.

Which approach should you choose? As always with software development, the answer is “it depends.” In this post, I’ll share an opinionated and pragmatic approach that I’ve found to work well for me.

Starting Simple: The Majestic Lambdalith

When building serverless APIs, I start in the Lambdalith corner. I create an API Gateway with a single /{proxy+} route that routes all traffic to a single Lambda function. The Lambdalith allows me to start small and iterate quickly. Adding new endpoints is as simple as adding a new route in the Lambdalith. As an API grows, I can optimize parts of it, as you’ll see later in this post.

The Majestic Lambdalith
The Majestic Lambdalith

REST/HTTP API Gateway vs. Function URLs?

The API Gateway can be a REST (v1) or HTTP (v2) API. There’s a considerable price difference between the two. In us-east-1, the price for the first million requests is $3.50 for REST APIs and $1.00 for HTTP APIs.

Some key features that HTTP API Gateways lack are Web Application Firewall (WAF) and X-ray tracing support. It also lacks direct integrations with services such as SQS. As you will see later, certain optimizations call for extracting endpoints to implement specific patterns, such as the storage-first pattern. REST API Gateway’s direct integrations become a crucial feature in that case.

Except for the above, I usually don’t use many of the advanced features of REST API Gateway. Since I’m building a Lambdalith, I don’t need features such as request validation, response mapping, or models. The Lambdalith takes care of all that.

WAF support is a deal-breaker for production workloads, so my default choice is still the more costly REST API Gateway. HTTP API Gateway will be my default choice the day it supports WAF due to the much lower price point.

Another benefit of building a Lambdalith with a framework that handles the API Gateway integration is that I can easily switch between the two gateway variants. If I start with HTTP API Gateway and later need a feature that only REST API Gateway supports, I can switch without changing the Lambdalith implementation.

Why not function URLs?

At first glance, function URLs sound like a perfect fit for Lambdaliths. I was thrilled to read that CloudFront added support for Origin Access Control (OAC) for Lambda function URL origins. That thrill quickly went away when I dug into the documentation to find that OAC doesn’t support PUT or POST methods.

You can still front the function URL with a CloudFront distribution to set up a custom domain and add a Web Application Firewall (WAF). However, without OAC, a client can call the function directly and bypass the distribution and firewall.

Picking a framework

With API Gateway proxying all requests to the Lambdalith, the Lambdalith is responsible for routing, parsing, and validating the requests. I have no interest in handling all that logic, so I’ll pick a framework that does it for me.

Express is one of the most well-known frameworks for building APIs in Node.js. However, it’s not built with serverless in mind. The rise of serverless and edge computing has given birth to a new generation of lightweight frameworks optimized for these environments. The one I have found to work best for me is Hono.

Hono offers an Express-like API, familiar to many:

1
import { Hono } from 'hono'
2
import { handle } from 'hono/aws-lambda'
3
4
const app = new Hono()
5
6
app.get('/', (c) => {
7
return c.text('Hello Hono!')
8
})
9
10
export const handler = handle(app)

I can centralize common logic using middleware in Hono, such as error handling and request validation. For example, I use the Zod Validator middleware to validate and parse incoming requests.

Separating Concerns

Great software architecture keeps options open. Inside the Lambdalith, I separate my business logic from the framework-specific code. I prefer to use the Ports & Adapters Pattern to accomplish this.

An API is an interface to your business logic. In my case, Hono is that interface, and there should be no business logic in it. Instead, I group all business logic into use cases. I might have use cases such as createUser and sendConfirmationEmail in one particular service. The implementations of these use cases know nothing about Hono. They don’t even know they are running in a Lambda function. This separation makes it easy to refactor and optimize the API as it grows.

In an early stage, I might have the POST /users route call both the createUser and sendConfirmationEmail use cases. I could then move the sendConfirmationEmail use case to a separate Lambda function that gets triggered every time a new user is created in DynamoDB using DynamoDB streams. Such a refactor should not affect the implementation of sendConfirmationEmail.

A route in Hono might look like this:

1
app.post("/users", zValidator("json", CreateUserSchema), async (c) => {
2
const payload = await c.req.valid("json");
3
const user = await createUser({
4
email: payload.email,
5
username: payload.username,
6
// ...
7
});
8
9
c.status(201);
10
return c.json({ username: user.username });
11
});

In short, keep your business logic decoupled from your Hono routes. Each route should only be responsible for parsing and validating the request, calling the appropriate use case, and returning, and optionally transforming, a response.

Testing

Testing your APIs is crucial. I use a combination of unit and end-to-end tests to ensure the API works as expected.

End-to-end tests

When using managed services such as DynamoDB, EventBridge, and SQS, the most effective strategy is to test in the cloud. You could mock every service in your tests or emulate services locally with something like LocalStack. However, you are testing against a different environment than what you are deploying to. I prefer to treat the API as a black box and test it as a client would by running through scenarios that send requests to the API Gateway endpoint.

If we treat the API as a black box, the approach used to build the API should not affect the testing strategy. The tests should not care if the API is a Lambdalith or a collection of single-purpose functions, just as a client would not. The tests are your safety net if you decide to refactor or split the Lambdalith.

Unit tests

Unit tests are also necessary. With the Lambdalith approach, we are responsible for routing, parsing, validation, and more. Hono makes it easy to test that your routes and middleware work as expected.

As written earlier, I separate my business logic from Hono-specific code. The implementation of business logic depends on the complexity of the domain, and different patterns come with different testing strategies. For the Hono layer, the API, I test that every route correctly validates and routes requests by mocking the use cases. For example, I test that the POST /users route calls the createUser use case with the correct input and that it returns the proper response:

1
test('POST /users', async () => {
2
const res = await app.request('/users', {
3
method: 'POST',
4
body: JSON.stringify(...),
5
})
6
7
expect(mockedCreateUser).toHaveBeenCalledWith(...)
8
expect(res.status).toBe(201)
9
})

OpenAPI Schema

An API is only as good as its documentation. The de facto standard for documentation for synchronous APIs is OpenAPI. Many tools can generate OpenAPI documentation from your code. When using Hono, I use the Hono Zod OpenAPI plugin as a starting point. The plugin makes it easy to define your models, routes, and responses using Zod.

I use the plugin as a starting point when I have the entire API in the same Lambdalith. There might come a time when I split the implementation into multiple smaller Lambdaliths, extract specific routes to single-purpose functions, or use API Gateway direct integrations for some routes.

Such optimizations and refactorings don’t change the external contract of the API, and the OpenAPI documentation should stay the same. One Hono application doesn’t necessarily reflect the entire API, and generating the OpenAPI documentation from code becomes less relevant. As an API grows too large for a single Lambdalith, export the OpenAPI schema from Hono and maintain it separately.

Next steps: Extracting, Refactoring, and Splitting

Software engineering is an iterative process. Sometimes, the single Lambdalith performs well enough for the entire API lifecycle. As an API grows, so does the number of endpoints and the complexity of the Lambdalith. Sometimes, it grows too large, cold starts could become unwieldy, and you need to optimize it to keep up with the demands of your users.

Let’s look at some ways to optimize as the API grows.

Extracting a single endpoint

Sometimes, I want to extract a single endpoint that requires more computing power than the others. In a Lambdalith, all endpoints share the same memory configuration. Thus, increasing the memory allocation to accommodate one endpoint would increase the cost per request for all endpoints.

A quick and dirty way to extract an endpoint is to deploy the same code as the larger Lambdalith to a new function but with a higher memory allocation. I can then have the /{proxy+} endpoint send requests to the original Lambdalith and point the extracted endpoint to the new function. As I iterate, I might re-implement the extracted function as a single-purpose function.

For most endpoints, the performance of the Node runtime is more than enough. However, there are better options for critical high-traffic endpoints where your users expect low latency. Rewriting the function in a more performant language like Rust can improve latency and lower costs. Another option is to look into alternative JavaScript runtimes such as LLRT.

Extracting a single endpoint
Extracting a single endpoint

A change of patterns

Another optimization is to change patterns for specific endpoints. For example, I might have an endpoint that requires a lot of post-processing, such as generating a report. Instead of having the client wait for the post-processing to finish, I could refactor it to use the storage first pattern and respond with 202 Accepted. One way to implement the storage first pattern is to send requests directly from a REST API Gateway to SQS. A post-processing Lambda function can then work on messages in the SQS queue.

As mentioned earlier, splitting or extracting endpoints shouldn’t necessarily change the external contract of the API as seen by the client. You might need a different strategy to maintain the OpenAPI documentation since the same Hono application no longer handles all endpoints.

The storage first pattern
The storage first pattern

Splitting the Lambdalith

You can slice and dice a Lambdalith in many ways. The resources in the API provide a natural seam for splitting the Lambdalith. In the classic petstore example, I might split the Lambdalith into three separate Lambdaliths:

I can now configure each Lambdalith independently. Perhaps the endpoints related to pets require more computing power, while endpoints related to users can get away with a smaller memory allocation. By splitting the Lambdalith, I can increase the memory for the pets Lambdalith without increasing the cost per request of the other endpoints.

Splitting the Lambdalith also allows you to limit the permissions of each Lambdalith. If the use cases store data in different DynamoDB tables, you can restrict the permissions of each Lambdalith to each table.

As mentioned earlier, splitting or extracting endpoints shouldn’t change the API’s external contract, as seen by the client. Your E2E test suite is your safety net during these refactorings, as it should catch any regressions.

Splitting the Lambdalith
Splitting the Lambdalith

Leaving Lambda behind

It’s hard to mention serverless or managed services nowadays without someone mentioning vendor lock-in. Every choice in software development comes with trade-offs and some level of lock-in.

The Lambdalith approach strikes a good balance. It keeps the benefits of serverless and maintains portability better than single-purpose functions. Consolidating logic from multiple single-purpose functions requires more effort.

If you decide to ditch Lambda and move to containers, a Lambdalith lets you do so with less effort. Create a Dockerfile, throw in the Hono application, and deploy it with your orchestrator of choice. Who am I to judge?

Summary

I’ve shared a pragmatic approach to building serverless APIs in this post.

Start with a single Lambdalith, routing all requests to a single Lambda function. It minimizes the overhead of managing multiple functions and the function and API Gateway configuration that comes with them.

Use a framework like Hono that gives developers a familiar approach to building APIs while still being performant enough for serverless environments.

Separate your business logic into use cases decoupled from the Hono application. It makes splitting, extracting, and refactoring endpoints easier as the API grows.

Treat your API as a black box when testing. Use end-to-end tests to ensure the API works as expected and catch regressions early when the API evolves.

As the API grows, so does the Lambdalith. At some point, you might need to perform optimizations, such as splitting the Lambdalith, extracting endpoints, or changing patterns.

Software engineering is an iterative process. Start small, be pragmatic, and iterate as you go. Don’t optimize too early. The Lambdalith offers a great starting point for your serverless API adventures.


About the author

I'm Elias Brange, a Cloud Consultant and AWS Community Builder in the Serverless category. I'm on a mission to drive Serverless adoption and help others on their Serverless AWS journey.

Did you find this article helpful? Share it with your friends and colleagues using the buttons below. It could help them too!

Are you looking for more content like this? Follow me on LinkedIn & Twitter !