MonoLambdas, Nano Functions, and Goldilocks

Event routing in AWS Lambda apps

Symphonia
Mike Roberts
Jul 20, 2022

Simple, single-responsiblity, AWS Lambda applications have one Lambda function and one event source. You might use API Gateway to provide a simple HTTP-callable interface to a Lambda function. Or perhaps trigger a “cron job” Lambda function on a particular schedule.

Simple API with Lambda

Many Lambda applications grow beyond one task. In the case of an HTTP API Lambda app you may want to both create and read entities based on two different types of request. Or perhaps you'd like one Lambda-backed application that offers both an API interface and a message queue interface.

Whatever that second task is, as soon as your Lambda-based application needs to do more than one thing you are faced with a choice - should you use one Lambda function…

Two API calls, one Lambda Function

… or two?

Two API calls, two Lambda Functions

Another way of thinking about this is in terms of events. When your application consumes one event type you route all events to the same consumer. But once you want to consume multiple event types does your application route each type to a different Lambda function, or to the same Lambda function?

This article explains the common solutions to the multi-behavior question. On one hand there are so called “MonoLambdas” / “Lambdaliths”. On the other: single-responsibility “nano functions”. There are many tradeoffs between these two, which I dig into.

Usually though I don't see these techniques as a binary decision - instead I see them as ends of a spectrum. I typically find that a good solution is to use both ideas when architecting a serverless application. Therefore at the end of the article I describe how you can think of a similar “Goldilocks” approach to your own work.

To start with though I tackle another question…

Why should you care?

This question of how to allocate behavior to one or many Lambda functions is of most interest to application architects. The tradeoffs I list later are typical to these kinds of decisions.

However this particular decision is also of significant impact to both team managers and developers . While some of the tradeoffs are technical (complexity vs capability), others are about developer effectiveness.

I, as someone who has been using AWS and Lambda for 7 years, am very used to working with the AWS platform and can move quickly when making changes, even with a complex design. However a team who is brand new to these concepts are going to be learning a lot of new things at once.

As such it may make more sense to pick a direction which makes the team as a whole more effective, and happier, even if it's not technically the perfect choice at the time. You can always refine the architecture later once the team is more comfortable with the concepts of building an AWS Serverless application.

MonoLambdas / Lambdaliths

When you start your first Lambda application you have one Lambda function. All of your code goes in that function, and you configure one function in your deployment definition. Nice and easy.

One solution to support multiple event types is to keep this simplicity. In other words package all of your application's code to one artifact (zip file or Docker image), and keep just one Lambda function configuration. In turn all of your event sources (API Gateway, S3, etc.) will route to this one Lambda function.

MonoLambda

This technique has been called either the “MonoLambda” or “Lambdalith” approach - taking an entire app (and often an entire source repository) and bundling it into one large Lambda function.

Because all of your events will be sent to your one Lambda function, your code will need to decide what behavior is necessary as it receives each event. In other words it will need some kind of internal routing. Often this is performed with custom code, but third-party libraries that can help are serverless-express, Middy's http-router, and Amazon's own “Powertools” (just for Python at time of writing.)

A common case of using a MonoLambda for an HTTP API is when using GraphQL - typically the whole Graph app will be deployed as one function.

Nano Functions

The opposite of a MonoLambda is to deploy separate Lambda functions for every different type of event.

Nano Functions

There's no standard name that I know of for this kind of design. Some people call it “micro functions”, but I don't like that because it overlaps in my head with “microservices", which is a whole separate topic! Instead I think of them as “single responsibility functions”, or if I want something snappier then “nano functions”.

For this solution each Lambda function is its own AWS resource, and is configured as such with the deployment tooling. Further, the deployment tooling is also the location of the event source mapping. I.e. it's the job of the AWS services to route a particular event to the correct Lambda function, and so no internal routing is necessary in code.

An important point here - just because these are multiple Lambda functions they are still managed in one atomically deployed application. The decision of when to break entire applications apart is a conversation for another day!

Tradeoffs

Let's play a little game of MonoLambdas vs nano functions and see who wins this architecture battle!

Developer Familiarity

For developers, working with a MonoLambda feels somewhat like building a traditional (non-serverless) server-side application. For example, if you're building a server-side HTTP application in Node you might be used to using the Express Framework. Express provides a web server interface, and allows you to programmatically route different paths to different internal functions, and all paths in your application are handled by a single Express app.

A MonoLambda works in a similar way - one application, with programmatic routing. In fact you can even run Express apps in this way with just a few small changes using the serverless-express library.

