Build Pragmatic Microservices — Avoid Building Mini Monoliths
‘Microservices’ is a big buzzword these days among the tech community. Thanks to NetFlix, Uber, and Amazon for making it popular with successful implementations.
Many organizations are now adopting this approach. Organizations are trying to refactor or rewrite their monolithic applications by breaking them into Microservices and there are organizations building products by adopting the microservices approach from scratch.
In both cases, important question architects must ask at the beginning and also during the development is whether “Are we on track to build microservices by following the design principles and best practices of microservices?”, if teams do not ask themselves this question regularly, there are very good chances that they end up building something I call as ‘mini-monoliths’.
This article discusses ‘mini-monolith’ anti-pattern, its characteristics, some key reasons why teams may end up building mini monoliths and ways to get back to building true microservices.
What is mini-monolith?
A mini monolith has the below characteristics:
Smaller in size compared to a typical monolithic application
Many features are bundled into one service
Independently deployable but not easily deployable
Setup is time-consuming because of complex configurability
The minimal setup itself demands large infra
Communication among modules using library level method invocations, reused utility code makes it difficult to upgrade or change the tech stack independently
Let’s take an example of a service built for an e-commerce portal. The initial business requirement is to build the capability which allows users to give a rating of a product on a scale of 1 to 5. The team decides to build that as a ‘Rating’ microservice. However, after a few weeks, the service characteristics are as below:
The service handles user feedback in various forms - Rating, Reviews, Likes, Comments, Survey, Discussions. All these features are bundled into one service because all are related features
Irrespective of whether the business uses all features or not, at any point in time, the full service with all features has to be set up and run
The tech stack of all the features provided by the service is the same, restricting developers from using the latest tech stack for particular types of feedback.
This service is now having characteristics of mini monolith because what had happened over some time is that this service has violated some of the fundamental principles of microservices, mainly the ones outlined below:
Many architects and developers would confuse modularity with microservice. One must understand that modularity is different from microservice.
So, what would have led to building this mini monolith when teams wanted to build simple microservices? These mini monoliths are a result of some of the complexities that arise because of microservices development approach and operational overheads.
Like any other architectural approach, Microservices also have other sides. Architects need to understand these complexities and overheads involved in building microservices and ensure to address them appropriately rather than end up building mini monoliths.
Business Scope or Code Reuse — Developers Dilemma
One of the important activities in microservices design is to define the boundaries of the service. There is no thumb rule saying that a microservice code should have an ‘X’ number of lines of code or ‘Y’ number of features. However, one must draw a boundary, and one important aspect when defining the boundary of the microservice is to make it atomic basis the business capability and also basis its usage scenario.
Developers tend to stuff or bundle all the related business capabilities into one service mainly because they would want to ‘reuse’ some of the low level, utility code across modules. There is nothing wrong with that approach but one must also carefully analyze the trade-off between having an atomic, reusable microservice based on business capability versus a service consisting of many features/modules bundled to reuse the code.
One way to achieve code reuse is to build separate infrastructure level, reusable microservices that can act as shared and supporting services for business capability handling microservices. This approach of reusable, shared, infra level microservices reduces overall code across microservices.
The properly defined scope also helps in other activities like regression testing and test case automation which is critical after the initial development. When the microservice is atomic and simple its test cases also will be simple and easy to automate. If several modules are bundled into one service then the service test suite becomes bulk making it difficult to manage.
Code reusability is important but at the same time, developers must also think beyond code reusability to consider things like how easy it is to make changes to a particular service basis the future business demands, how easy it is to deploy a service, how testing can be made easy, how the tech stack can be upgraded or changed if required. Because all these are recurring activities after the initial development phase and the majority of the time, effort and cost go in these activities.
Distributed Computing Complexity
Microservices communicate over APIs. Microservices provide the flexibility to use different technology stack as long as the interfaces and contracts are defined in a programming independent format like REST and JSON. Microservices are integrated to form a product or platform, by doing so the computing is distributed with commands flowing over the internet in the form of API calls. Microservices will respond to commands by executing their piece of code in their own set of containers infra and the majority of them will have their database.
This distributed computing model comes with operational complexity. There are mainly two types of overheads:
Deployment involves service discoverability, understanding the service contracts and interfaces exposed to integrate with other services, setting up infra requirements like communication ports, ensuring configurability in place
Operations and maintenance overhead
Each service runs on its container infra and communicates with other microservices over the network through API calls so one has to keep monitoring infra and network failures. A communication failure to one microservice in a platform may lead to a cascading impact resulting in multiple service failures.
These overheads will grow as the number of microservices increases. This distributed computing complexity may drive architects to move away from microservices design principles and stuff everything into one service which will lead to the mini monolith and eventually a complex monolithic application. The solution to the above problem is not in moving away from the microservices architecture but to design microservices such that one must consider operational and deployment overheads during the design phase itself.
There are infra management tools and DevOps teams dedicated to handling some of these complexities. However, deployment and maintenance overheads cannot be handled fully by external tools and infra teams, moreover, these issues will have a larger impact, mainly on the adoption, if not handled by design and development teams.
Dependencies among microservices may not be fully clear and explicit during the design and development stage which leads to service orchestration failures at runtime. Architects must consider this fact and try to get a clear understanding of the dependencies so that they can minimize operational overheads instead of delegating that to DevOps teams.
How can we reduce distributed computing operational complexity at design and code level?
A microservice must be designed to be autonomous so that even if there is a communication failure to the service, it must not lead to the cascading impact of other services resulting in the collapse of the entire system. Also, microservice has to be designed to have a graceful exit in case of issues. It cannot afford to bring down other services or the entire system. The autonomous nature of the microservice plays a key role here to minimize the cascading impact.
Basic monitoring has to be built-in. Unlike Monolithic applications, failure of microservices when operating in a larger distributed computing environment is hard to identify. Debugging and measuring the impact of failure on the user experience and business also becomes difficult. For example, in an e-commerce portal, if the microservice handling the rating of the product fails, the e-commerce portal will continue to work but it will have an impact on business and user experience due to non-availability of the rating of products. It is also the responsibility of the microservice to declare its status to the monitoring systems or the operating environment.
In a distributed computing model, microservices bound to fail due to various factors outside of code: hardware failures, network issues, usage anomalies, cyber-attacks, etc. When designing a Microservice one must factor these but at the same time need to carefully think where to draw the line. Too much of defensive programming may also result in bloating up the microservice code making it harder to maintain, upgrade and integrate with other services
Team Structures and Lack of Strict Governance
“Any organization that designs a system (defined broadly) will produce a design whose structure is a copy of the organization's communication structure” — Melvin E. Conway
In a monolithic application, typically teams are formed such that there will be a UI team, Database team, Middleware/Business layer team, etc. When following the microservices approach, this model can be changed to form teams along the lines of business problems. Similar to how you decide a microservice size based on the business problem, the teams also can be formed based on the business problem.
When the teams are formed based on business problems, Conway's law may not hold because the team is formed such that it handles or rather ‘owns’ a business problem and as a result, there will be less scope for thinking along the lines of organization communication structure.
So why does a team formed like this build a mini monolith? Let’s examine.
Think of scenarios wherein the same team handles multiple microservices. In such a scenario there are chances that developers will try to make library function calls across different microservices because of 3 reasons:
The team thinks along the lines of ‘related functionality’ and tries to stuff everything into one service because their assumption is, they can make use of code. In the above e-commerce feedback example, the team would have thought features all are related to user feedback, and code can be reused across features modules to save development time. Very few developers think of building systems by reusing APIs.
The team has access to the full codebase. There is no restriction to make direct function calls across modules and no enforcement to make API calls.
Time constraints. Release dates.
What should an architect do to avoid teams going in a mini monolith path?
Clearly define the business boundaries and define APIs around that. Publish APIs. Ensure there are enough checks and validations on making library function calls
Note that modularity is different from Microservice. Many developers will fall in the trap that modules are the same as microservice. No, it is not. A Monolithic application can be modular.
Have a proper governance model and define restricted access to the codebase
The microservices approach is great. Building pragmatic and simple microservices is not easy. It requires a lot of discipline and change in mindset. Traditional architects and programmers must unlearn a lot of things otherwise they end up creating mini monoliths over some time.