Lessons learnt: Building a framework for serverless solutions
In this article, I am sharing my experience of what was delivered. I am splitting this into two parts. In this part, I will discuss some common points to keep in mind while building a framework. In the second part, I will provide some details of my work in crafting and constructing this framework using AWS serverless services as the underlying platform.
Before delving into the specifics, it is important to establish the intended audience and set expectations. This article is tailored for application architects/senior engineers who possess knowledge and hands-on experience in constructing solutions but want to excel towards becoming a technical lead/lead architect. The objective of this article is to provide valuable guidelines for building a framework. However, it does not delve into detailed solution designs, as they tend to be overly specific.
· What is a framework?
∘ Why do you need a framework?
· Designing prior to building
· Iterate and evolve
· Single Responsibility
· Change is the only constant
· Modularity
· Configurable code
· Build your arsenal
· Divide and conquer
· One step at a time
· Resources
What is a framework?
Definition on Wikipedia¹ states:
In computer programming, a software framework is an abstraction in which software, providing generic functionality, can be selectively changed by additional user-written code, thus providing application-specific software. It provides a standard way to build and deploy applications and is a universal, reusable software environment that provides particular functionality as part of a larger software platform to facilitate the development of software applications, products, and solutions.
In a simple one-liner, it would be: A software framework is a tool that helps developers build and deploy applications more easily.
The definition implies that a software framework is a tool or for a better term, acts like a tool or a set of tools. In that case, it should hold comparable properties to that of tools. It should:
- be handy; easy to access and use.
- be reliable; get the job done.
- be robust; does not break or get damaged when you use it.
- be fit for purpose; it does the job that it is made for.
Why do you need a framework?
Having a framework helps you manage your solution in a standard approach. All your features and services are defined based on a fixed set of assets which makes maintenance manageable.
Now that we understand what to expect let us get into the details of how we can build a framework meeting these requirements.
Designing prior to building
Although it may appear evident, I have observed numerous teams succumbing to the pitfall of neglecting the design phase or just hastily drafting the necessary plans before diving into development. Consequently, the design remains stagnant, rarely reviewed, or updated, and becomes a static diagram created at the project’s inception.
Like the build process, the design phase should also follow an iterative approach. By incorporating design iterations before each build phase, you ensure that your solution remains current and provides the necessary guidance for constructing it correctly.
When developing a framework, it is crucial to prioritise the core requirement. In my case, the core requirement was providing a solution for integrating multiple SaaS providers and providing interfaces for consumers to interact with those providers. The interface may transform responses before returning the result. This meant the focus of our solution was to deliver capabilities like that of an integration platform.
There are several off-the-shelf integration platform products, but they come with a price; licensing and operational costs. The client was willing to build a framework that focussed on its needs and provided cheaper long-term alternatives to the integration platforms. The client also had limited requirements which utilised limited features if an integration platform was used. This approach may differ for each client, and it all comes down to requirements and preferences in the end.
To achieve the above requirement, we went with a layered approach to our solution. In a layered solution, each layer is responsible for a specific set of functions. This makes it easier to manage your components as you split them based on their function. The following diagram illustrates a version of a layered design:
The provided diagram presents a breakdown of the deployed framework into distinct layers: Interface, Message, Processing, Storage, and an overarching Observability layer. Each layer encompasses a collection of services that are pertinent to its specific functionality. This approach establishes clear boundaries for services and components, enabling efficient management of all elements. For instance, the Interface layer may include services such as AWS API Gateway or Azure API management and an authentication service like AWS Cognito or Microsoft Entra ID (formerly Azure Active Directory), ensuring a well-defined and organised framework. The layers in the diagram are just an example. A similar framework will be discussed in detail in part 2 of this post.
Iterate and evolve
When you start designing, it is always important to test your designs. Building prototypes serves as the best approach for this purpose. Prototypes should be minimalistic, focusing solely on testing specific functionalities to validate your design. To prevent excessive work on prototypes, consider treating them as spikes² within a sprint. This allows you to allocate a specific time limit and define a scope, enabling you to measure the results against your predetermined objectives. If the prototype aligns with the defined scope, it becomes an integral part of your design. However, if it falls short, valuable insights are gained that may prove beneficial in the future.
Single Responsibility
In your framework, it is crucial to establish distinct boundaries and responsibilities, particularly between layers. For instance, if your interface layer handles authentication and authorisation, it should solely focus on these tasks, delegating any data manipulation or transformation to another designated layer. This approach ensures that each layer has a well-defined purpose and minimises the risk of coupling or duplicating logic, which can lead to complexity as the project progresses. By adhering to clear layer definitions, you can maintain a more manageable and efficient framework throughout the lifespan of your project.
Change is the only constant
Throughout my experience of working on various projects, I have witnessed a consistent pattern of change from the initial discovery phase to the final release in the project life cycle. However, change can become burdensome if our solutions are inflexible. To effectively navigate this constant state of change, it is crucial to embrace flexibility. This entails ensuring that our layers are adaptable to accommodate new services and that our code is configurable to seamlessly adopt new features. Additionally, maintaining loose coupling between components is of utmost importance. By implementing loosely coupled components, as far as can foresee, we can easily swap them out as needed, minimising challenges. By prioritising flexible layers, configurable code, and loose coupling, we can effectively manage change and ensure the long-term success of our projects.
Modularity
To achieve the flexibility mentioned above, one major approach is to make your code base modular. A common set of features that perform similar functionality can exist in a module. Some examples of modules are:
- Validation: This is responsible for any object validation required for the framework. It may consist of JSON or XML validators based on your requirements.
- Logging: This module handles all the logging configuration such as log levels and log formats for the complete solution.
- Error Handling: This module defines the error object structure and format for the complete solution.
These are just a few types of modules that are commonly needed in all projects. If set up from the beginning, they provide the flexibility to handle new requirements without breaking your solution.
The following is a Python code snippet that gives an idea of a simple error-handling module:
In the above snippet the class ErrorException consists of two methods format_exception which formats error messages to an error object and format_and_throw_exception which throws the formatted object as an exception. This module can be used across all services within your solution and helps in standardising error messages.
Configurable code
Modularity will give the separation of concern in your framework, but each module may, over time, get complicated as more changes are done and features are added. You may want to perform JSON object validation initially then a requirement comes in to support XML validation. Later, you are required to validate CSVs. Your modules can have separate logic for each type of validator but how that validator is accessed is where your code starts getting littered. You start adding several if-else conditions to cater to multiple requirements. It’s not long before code complexity increases, code readability gets poor, and testability becomes a pain.
To overcome this challenge a config-based approach can come in handy. Instead of writing several conditions to trigger logic, you can create configuration files that specify what to trigger based on the type of request. These config files can be of any format JSON, XML, YAML, or any other type depending on what is preferred. Following is an example of a config file:
The above code snippet is of a config file that specifies how to execute logic when a GET request to get a list of users is received. The URL specifies where to fetch the users from, the validator specifies what the name is for the validation schema. The payload specifies where the params will be and the strategy specifies what function to execute to get all the users. By using this approach, you can easily create a new configuration e.g. to get_all _courses or get_all_books, without having to write new code each time.
This is just a small example and there is always room for change. The crucial point is to have such an approach in place.
Build your arsenal
Once you start building a configurable module, you should maintain them on a regular basis. You will reuse the module in more than one service. The module then becomes a key component of your solution, creating dependencies. Any changes to the module should be reflected in all places. Regular updates can become a challenge unless you package your modules. Packaging modules make them well maintained, versioned, and easily shareable. Here is an informative article on managing third-party packages which will help you have a better understanding of package management³. All your services that are dependent on your modules would switch to adding it as a dependency. The logging module may change to a logging package used across multiple services within your framework. If a change is done to the package, it is reflected across all services with a simple update. The packages will help you share common modules across numerous services and give you reusability. Maintaining these packages via package managers helps in versioning which brings in backward compatibility as well. We will investigate this in more detail in part 2 of this post.
Divide and conquer
Whilst we have mostly focussed on the technical aspects of the project, some managerial aspects are crucial to the success of your framework. One of the crucial factors is the structure of your team. Typically, teams are divided based on deliverables, with each team responsible for delivering a specific set of APIs. However, to ensure the success of your framework, it is essential to have a dedicated framework team that supports all the other teams.
The role of this team is to identify common features across teams that can be packaged as modules. This team also defines the standards for these modules such as the approach for creating a module, the naming convention for modules etc. These modules are then built, managed, and released by the framework team. By centralising common modules, the chances of breaking changes are reduced, and the framework team can ensure that all the other teams are aligned, and that their changes do not break any services.
To achieve this, regular cross-team check-ins should be conducted to identify the common needs of all teams. This helps to ensure that the framework team is aware of any upcoming changes and can plan accordingly. With a dedicated framework team in place, the development process becomes more streamlined, and the chances of success are increased.
One step at a time
You may at times lean towards building a complete set of features for a module before it is used. For instance, you may consider building a validator that supports JSON, XML and CSV validations. This may seem useful, and you might need all types of validators in your framework but, it makes you build some features which are not used, or which may change in the future. Always break it down to what is needed first. By delivering only what is necessary and adapting to changes, you can avoid the need to rewrite a large portion of your work.
Let us summarise what we have discussed:
- Have clear boundaries and responsibilities between layers in a framework.
- Designing before building and iterating your design process.
- Take a layered approach and loosely couple your layers.
- Build modules using configuration files to simplify complex logic.
- Package your modules for reusability.
- Have a dedicated framework team.
- Build what is needed first rather than the full package.
I hope this article gives some idea about designing and developing frameworks. In the next part, I will provide a concrete example of these guidelines using my project as a reference.
Resources
[1] Software framework (2023) Wikipedia. Available at: https://en.wikipedia.org/wiki/Software_framework (Accessed: 07 May 2024).
[2] Spike (software development) (2024) Wikipedia. Available at: https://en.wikipedia.org/wiki/Spike_(software_development) (Accessed: 07 May 2024).
[3] Noble, T. (2024) Package overload: How to optimise third-party packages for a leaner, meaner project, Medium. Available at: https://medium.com/deloitte-uk-cloud-blog/how-to-make-the-best-use-of-third-party-packages-9fc778105f30 (Accessed: 07 May 2024).
Note: This article speaks only to my personal views/experiences, is not published on behalf of Deloitte LLP and associated firms and does not constitute professional or legal advice. All product names, logos, and brands are the property of their respective owners. All company, product and service names used in this website are for identification purposes only. Use of these names, logos, and brands does not imply endorsement.