Function-as-a-Service (FaaS) platforms allow you to deploy and scale your application at the function level. This paradigm shift offers many advantages but has caused some confusion as well. Applications have always been thought of as a single unit. So as you move into this new paradigm, questions arise:
- Should you lift-and-shift existing applications into one big monolithic function?
- Or should you break your application up into many functions, each serving a single purpose?
There are deployment tools that support both approaches already, so which should you choose?
In this post, we’ll talk about the pros and cons of each approach in terms of security, discoverability, and performance. We’ll also touch on some special cases where practical limitations often force you into consolidating your business logic into a single function.
Monolithic vs Single-Purposed Functions
What exactly are monolithic and single-purpose functions? A good example is when you have an API with multiple endpoints and HTTP methods, such as a CRUD API for managing users.
A monolithic function would implement the entire API and handle the routing inside the function code. For instance:
You can implement the same API with single-purpose functions where every endpoint and HTTP method is handled by a separate function.
The principle of least privilege (PoLP) is an important concept in security. The idea is that every part of a system has minimal access to the data it needs to do its job and nothing more. So when this part of the system is compromised, the damage the attacker can inflict on the rest of the system is minimized.
Monolithic functions violate this principle. A monolithic function requires a broad set of permissions because it has to implement all the features of an API. If an attacker successfully executes an injection attack against any of the endpoints, the attacker would gain both read and write access to the data.
With single-purpose functions, you can assign more granular permissions to each of the functions. As such, if any of the functions are compromised, the attacker would not gain full access to the data. This provides much better security for your application.
An often-used argument for using a monolithic function is that “it helps you with cold starts.” Unfortunately, it’s not as simple as that. This argument is only valid when your API experiences very low traffic.
When it comes to optimizing cold starts, you need to consider both:
- The frequency of cold starts. Every time the FaaS platform scales your function to meet increased traffic, you will experience a cold start.
- The duration of cold starts. Not all cold starts are equal, as a number of factors such as memory size and language runtime can influence the duration of the cold starts.
Since you can’t control the user traffic, you have little control over the frequency of cold starts. It’s, therefore, better to focus your effort on making cold starts as short as possible.
Here, monolithic functions can put you at a disadvantage again. Because the monolithic function has to implement every feature the API offers, it is, therefore, going to require more dependencies to do its job.
During a cold start, the FaaS platform would need to do three things before it can forward the invocation event to your function for processing:
- Initialize the container environment.
- Initialize the language runtime.
- Initialize your function code.
More dependencies mean the platform would take longer to initialize your function code, therefore also increasing the duration of cold starts.
With single-purpose functions, you can keep the dependencies of each function to a minimum. This helps keep the duration of the cold starts to a minimum as well.
One of the common problems with monolithic applications is that, while they appear to be simple at the infrastructure level, there are usually a lot of complexities hidden in the code. This is why the phrases “big ball of mud” and “spaghetti code” are often used to describe these systems.
As you lift-and-shift these monolithic applications into monolithic functions, you stand to inherit all the same problems in your code. Furthermore, you have no way of figuring out what business capabilities these monolithic functions offer without looking into its code.
But with single-purpose functions, you can get a quick glimpse of what the user API offers by simply reading the descriptive names for the relevant functions. This arrangement makes the actual business capabilities that functions offer much more discoverable.
So far it appears that single-purpose functions are superior in every case. But from time to time you might find that it’s not feasible to use single-purpose functions due to practical limitations. The two most common cases are:
- When you implement GraphQL servers.
- When you process Kinesis events inside a complex event-driven architecture.
There are many platforms that offer GraphQL as a service. AWS, for instance, offers the AppSync service. While these platforms are certainly powerful and can help you build a GraphQL endpoint very quickly, they are not without their shortcomings. AppSync for instance imposes several service limitations and lacks support for compression, caching, and batch operations. So, oftentimes you still have to implement custom GraphQL servers yourself.
Because of the way GraphQL works (a single endpoint to query or mutate all resources), it’s difficult to split the resolver logic into multiple functions. Although the resolver targets can and should still be single-purpose functions, the GraphQL server implementation itself is best kept as a monolithic function.
Processing Kinesis Events
A popular trend in serverless is to build loosely-coupled microservices that are linked together via events. For example, as a new user signs up to the system, the User microservice would publish a user-created event to a centralized event stream. And when the user places an order, the Order microservice would publish an order-placed event to the same stream. Other microservices would react to these events and perform their own tasks. The EmailPromotion microservice would look for order-placed events to see if it could cross-promote other products to the user by email. The Inventory microservice would look for the same order-placed event and decide if it needs to order additional inventory from the suppliers.
This is a very powerful way of building complex systems out of simple, loosely-coupled components and a natural fit with microservices. However, Kinesis Streams have a read throughput limit of five reads per shard, which imposes a practical limit on the number of Lambda functions that can subscribe to a stream before performance degrades. The recently announced Enhanced Fan-Out feature has raised the ceiling, but it’s not an infinitely scalable solution.
As a result, we often have to consolidate processor logic in order to reduce the number of functions that have to subscribe to the same stream. It’s also possible to use multiple streams instead, but Kinesis cannot guarantee the ordering of events across two different streams. This means that it’s possible for you to receive the order-placed event for a user before the user-created event if the two events are recorded in different streams. Hence, consolidating processor logic is still the most acceptable workaround.
In conclusion, single-purposed functions are better for security, performance, and discoverability! By reducing what each function needs to do, you can follow the principle of least privilege (PoLP) and use restrictive permissions tailored for each function.
Reducing what each function needs to do also helps minimize the number of dependencies each function needs. And this in turn helps reduce the time to initialize each function, therefore improving the duration of cold starts.
By breaking the application’s responsibilities into multiple functions, you also help surface these responsibilities out of the code. This makes it easier for everyone to discover what the application has to offer without having to look inside the code or rely on external specifications such as Swagger.
Single-purposed functions should be considered as the idiomatic way to build serverless applications. However, given the current platform limitations, there are times when it’s not feasible to follow the idiomatic path. The most notable special cases are when you implement GraphQL servers or when you process Kinesis events inside a complex event-driven system.