Even better, with a MonoLambda you aren't restricted to just processing one type of event source in one Lambda Function - you can route any Lambda event to your single Lambda Function. That means API Gateway, S3, scheduled events, and more can all be processed by the same Lambda function.

On the flip side - if you have a lot of individual nano functions then this will feel somewhat alien to developers new to serverless thinking. That's because there's a lot of cognitive overhead involved with many different entry points, dealing with the Lambda platform, and adding / modifying deployment configuration. Which brings us to the next point…

Running Score: MonoLambdas: 1 - 0 Nano Functions

Deployment work

Over time your deployment configuration - CloudFormation / SAMhttps://aws.amazon.com/serverless/sam/ template, CDKhttps://aws.amazon.com/cdk/ app, etc. - will grow quite large when using a nano functions approach, and require frequent maintenance.

With a MonoLambda, on the other hand, your deployment configuration may barely need any ongoing work. For example if your application solely uses API Gateway as an event source you can configure API Gateway to route all requests on any path to one Lambda function, and provide that Lambda function with broad security permissions. In this case developers will spend almost all of their time in application code, and very little in deployment code.

Running Score: MonoLambdas: 2 - 0 Nano Functions

HALFTIME BREAK

The two benefits above seem significant - an easier life for the developers! And yet many people who have spent time working with production Lambda applications would steer you away from a MonoLambda. Let's see what happens in the second half of this game.

The Principle of Least Privilege

A key drawback of a MonoLambda is that every codepath has the same security permissions. For example, say you have a HTTP API application that allows both reads (via a GET call) and writes (via a POST call.) The GET codepath needs read access to the database, and the POST path needs read and write access. With Lambda we configure security permissions for other AWS resources (e.g. DynamoDB) on the Lambda Function's deployment configuration, and therefore for a MonoLambda we'd need to provide the broadest scope configuration for all codepaths. The result is that our GET codepath will have write access to the database, even though it doesn't need it.

This is in conflict with the Principle of Least Privilege, as described by John Chapin in our book as follows:

Unlike in a traditional monolithic application, a serverless application could potentially have hundreds of individual AWS components, each with different behavior and access to different pieces of information. If we simply applied the broadest security permissions possible, then every component would have access to every other component and piece of information in our AWS account. … We can address this risk by applying the principle of “least privilege” to our security model. In a nutshell, this principle states that every application and indeed every component of an application should have the least possible access it needs to perform its function.

With my specific example of a simple GET / POST API Lambda app the security concerns aren't huge, and a team would likely decide the tradeoff of having just one Lambda Function was worth it.

However as the application grows this problem gets much more significant. E.g. say that your application has both “admin” routes, and “customer” routes - allowing “admin” access from your “customer” codepaths opens you up to security problems.

Running Score: MonoLambdas: 2 - 1 Nano Functions

On the other hand, managing all of these permissions is burdensome, and currently the deployment tools don't make life particularly easy. So let's call this one a tie!

Running Score: MonoLambdas: 3 - 1 Nano Functions

Code vs Configuration for event routing

A useful benefit of deployment-definition tools like SAM and CDK is that they allow event routing to be configured. E.g. events from S3 can be configured to trigger one Lambda function, while requests from API Gateway can trigger a different one.

However this is just scratching the surface. With many AWS event sources the content of the request (e.g. HTTP path, or the name of the S3 bucket with a changed object) can also be used to further configure routing to different Lambda functions.

With a MonoLambda, however, all of those routing decisions must be placed within code that runs in the application. When using a routing framework this might not be too tricky, at least for HTTP calls, but it's still not making the most of the larger AWS platform.

Less code, more configuration. Score one for the Nanos!

Running Score: MonoLambdas: 3 - 2 Nano Functions

BZZZZ But what's testing all of those configurations? With code-based routing a unit test can cover it, but for configuration based routing you'll need to run acceptance / integration tests against your deployed application in the cloud to check everything is OK. It's my preferred approach, but for other teams this is a lot of extra effort. It's another counter-attack point for MonoLambdas!

Running Score: MonoLambdas: 4 - 2 Nano Functions

Handling Lambda operational settings

Lambda has various knobs and dials to give different behaviors and capabilities. Just look at all of the properties on the Lambda function CDK construct to get an idea of how many there are!

When you use nano functions each function can have its own operational settings, but with a MonoLambda each code path uses the same operational configuration. Nano functions have a clear win here, for the following reasons:

For low-latency, CPU intensive, API Lambda functions you can set the Memory Size high, and the Timeout low. For scheduled tasks that take a long time, but are I/O bound you can use the opposite settings. Apart from improving performance this can also help reduce costs.

