Split Monolith Into Micro Front-End
by Teobler on 08 / 02 / 2022
views
I've recently had the opportunity to use micro front-end to solve some team problems on a project, so I'd like to take this opportunity to share it with you.
As a solution that has emerged in recent years, micro front-end is not something new, but since it is a solution, what problems did it help us solve? Here I will use our project as an example to talk about.
Why do we need micro front-end?
Our project can be considered a relatively large project, with 17 business lines for whole vision. However, the project was not well thought out at the beginning for various reasons and was tackled as a normal front-end project, and after the first phase was completed and the first business line went live, we started the development of the second and third lines of business, and immediately afterwards we encountered a number of problems.
Code conflicts
When the first phaes of this project goes live it is maintained by the another maintenance team and the delivery team continues to develop the project afterwards. As all the code is maintained together as a monolith in the same repo, the two teams often have conflicting code changes and need to be careful about merging cod, as well as understanding each other's business to see if their own business will break the other's.
Deployment conflicts
As all infrastructure, including CI/CD, is common usage, any team trying to deploy their own code will inevitably have an impact on another team. Whether it be feature toggled or chunk base development, will increase the mental burden on developers.
Technology stack conflicts
As the project is relatively large and the number of future teams is uncertain, we can't limit the technology stack to a specific one, otherwise there is a risk that some teams will have to use their own completely unskilled technology stack, and besides the possibility of third-party teams joining in the future, and we don't want to tie the whole project to a particular technology stack.
With this background we found that micro front-end was a good solution to our problem. To put it bluntly, in the context of our project, what we wanted most - team autonomy.
We want the teams in each line of business to be free to modify their own code without fear of conflict with other teams. They are free to choose the technology stack they are familiar with without overly restrictive. Also the deployment of any team does not affect other teams, which means that if a section or a page that one team is responsible for goes down, the sections or pages of the site maintained by other teams are still available.
Most importantly, such a structure allows each team to focus on its own technology and business, reducing unnecessary and ineffective communication between teams and improving the efficiency of each team's development.
Opportunity of splitting
For micro front-end splitting, this is a technical improvement with a lot of work, and it is different from other technical improvements in that it has no templates and there is no way to copy exactly what other projects have done, it must be done in context of your own project.
On the other hand, we need to agree that in our day-to-day development, in most cases it is not possible to give developers enough time on a project to do technical improvements, which means that most technical improvements need to be made alongside business development. It is then important to find a opportunity to make improvements.
So when is such a time usually?
Significant changes or evolution of the business
I think most of you have experienced this situation, where product owner has decided on a requirement at the beginning of development, then development goes on. But for various reasons need to make a big change. In the face of such a big change in requirements, our code usually needs to be changed, and this change also requires some rewriting of the code, and this rewriting process is a good opportunity to split.
During this period we had good reasons to convince the project stakeholders to give us time to reorganise the project code to better support the business.
No more major improvements in business stability
At this point the business is stabilising, but the current architecture if indeed also causing a hindrance to development. Then it is possible to improve on this stable architecture. Of course at this point the business is still evolving and we can adopt two strategies:
- One of them is splitting tasks as a high priority and new business development based on a new architecture
- The other is to start with ongoing development on the old architecture and have the developer responsible for the split, then migrate the business and technology over together during the splitting process
Principles of splitting
We must have had a purpose in splitting the micro front-end, either to make incremental upgrades to the technology stack or, like us, to increase the autonomy of individual teams.
In the case of our project, we seek the highest level of autonomy for each team, then we want the individual apps to communicate and depend on each other as little as possible, and each app to be able to handle its own business as independently as possible.
With this general premise in mind, we can guide the split according to a business-oriented module approach, based on this, we have defined some principles when splitting.
- Ensure business independence, a business line should be supported by a separate app, allowing the business team to have full control of this app
- Cross-business pages should not be held separately by each business, but should also be split into a separate app
- A common utils library and a common component library are jointly maintained by all to support their respective businesses
Preparation before splitting
Preconception
single-spa
Single-spa is a micro front-end framework that does not restrict the exact technology stack used by each app, but mainly renders different apps on the page by controlling the route.
We did some research before starting the micro front-end split and then chose it as our micro front-end framework, we said research but we didn't know much about every framework at that time, such as the famous qiankun.
There is actually a little tidbit here: The first framework we learned about was single-spa. There was a small requirement that single-spa couldn't implement, so I followed the documentation on the official website to ask slack about it, and I received a reply early the next morning.
In-broswer module vs build time module
Before we start, I may need to introduce you to two concepts to help you better understand the architectural design in the next articles. The first concept is the in-broswer module, or es6 modules, which corresponds to the most widely used build time module nowadays. Let's look at a diagram:
In this diagram the two JS files are referenced by each other and the final packaged result is the build time module, which you may think is separate when writing the code, but in fact the contents of the two files are merged into one JS file which is then referenced by the HTML file.
For in-broswer modules, which are requested by the browser from the network based on the url you provide, each import you make represents a network request, and each file really becomes a separate module that depends on each other through network requests.
But one disadvantage of such a module is that it does not have the ability to refer directly to the corresponding module by giving it a name, as we do in everyday development like this:
import singleSpa from "single-spa";
As it is necessary to locate where this module is in the network and send the corresponding request, it requires a complete url:
import singleSpa from "https://cdn.jsdelivr.net/npm/single-spa/esm/single-spa.min.js";
Import-map
This feature makes it unpalatable to most programmers; after all, most people don't want to write a long string of URLs to refer to a module. To solve this problem, WICG has drafted a new browser specification called import map:
<script type="importmap">
{
"imports": {
"single-spa": "https://cdn.jsdelivr.net/npm/single-spa/esm/single-spa.min.js"
}
}
</script>
The import map is a special script tag with the type importmap, inside the script tag is a json object whose key is the name of a module and whose corresponding value is the url of the module.
Of course, since import-map is a script tag, it stands to reason that it could also be an external script by adding the src attribute:
<script type="importmap" src="https://some.url.to.your.importmap"></script>
So we can use the in-broswer module in our code as if it were a normal module, according to this import-map:
import singleSpa from "single-spa";
In some cases, different versions of same package may be referenced in your project, and the scopes function of import-map can be used to restrict the reference to a particular file:
<script type="importmap">
{
"imports": {
"lodash": "https://unpkg.com/lodash@3"
},
"scopes": {
"/module-a/": {
"lodash": "https://unpkg.com/lodash@4"
}
}
}
</script>
The scopes here means that if a module starts with module-a, then if it contains an import that refers to lodash, this import will refer to the v4 version and all other imports will refer to the v3 version.
Systemjs
Then comes the front-end legacy show, and it's clear that such a new specification is not currently supported by most browsers, not to mention IE, which never will be. So we need polyfill - systemjs, how it works will not discussed here, if you are interested you can read the documentation on github via the link. In a word, systemjs is a special polyfill for the es-module.
Let's look at a simple demo to see how it makes import-map work:
This is a very simple demo, the HTML page has a template, and then an es-module is imported, this module is also very simple, all it does is import vue and replace the name in the template with what we want.
But here is a detail, when we import vue we have to use a url to import it, what happens if we replace this url with the string we normally use when developing?
This error occurs because we marked the script tag as an es-module, so the import keyword is executed by the browser at runtime, but since the string after it doesn't tell the browser the right location for resource, the browser can't find the resource, and it reports an error.
If we want to replace the url with the string we normally develop with, we have to rely on import-map, but most browsers don't support this feature yet, so we need to introduce systemjs:
Since we are using systemjs, in order to play by its rules, we need to modify some code on the original specification:
- The first thing we need to do is import systemjs at the beginning
- Then change the type of import-map from
importmap
tosystemjs-importmap
- Next change the type of es-module from
module
tosystemjs-module
- Finally, the biggest change is that in es-module we no longer use import and export to import and export modules, instead we use systemjs syntax, but don't worry, packaging tools like webpack and rollup now support packaging code in systemjs style, so we can still write our code as normal
Architecture design
This is the end of our preconceptions and we are ready to start the formal splitting process, but we need to design our infrastructure architecture and code organisation in advance before the splitting can begin.
Infrastructure Architecture
Based on single-spa plus import-map, our final planned infrastructure architecture looks like this:
- Firstly, all the static resources for our front-end are deployed separately in AWS's S3 service, with the only HTML file stored in the S3 of the root container.
- When a user accesses our site, traffic arrives at the AWS S3 of the root container from the client side, and the user's browser loads the HTML page in the root path first, which has an import-map script in the head tag.
- At this point the client will send another request to the S3 where our import-map is located to get the import-map.
- Then we import the root container with systemjs in the body tag and the whole APP starts to run, next we go to different S3 to get the corresponding static files according to different paths
Deployment strategy
Deployment is an essential part of achieving autonomy for each team, and the ultimate goal is that different teams' deployments don't affect other teams' operations. It makes sense for a to business project when one team's online code goes down but the other team's business still works.
For this purpose, each team maintains its own CI/CD pipeline for its own app, and it is important to note that you need to update the import-map with the address of your own team's app after each deployment, so that you can also manage the versioning process. As long as a version of a static resource is always stored in S3, simply updating the import-map with the corresponding address will allow for quick deployment and rollback.
Local Development Strategy
There are two strategies when developing locally, one is to start a root container directly locally and then register the local APP into the root container.
However, such a development approach requires the resolution of dependencies, such as common utils libraries and common component libraries that the APP depends on. There are two ways to solve these dependencies.
One is to package the corresponding dependencies directly, configure them locally, and reference the packaged dependencies directly during local development;
The second way is to run these dependencies as a shared APP directly locally as a server, and then share them via import-map, and reference the exported methods and components directly during development, which is also provided by single-spa, for those interested in this link.
The second way is much simpler and the development experience is much better. Usually we have a development environment. We can use the tool import-map-overrides to override the online import-map with the APP address to the local address. Then, when browser look for the APP through the import-map, it will request your local address directly, so the code be requested will be your local code, you can develop seamlessly with various dependencies.
You might worry about there are security issues, but the tool can actually be configured in such a way that the port is only opened locally and under a certain domain(e.g. devementment env), and the backdoor is not opened anywhere else.
Actual splitting
After all this, it's finally time to get started, but there's a famous saying that says 'What you dream is light, what you see is night'. When you have all your plans ready to go, reality doesn't usually give you what you want. Some of our plans that looked so good were temporarily shelved by our father, and some of them were revamped due to poor design and development experience.
Too expensive
Cost is always a topic that cannot be negotiated with the client, and our new architectural design adds a number of things to the monolithic front-end:
- multiple pipeline
- multiple resource deployments(each APP use an isolation S3)
- Extra import map service
I'll have to pay 100$ for a project that used to be done for 10$, you're making it hard on my wallet!
So says the client. In this case, we had to negotiate with the client as to why these things were necessary and why we needed to add so many resources. But the problem with the project was that we didn't have time to negotiate, so we decided to "downgrade the structure":
- Build our app with a single pipeline for now, and split the pipeline when we have enough evidence for the next installment
- This decision turned out to be completely wrong. Imagine an agent with 1G of RAM that needs to build a front-end project with 5 APPs
- Also due to the wallet problems of our client, we only have one agent for our project, so please imagine our daily development haha
- Temporarily deploy all apps to the same place, separated by folders, and then leave them as they are if you find they meet your needs after a while
- Generate one import map per build, do not maintain separate import map resources, and seek to split them when teams interact
repo splitting problems
We started with the idea of a separate APP split into a separate repo, but when we really got going, on reflection, was it necessary?
This reminds me of the microservice repo on the backend of the Phase 1 project. As it was a Phase 1 project, calls between different microservices required setup, so most of the time there were more than three Intellij's opened locally, and with the mess of other applications, it was a test for a Macbook with 16G RAM.
Back on the front-end side, it is highly likely that we will extract/change the common codebase frequently during our daily development, which means we will need to commit changes and update versions frequently before we can use them.
Furthermore, the current volume of the two teams does not really need to be split in such detail
Cross-business page splitting issues
The original vision was that a line of business would be a separate APP, and that some cross business pages (i.e. pages that each business would have, such as User Account Management) would also be extracted into a separate APP.
And we really did, and then we have encountered many difficulties:
- "BA says this page is unified, and the changes to this business, that business too." "Extract it!"
- "BA says this new page is to be standalone and all new features are to take effect in all businesses." "Extract it!"
- ......
"The logic of this public page is the same as the logic over there, we make it a copy?" "......"
This strategy resulted in a large number of APPs in our project which, on reflection, seemed unnecessary. Adding to the cost of building and adding to our own development and maintenance costs was putting the cart before the horse, so we made an improvement - we crammed all the public pages into one APP.
This sounds strange at first, but when you do it, you find it really works. All changes will take effect in all businesses, with different permissions for different businesses and everyone maintaining the same copy of the code. Wait a minute, didn't you just say that you didn't want everyone to maintain the same code for fear of conflicts?
This way we don't have to maintain multiple copies of the code and we don't create conflicts - because requirements are one-way and if there is a conflict, it's a requirement conflict and the golden father has to break it up internally.
Some of you may say, "Why not try the back-end splitting approach and use DDD to guide the splitting? Coincidentally, we started off with a back-end DDD approach to guide the splitting, and then these problems occurred, at least in our practice.
CSS conflict issues
This is another serious problem we encountered. We used Material UI in our project, where the CSS was CSS-in-JS, and because we had our own set of class name generation rules, the style names of multiple APPs conflicted without proper scope control, leading to serious interactions.
This is not a single-spa problem, but single-spa does offer some solutions, including the isolation of JS libs and CSS, which can be easily searched on the website or in the [github issue](https://github.com/single-spa/single-spa/ issues/362), so I won't go into too much detail here. The key to solving the problem is to use different JS or CSS solutions with appropriate isolation.
Written at the end
The above are probably the things that I still remember from the process of splitting the micro front-end. The biggest benefit to me from this split is not really the technical improvement, but the understanding of two key points of doing the project:
- Everything will not go according to your plan, the bigger the event, the more so. Consider the unexpected in time, be flexible, don't stick to the design and change your plan based on reality.
- It is not necessary to consider all future scenarios in a single architectural evolution, not to mention whether you can be thorough, and who can say that future scenarios will not change? Don't speculate on the future with the present, just live in the present, design flexibly, prevent future situations in advance and prepare for plan B.
Reference
[1] single-spa
[3] micro-frontends-experience
[4] systemjs