You need to be this tall to go from monolith to microservices — Part 1
Or what we would have liked to know before we started
This article has been co-written with colleague David Panza when we were working on one of the two platform developed in an international investment bank.
The goal is to give you a feedback on our transition from two “monolithic” applications to microservices architecture by focusing on what we would have liked to know before we started.
Leaving the monolith.
Before starting, a bit of context, the story is quite classic in fact, two applications, started more than 10 years ago and managed in the same department, have aged badly. Maintenance and evolution have been offshored. In France, they were only tech leads mainly there to validate the quality of developments afterwards. The problem is well known: the technical debt accumulates, adding new features becomes more and more difficult and frustration is created both on development and business side. It was therefore necessary to regain control and especially to restore the ability to deliver value easily and quickly.
On one of the applications, everything started by gradually replacing the aging user interface by superimposing a modern SPA technical layer (with AngularJS and adding a REST layer). In parallel, new functional features are added via new applications (these famous microservices). The transition, still in progress, must be as transparent as possible for users. A strangler strategy is therefore implemented in order to gradually outsource the functionality of the monolith.
On the second application, the transition combines strangulation strategy, to bring new features to the existing, with a more radical redesign of the product. This overhaul directly lays the foundation for new microservices using knowledge of the existing.
This transition from monolith to microservices is rich in learning and it’s this experience that we would like to share with you with a guide on the necessary steps and the different pitfalls that you will encounter in this adventure.
We decided to build these advices in four areas of maturity: implementation, continuous deployment, collaboration and production. These four axes have each their own challenges, but we believe that the order represents a progression in the difficulties that you will surely encounter.
Let’s get started …
First what are microservices ?
You have surely heard “microservice” hundreds of times now. If you do not know what is it, it’s simple: a microservice is a unique and completely autonomous application. If you expand, creating a microservices platform is like creating multiple applications, each with a single responsibility and each collaborating with each others.
By single responsibility, we mean that a microservice focuses on one and only one thing, such as managing a customer’s shopping cart, orders or billing.
By autonomy we mean a microservice must be as independent as possible of others. This implies coupling as low as possible with other microservices or their components. It must have its own configuration, infrastructure and database. In the event of a malfunction, a microservice should have a minimal influence on the others and therefore on the functioning of the whole system.
The idea of this type of architecture is to divide and conquer, which greatly reduces the complexity of a system. And for good reason, it is much easier to think about simple and small problems than to attack a problem in its entirety.
And as opposed to microservices is the evil monolith, a software monster from the depths of legacy, which centralizes all responsibilities and business intelligence. An application that brings them all together. You have probably already met some during your career. When a monolith is poorly designed, it becomes more complex to evolve, due to coupling, difficulty in testing, maintaining, controlling or deploying.
What you have to be aware in software development is that nothing is fixed. Technologies are adapting, design styles are changing and business needs are moving. The software you are designing surely answers problems in a specific context and time frame. These problems will also inevitably evolve and your software will have to adapt to these changes, otherwise it will become obsolete. It then becomes essential to think of your code as interchangeable and easily changeable so that it can adapt over time (via the SOLID principles for example).
For the older ones, you may have noticed that dividing a system into several small parts is not a new practice. Such an architecture existed long before microservices: it was called Service Oriented Architecture! It is therefore natural to think that microservices is a subtype of SOA implementation. Well, not quite… These two approaches do indeed have a strong similarity in their intentions to separate complexity but are profoundly different in their motivations.
Microservices have the goal to separate in order to better adapt to change. By adapting, it means evolving code or throwing it away! If the need arises, it is perfectly acceptable to part with one of your microservices without jeopardizing your system. The low coupling is therefore of paramount importance!
Conversely, the SOA approach aims to separate to better reuse. Above all, it responds to a problem of centralization and cost reduction. By increasing the reuse of a service, the coupling becomes even stronger. As a result, it is difficult to upgrade your system or change part of it without having an overall impact. Such an architecture is more in line with the urbanization of an IT system.
Now that you know what a microservice is, we will be able to introduce our four axis of maturity.
First axis : Putting it into practice
Separation of responsibilities
As you know, microservices aim to separate the responsibilities of a system into single-liability services. The first difficulty is therefore to identify the different responsibilities of your system. Naturally, as a developer, you will try to separate by first analyzing the code and its dependencies. An approach that seems fast and pragmatic, but which will give you a breakdown that is not very viable in the long term, because it is too technical.
The other approach focuses more on a business analysis of your software. First, you will need to know your users and their intentions. And to do this, there is an approach that is becoming more popular: Domain Driven Design (DDD). The first books to refer to it is Eric Evans’s “Domain Driven Design, Tackling Complexity in the Heart of Software” published in 2003.
In the DDD approach, the goal is to extract business knowledge in order to make it explicit. And one of the important points is to share the same level of communication with the business through the same language, with as little ambiguity as possible (the notion of “Ubiquitous Language”). Multiple workshops can help to acquire this language and extract business knowledge: Event Storming, Example Mapping or BDDs practice. Once extracted, it will be an integral part of your software and your code will be strongly impregnated with it, especially in the naming of concepts. Moreover, it will lead you to make strategic decisions, with the emergence of aggregates that will surely become your future microservices!
As you will have understood, designing a good microservice architecture does not only require technical skills, but also a very detailed knowledge of the business to which it responds!
Collaboration between microservices
By definition, a microservice provides only one service and has only one responsibility. It is therefore generally through a set of services that a platform, that meets user needs, is built. To achieve this, collaboration between them is essential. At this point, you have two options:
Synchronous and direct communication: based on the HTTP or RPC protocols and allowing you to call other microservices. This type of communication is the most natural and by far the simplest. In addition, the recovered data will have the advantage of being the most up-to-date. Nevertheless, this possibility will induce a direct coupling! If the service called is slow or does not respond, then the caller will inevitably have to anticipate it, for example via a fallback strategy (Circuit Breaker pattern).
Conversely, the asynchronous communication will pass through a message protocol provided by brokers such as Kafka or RabbitMQ. These messages can be used to broadcast the data of a service, which will potentially be consumed by others. A major advantage of this type of communication is decoupling. So no more problems related to the unavailability of a service or slow network! However, this communication is more complicated to implement and has its drawbacks, such as data freshness, re-transmission or message loss.
In addition to the type of communication between microservices, you will have to choose a strategy of collaboration. And again, two strategies are possible:
Orchestration, where a single service coordinates a set of other services for a specific use case like a purchase tunnel. All business and control logic is centralized. If a problem arises, then the orchestrator will know what to do to bring the system back to a consistent state. This strategy implements a central brain that, in the event of a problem, can potentially paralyze an entire platform acting as a Single Point Of Failure.
On the other hand, the choreography is based on a principle of autonomy. A service can react accordingly to an event in the system. The idea is therefore to lead the collaboration by listening to events. Since each party is autonomous, the dysfunction of one does not put the others at risk. But with this type of strategy, it is possible that your system may find itself in an inconsistent state. For example, in the case of a purchase tunnel, if the micro-delivery service has already made the delivery while the invoicing service fails. This transaction concept, which is already complex to manage, even in a monolith, becomes even more so in a distributed system. However, it is possible to remedy this by using the Saga pattern.
Technique at the service of a need
If a microservice is autonomous and an unique responsibility, it goes without saying that its code, as well as its role, is unique and very specific. To this specificity, you have the opportunity to adapt your languages and tools. The impact of these choices should have a minimal influence on other microservices, and this gives you more freedom to meet the business need.
Thus, choosing an object or functional language, strongly typed or not, using a specific framework or library or a document oriented database rather than relational database, must always be done according to the nature of the need. And this is one of the strong advantages of this architecture, because beyond filling in your CV, it will allow you to experiment quickly, without major impact to the whole platform, and give you the capacity to share these experiments with other teams (See 3rd principle of maturity — collaboration).
Control the quality of your architecture
The microservice architecture is based primarily on a distributed system which brings its share of complexity, especially in terms of quality. In the case of a monolith, where the codebase remains centralized, it is easier to check the correctness of your application before sending it to production.
One of the advantages of microservices is to develop smaller and isolated codebases, which will certainly shorten the time between development and production. In theory, it will allow you to go faster. For this, like any good developer, you’ll make sure your code works and does not create a bug for your users. And to do this, a test strategy will be applied and will certainly include automated tests at different levels. We will therefore ensure the quality of our developments by testing!
Knowing the test pyramid helps us to develop a tests strategy by distributing them to different levels of abstraction. For a monolith, it has been more and more mastered. If you have question about how to test your code, Cyril Dupuydauby, a colleague of mine, has written a very good article on how it : https://blog.usejournal.com/unit-testing-youre-doing-it-wrong-407a07692989
But in the case of a distributed system, how do you ensure that your system always works?
One solution is to add high-level tests such as UI or end-to-end tests. This type of tests will cover your entire system but remains extremely expensive to develop and especially to maintain because it is very sensitive to changes. Another solution is to add testers who will manually check the consistency of your application. Not very effective especially if the delivery rate is increased, you will need more testers and/or more time to check if there is any regression.
It will therefore be necessary to adapt your test strategy accordingly. Because the complexity of a distributed system lies in the collaboration between microservices, it’s becoming essential to test the boundary of a service. Contract testing, better known as Consumer Driven Contract (CDC), offers a great solution for this and allows to tests the collaboration between two microservices (with tools such as PACT or Spring Cloud Contract).
Another way to ensure that a distributed system does not have a bug is to simulate real user behavior. More complex to implement, the Simulation Testing will allow you to find anomalies on a real use case. A pattern that is based on automatic test generation, which will check the consistency of a system via invariants (known as Property Based Testing).
This is the first part of this long article.