Beyond optimization configuration, like Memory Size and Timeout, you might actually need different, incompatible, functional settings across different event types. E.g. for some types of event you may want to set Reserved Concurrency to 1 so that only one instance is ever running, but for other types you may want to scale as wide as possible. With a MonoLambda this simply isn't possible.

And finally you can only set one Log Group per Lambda function. This means that logs for different types of event will all end up in the same place when you're using a MonoLambda. If you're using structured logging, and good log analysis tools, then this can be mitigated, but out of the box it's usually easier to have the logs for different event types go to separate places, which is what you get with nano functions.

Running Score: MonoLambdas: 4 - 3 Nano Functions

Slower deployment and cold starts

Two activities that happen frequently when you use Lambda are deployment, and startup. Deployment occurs often since you will typically do a lot more testing in the cloud at development time. Startup happens more frequently than non-serverless applications since the Lambda platform uses ephemeral environments to process events.

The speed of these activities is significantly impacted by the size of the code artifact for your Lambda function. In other words a larger code artifact will slow down how long it takes to deploy a new version of the Lambda function from your development workstation to AWS, and it will also increase the cold start time of your function (see here for more on cold starts.)

If you support multiple event types in one Lambda function (the MonoLambda style) then your code artifact must contain all of the code and libraries to support those events. This larger artifact will cause slower deployment, and cold starts, than if you used smaller artifacts, optimized for more specific functions.

On the other hand multiple Lambda functions, with their own custom artifacts, will speed up both activities.

Running Score: MonoLambdas: 4 - 4 Nano Functions

FINAL SCORE - It's a tie!

After all of this, the game is a tie! What does that mean? Well, as with all architectural decisions this choice really depends on the context of your specific application.

But also like many architectural decisions this one isn't a discrete choice. In fact we can mix and match both styles. Let's look at this a little more.

Mixing both approaches

There is no documentation anywhere that says that a single Lambda application with multiple behaviors has to exclusively be either a MonoLambda app, or a collection of single-responsibility nano functions. In fact it's perfectly reasonable to use both techniques in the same application.

Let's take an example of a reasonable size application that has the following external interfaces:

  • A REST API, and Websocket API, used by a customer mobile application
  • A REST API used for administration from an in-house web app
  • An SQS queue for receiving updates from other services in the organization
  • A scheduled task that performs uploads on a daily basis

We might design this application as follows:

The Goldilocks Design

Here we have five separate Lambda functions - one per top level event source. That means that different types of workload are handled by different functions, but different events within each workload are handled by the same function.

For example, leaning on the nano function benefits, our daily scheduled task which takes 10 minutes can have a different timeout to the API requests, which in this case might usually take less than 1 second.

However, taking more of a MonoLambda approach, we handle all Admin API requests in a single Lambda function, which itself will contain an internal router.

What this means is that while we have a non-trivial deployment configuration here of five different functions it might not actually need much continuing work over time if the basic interface stays the same.

What we're trying to do with this mindset is find the “Goldilocks” design for our application - not too much MonoLambda, not too many nano functions, but something in between.

Conclusion, and what about that second event type?

You now know the two basic schools of thought when it comes to building Lambda apps that have more than one responsibility:

  • MonoLambdas put the entire application in one Lambda function. They favor developer productivity over operational complexity, and perform in-process event routing
  • Nano functions use a Lambda function per event type. They lean on the AWS platform for event routing, and embrace precise operational configuration for each event type

However I've also shown you that these approaches can be combined to give you the right mix according to both the requirements of the app, and the skills and experience of your team.

The one question that remains is what happens when you add the second event type to your simple, single-responsibility, Lambda app. Should you stick with the default MonoLambda, or should you immediately embrace multiple functions?

I would suggest that if your team on the whole are still fairly new to Lambda, and if the second event type can be satisfied by the operational constraints of using a MonoLambda, then go with a MonoLambda. You can always change the decision later once the team are more comfortable with the platform.

On the other hand if the team are already largely experienced with Lambda then I'd recommend you make the choice based on wider operational factors - will any of the next few event types have different operational preferences - security, performance, logging, etc., - from your first event type? Would combining any of the next few event types into one function cause consequential deployment or startup performance impact? If the answer to either of these questions is “yes” then introduce multiple Lambda functions, otherwise stick with a MonoLambda until your performance or operational requirements drive you to a mixed design.


I hope you've found this article useful! If your team needs help with this type of design problem, or other AWS Serverless problems, then drop me a line at mike@symphonia.io.