The term “microservices” emerged first around 2011/2012. Since then it became almost a must in an IT world. Even though many years have passed, have we already learned what it actually is?
Let’s first take a look at some definitions:
Microservices are a software development technique — (…) that structures an application as a collection of loosely coupled services. In a microservices architecture, services are fine-grained and the protocols are lightweight. The benefit of decomposing an application into different smaller services is that it improves modularity. This makes the application easier to understand, develop, test, and become more resilient to architecture erosion. It parallelizes development by enabling small autonomous teams to develop, deploy and scale their respective services independently. It also allows the architecture of an individual service to emerge through continuous refactoring. Microservice-based architectures enable continuous delivery and deployment.
And another one from Martin Fowler:
The term “Microservice Architecture” has sprung up over the last few years to describe a particular way of designing software applications as suites of independently deployable services. While there is no precise definition of this architectural style, there are certain common characteristics around organization around business capability, automated deployment, intelligence in the endpoints, and decentralized control of languages and data.
You may notice that the basic common thing in both of them is the independence of services and choices. Now let’s take a look at a few questions, and see if you are implementing a true microservices-based system!
Are you able to restart services independently?
Or maybe you need to do that in a defined order? Such behavior can be caused by specific cluster systems where a node joins a seed node, like e.g. in Akka Cluster (when you use it incorrectly). Other cases could be related to long-running connections or sockets, which can be opened only by one of the sides. Remember that communication should be lightweight and simple. Even better when it is fully asynchronous and message-based. All of the constraints make the services dependent on each other possibly causing difficulties in a long term maintenance. Issues in one service can possibly cause problems in all dependent services, which can bubble up to a bigger global downtime.
Are you able to deploy services independently?
We have seen cases where all the services in the system had to be released and deployed at the same time. Such a process was taking a few days. This definitely limits possibilities to introduce Continuous Delivery process, causes downtimes and restricts your time of reaction. Solving urgent bugs takes too much time just due to the deployment process overhead. When there are different teams maintaining different services, any deployment dependency may block other teams from deploying their services, causing work stoppage and lowering work efficiency.
Can a modification cause a need to perform synchronized modifications of other services?
You can think of multiple reasons leading to such behavior. First of all API changes. Adding a new field in the API request may break compatibility between the services. There are however workarounds for such situations:
- You may want to keep API changes always backward compatible, meaning that every time a breaking change appears, a new API version is added. This doesn’t mean you need to maintain multiple API versions forever, but just for the time needed for the migration. You can just talk with other teams to know if everyone migrated or include metrics showing if old APIs are still being used.
- You may also consider deploying a few versions of your application, allowing other services to switch to the latest release and then turn off the old one.
- You may also consider using formats supporting schema evolutions, like e.g. Avro, where adding a new field does not need to break compatibility. When a value for a field is missing a default may be used.
Other reason for a synchronized deployment may be a shared code between different services. Depending on the release process of the shared part, every modification may force updates in all services.
Does the database change force a change in multiple services?
Well, communication via database is a well-known anti-pattern. In such scenario you need to upgrade the schema and modify the code of a few microservices at once. It is best to have a schema managed only by a single service. It is much easier then to maintain (e.g. to perform database upgrades) and to perform the releases. If you’re already in a shared-database situation, then try to limit writes to be done by a single service, letting another one to only consume.
Can you upgrade a dependency or Java version in a single service?
It should be always possible, right? But in reality, it is not. We have seen a lot of cases of shared “common” libraries or just projects containing definitions of all dependencies used in the system. The microservices intention was to have independent beings, which does not even need to be written in the same technology stack. Starting a new project you should be able to decide, what is the best technology for it. Each coupling brings problems. At some point, you want to upgrade Scala or JDK, but you don’t want to do that on the whole system at once. In a properly granulated system you can perform the upgrades in the single service, deploy, test, monitor for a few days or weeks, and then if everything is correct, you can perform the upgrades in the rest of the system. However, when
common exists, or other strong unification mechanisms, such possibilities are highly limited.
Are your framework/library choices forcing other services to make the same choices?
Some libraries are quite invasive. When you choose them to integrate with external systems, it may suddenly appear that you don’t have too much choice, when you’d like to implement a new service in a different technology stack. Overhead related to getting it integrated the same way, may be just too big.
For example, let’s say that you have a few services written in NodeJS, leveraging JSON:API. Then, you want to implement new service in Scala and it appears that there is no reasonable client-side library (we had a similar case a few years ago, but now the situation in this area is a bit better).
Is a single service failure causing the failure of the whole system?
Let’s say that communication in your system is fully synchronous. One service makes a request to other one, waiting for the response. If other one fails, then the first one can’t work at all. If that is your case, try to introduce asynchronous communication via messaging. It may be more difficult to model all the processes, but such approach allows to limit effects of failures.
In cases when it is not possible, you may consider a temporal limitation of application features. For example, let’s say that an online shop page includes product information and reviews, coming from two different sources. When the reviews microservice doesn’t work you can still display the product page, just informing the user that the reviews system is temporarily unavailable.
Do you share anything?
Do you share the code? Configs? Anything? Well, this is a tricky question. Microservices purists believe that the best case is to share completely nothing! That it is even better to copy code than to share it. In practice, we sometimes accept having some shared libraries, but with strict limitations. These may be libraries including
proto files for protobuff or just plain POJOs. Rather avoid having further dependencies in such libraries. We have encountered a case where a simple client library caused dependency conflicts in a big system. At some point, it appeared that the oldest parts of the application were including older HTTP library and upgrades weren’t possible. Fortunately, you can avoid such cases with dependencies shading, but be very careful.
The evils of too much coupling between services are far worse than the problems caused by code duplication.
Implementing a perfect microservices-based system is not an easy task. People tend to take shortcuts introducing coupling by using shared code, what at some point makes the system to be in practice a Distributed Monolith. Often it is a result of using microservices from the beginning of the project when the domain is not fully clear and you don’t have the whole DevOps background prepared. It is better to start with properly structured Monolith and then to migrate to microservices where the responsibilities and domain are fully known and can be easily divided. However, you need to make sure from the start that it has a clean design and package organization, which later will allow for easy code migrations to new services (e.g. single almost top-level package can be a base for a new microservice). ArchUnit may help you with that by analyzing dependencies between packages.
(…) you shouldn’t start a new project with microservices, even if you’re sure your application will be big enough to make it worthwhile.
Remember, microservices should make development and maintenance easier! Simple loosely coupled services, which can be deployed independently, that’s all that you need.