Monolith To Microservices
The Art of Microservices: Transforming Your Software Architecture
Breaking up a monolith and migrating towards a microservice architecture can be an intimidating thought. This article is a starting point for everyone who is getting ready to tackle such a migration.
Before You Start
Planning a migration
Migrating to a microservice architecture has to be a mindful decision. You should not just migrate for the sake of it, it should solve a problem, that you can’t solve with your current architecture.
Reasons for a microservice architecture
The reasons for migrating to a microservice architecture can vary, the following could play a part, but there are (mostly) always multiple factors to a problem.
Improve team autonomy
Reduce time to market
Scale more efficiently
Embrace new technology
Early stage startups and new projects rarely benefit from a microservice architecture, a clean modular monolith reduces overhead and can be split up later.
The Migration
Importance of incremental migration
If you do a big-bang rewrite, the only thing you're guaranteed of is a big bang — Martin Fowler
Small incremental steps are easier to review and therefore offer a good opportunity to revisit your strategy more frequently. This also is important for the concept of Cost of Change. It is easier to fix small parts right away instead of fixing up a week's worth of work.
So where do we start
First, we need to develop a domain model for our monolith. Come up with a domain model consisting of bounded contexts that represents the different entities of the system and the interaction between such. Each of those bounded contexts represents a potential unit if decomposition.
Put the fundamental processes of Domain-Driven Design to practice: often revisit and adapt your model. Use event storming to come up with a shared domain model.
It may seem intuitive to start with the part of the system that has the least inbound dependencies or looks like it would be a simple task. That is not so far from the truth but there is a bit more to it. As mentioned above you have to factor in the impact of the change. E.g. if you start with a part of the system that might be easy to decompose but is hardly ever changed, and your goal is to reduce time to market, that might not be the best starting point.
Changing Skills
It might be necessary that the current set of skills is not the one that might drive the transition. Evaluate the difference between the two sets and figure out how you can get there. E.g. training, conferences, hiring.
How will you know if the transition is working
Quantitative and qualitative feedback is necessary to see if everything is on track. Have regular formal or informal check-ins and determine if everything is okay or even another approach should be considered.
Splitting up the Monolith
Before you start to split up the monolith, it is advisable to investigate the quality and reusability of the existing code. If there are parts that are written in a scalable, maintainable and readable manner, there is no reason to begin from scratch when extracting the section into a microservice.
As for other levels of abstraction, patterns exist which are suitable for certain problems. As in other parts of software, e.g. design patterns, those patterns are not the holy grail for writing perfectly correct and optimal software but rather a guideline which can help to identify the base of a solution for a problem quickly.
Strangler Fig pattern
The strangler fig pattern is inspired by a certain type of fig that seeds in the top of the branches of a tree. The fig plant then slowly covers the tree from the top to the bottom and uses it as a supportive structure. In the end the tree will die and rot and the fig tree will stand on its own.
The strangler fig pattern enforces incremental change and you can decide on which parts to extract first by looking at qualitative and quantitative metrics.
You extract a part of the monolith and reroute traffic to a new microservice through a proxy. Depending on the relationships and communication of the extracted part of the code in the monolith, you want to redirect the traffic following different approaches. You could have a HTTP proxy or an API which dependent on the path or a resource parameter will channel the traffic.
Migration patterns
Rollbacks
You can always reroute traffic to the monolith, e.g. in case of bugs. But pay attention because if new features are implemented or worked on in the new service and bugs arise, switching back to the monolith will force you to take away functionality from the user.
Branch by Abstraction pattern
If modules which should be extracted are deeper in the monolith and it is not enough to intercept the communication at the perimeter, we face the problem of how we should intercept this communication without slowing down work of other developers.
Creating a new source control branch might be sufficient but if the development time is significant, merge conflicts could be intimidating. That's where the branch by abstraction pattern enters the scene. Essentially it consists of the following steps:
Create an abstraction for the functionality to be replaced
Change clients that use the functionality to use the abstraction
Create an implementation with the reworked functionality
Switch over the abstraction to use the new functionality
Clean up the abstraction & remove the old implementation (you might leave it in there for a possible rollback)
This pattern can be extended to the “Verify branch by abstraction pattern” which calls the new implementation first and calls the old implementation only if the new one fails. This has advantages and disadvantages. There can be less total failures but adding error handling logic results in an additional layer of complexity.
Parallel Run Pattern
This pattern allows to run to implementations in parallel and only use one of them (mostly the old one) as a single source of truth. The pattern is not bound to the scope of services but also to functionality within a single system. Both implementations are running at the same time, the generated outputs can be compared either live or after a certain time, let's say every day. The implementation that enables live feedback is more complex to implement.
This can be paired with the so called Spies, which essentially are stub so multiple in parallel running implementations are not leading to multiple triggered clients, s.a. two emails sent to a customer instead of one.
Implementing the Parallel Run pattern is not a trivial process, it is mostly used for extracting sections of a monolith which are highly critical to the system and should not fail at any time.
Decorating Collaborator Pattern
Decorating the monolith with a proxy that captures data from inbound and outbound requests to the monolith can diminish the need of directly communicating or even touching the monolith. Be careful though if additional data is needed and you think about directly requesting it from the monolith. This may lead to circular dependencies.
Change Data Capture Pattern
This pattern is mostly used when the inbound and outbound events don't provide enough information for using the decorating collaborator pattern. It uses direct access to the database or other data stores. It will listen to inserts of records or publishing of events. You could use database triggers or transaction log pollers.
Decomposing the Database
The Shared Database
Usually, in a monolith system also the data is gathered in a single database. Even if services are already separated into microservices, a shared database comes with a few challenges and implications:
Multiple services consuming/manipulating the same data
- Does that mean that they all implement the functionality to manipulate the same schema? That would be a hint that the logical cohesion across services is low.
It becomes unclear who “controls” the data, and where the manipulating logic lives
Coping Patterns
It is almost always preferred that a shared database is broken apart and data is owned by each microservice. If, for any reason, that is not possible, patterns like the Database View Pattern or the Database Wrapping Service Pattern (see below) could come in handy.
Database View Pattern
Introducing views can enable teams to modify the underlying schema while services still use the old schema. This also has some downsides: since the view often consists of cached data, the consumer could consume stale data.
Database Wrapping Service Pattern
If the database is loaded with logic, e.g. stored procedures, and keeps growing, it makes sense to cut direct communication between services and the database and instead have a wrapping service. So teams can not put more logic into the database. This pattern requires work on the side of all consumers since they have to change their communication from direct database access to communicating through an API.
Database-as-a-Service Pattern
If, for any reason, a service needs to directly communicate with a database, we could offer him a “external” read-only database which reflects the relevant part of the internal database. Between the internal and the external database we have a mapping engine that purely acts as an abstraction layer. On an update to the internal database, the mapping engine will decide on what to change in the external database.
Clients need to be aware that the external database will lag behind the internal database, and it is therefore possible, that they get stale data.
Implementing a Mapping Engine
The mapping engine could be implemented as a data capture system (as discussed earlier) or it could listen to events emitted by the service in question and use them to update the external database.
Compared to Views
An advantage over views is that for the database-as-a-service pattern the internal and external databases can have different technologies. You could use Cassandra inside the service and a traditional SQL database public-facing.
These patterns do not attack the root of the problem, but rather are bandages.
Change Data Ownership Pattern
If the data that is produced/manipulated by the service you are extracting, still resides in the monolith and is used by other services, we need to move the data into our service and make the monolith treat our service as the single source of truth. The monolith is changed to call out to our service and read the data or request changes.
Of course this can result in broken foreign-key constraints, broken transactional boundaries, and more. These problems arise with distributed systems and deserve an article of their own.
Synchronize Data in Application Pattern
One approach is that the application itself synchronizes both databases (the monolith database and the new, extracted database).
This happens in three steps:
Bulk synchronize Data
Make a bulk data import from the monoliths database to the new database
In the mean time there could be new changes to the monoliths database, consistently tickle copy over the new data
Synchronize on Write, Read from old database
- With both databases in sync, a new version of the monolith is deployed that now writes to both databases. Clients still read from the old database
Synchronize on Write, Read from new database
Change the read to the new database
Still write to both databases, so in case of a necessary rollback the old one is synced
Once the new database has proven it's stability and reliability, the old system can safely be removed.
Conclusion
As you can see, splitting up a monolith can be lots of work and introduces new complexity and challenges into your company. Start incrementally, and carve services out of the monolith. Hold regular review sessions to discuss what’s working and what isn’t.
This article is of course just a glimpse into the world of microservice architecture and migration patterns, there is a lot I could not cover since it would make this (already long) article even longer.
If you want to learn more about microservices and distributed systems, I strongly recommend you take a look at the resources section of this article.