Run-Time Integration in Microfrontends Architecture: A New Vision of Web Applications
As in any field today, especially within the IT industry, we must look to the future and find feasible ways to align ourselves with the ever-changing needs of society and technology.
written by Andrei Miron (Frontend Technical Lead), in the March 2023 issue of Today Software Magazine.
As in any field today, especially within the IT industry, we must look to the future and find feasible ways to align ourselves with the ever-changing needs of society and technology. Whether we are talking about frameworks, tools or processes, the outcome is the same: new technologies will replace the old ones.
This is also true in Web Development, where the biggest changes up until now were to the frameworks we were using, how to render a page in DOM more efficiently, how to make data modelling less expensive in terms of memory and so on. It seems that the time has finally come to focus and how we choose to architect a project, in such a way that we can develop products faster in larger teams in an independent way, and this is where Microfrontends come into play.
Microfrontends are a relatively new concept in the world of Web Development, but they have quickly gained popularity due to their many benefits. As developers began to break down monolithic applications into smaller, independent services, they realized that the same approach could be applied to the frontend of web applications.
Today, there are numerous tools and frameworks available for building microfrontends, making it easier for developers to adopt this approach in their projects.
The need for microfrontends arises due to the limitations of monolithic applications, and by decentralizing the frontend into smaller self-contained pieces, we can overcome this while keeping the monorepo structure.
In short, this involves developing smaller applications (modules) that can be run independently of each other, which will eventually integrate to form a complete application, and the reasons for such an approach are numerous:
Scalability – we can have several teams working on different parts of the application without conflicts, and each can manage how they will launch their application, and in case of the increased complexity of a component, only that component can be adjusted without impacting the rest of the application.
Diversity of technologies used – because each microfrontend is independent, we have the possibility to use different technologies and frameworks as long as a few rules are followed. This helps to form teams faster because an application will no longer be constrained by only one framework. Also, whenever a new microfrontend needs to be integrated, the newest technology can be chosen.
Building applications independently – the possibility for each microfrontend to be built independently, without the need to rebuild the entire integrated application.
Easier debugging process – having the application structured in microfrontends, the risk of a problem from one microfrontend affecting another is very small. This makes diagnosing the problem easier and the whole application more stable.
Microfrontends With Module Federation
Most likely, at some point, each of us has interacted with this concept, perhaps even without realising it. One example can be some packages (components) published in an Artifactory (NPM) that we import into one or more projects to reduce code duplication. Starting from here, we can distinguish 3 ways in which we can integrate components into applications:
Build-Time Integration – before the web page loads, the files imported from outside the application must already be loaded and initialized (NPM packages).
Server-Side Integration – a server will decide which components must be loaded into the application.
Run-Time Integration – the components will be brought and loaded in on the web page only when they need to be rendered in the DOM.
If the first 2 integrations are somewhat familiar, the novelty comes on Run-Time Integration, and this is what we will focus on. Here the difference is that microfronteds, which can also be independent pages, are integrated into the application by printing a specific URL (e.g., https://my-app.com/MFE/remoteEntry.js), without the need for rebuilding the container application when a change is made to the child microfrontend.
Although we have an architecture of microfrontends, the ideal would still be to have a main component (container/parent/shell), which will have to integrate the others (children), and only through it, in special cases, the microfrontends can communicate with each other.
Along with this structure comes a set of recommendations to help as applications grow:
There should be no direct communication between child microfrontends. Sharing components from one child microfrontend to another is to be avoided. Exceptions are shared libraries connected via Module Federation.
Data transfer between container and child applications should be avoided as much as possible.
CSS should be encapsulated in microfrontends and not used in general. Style inheritance can be a problem in developing applications because there can be conflicts in naming classes or styling elements (header, body, etc.).
The container should be able to decide which version of microfrontends it renders.
As this concept of integrating microfrontends is new, so are the tools helping us develop these applications as easily as possible, and this is where Module Federation plays a significant role.
Module Federation is a plugin built into Webpack 5 that allows microfrontends to exchange components, dependencies, or even entire applications with each other without the need to copy code into separate projects. Before, if we had multiple microfrontends that we wanted to use in other applications, they had to be built and deployed inside the applications that used them; thus, the performance and size would grow exponentially due to duplication.
This plugin comes as an alternative because it allows microfrontends to distribute code in a much more efficient way by loading resources dynamically, only when a web page is rendered in the DOM (Run-Time), and not when it is built to be implemented.
Any change made to a child microfrontend will automatically propagate to the container and the other microfrontends without the need for separate implementations for each individual application. Thus, code becomes easier to maintain and understand, and teams can work more and more independently.
Setting up and using Module Federation is quite simple. But as the application grows, so will the complexity, and for a basic start it is enough to have the following things:
Firstly, to be able to expose and access different microfrontends, we will have a file called ‘webpack.config.js’ in both applications, but with different configurations:
In the Microfrontend Dashboard (child):
We need to set a name for the components we expose (name: 'dashboard'), and what the file generated by webpack will be called (filename: 'remoteEntry.js').
The 'exposes' key will expose the components we want to export, but some rules must be followed: an alias must be chosen to be able to access: 'DashboardPage', we must set the path of the component we want to expose and we can expose multiple components, but they must have different aliases.
'shared' are the packages that are used by all microfrontends. In general all devDependeincies in `package.json` should be passed there, in order to not to reload them multiple times.
In the Container Microfrontend (parent):
We must set a name for the components we expose (container).
Through the 'remotes' key, we will import the exposed components into the application, but some rules must be respected: start with the same name as the microfrontend it exposes (dashboard) and the generated file has the same name (remoteEntry.js).
Next, we'll work with the following file structure, and because we want to have language-agnostic applications, we'll also implement a function that inserts the content returned from child microfrontends into elements in the parent container. For the example below, we'll use ReactJs.
An important thing to remember is that we use a dynamic way to consume microfrontends so even if we use components from the same repository (monorepo), we need to asynchronously load these components because they will be interpreted as external modules. That's why we need to have a ‘starting’ file (bootsratp.js) where we copy the contents of the 'index.js' file and then import 'bootstrap.js' into 'index.js'.
Then we'll focus on the Dashboard application, where all we need to do is modify 'bootstrap.js', to create the function that renders the content of the Dashboard in the DOM in the parent application.
Now we can move on to the Container application, where the first step must be to bring the Dashboard component and render it in the DOM. We will do this by using a ref, and inserting the content from Dashboard into the parent app.
And from here on now, the setup will be the same as in a standard ReactJs application:
At this point, we'll have an app that will use microfrontends to render content, and that's all we need to scale from here.
What We Can Do Better, and What We Must Be Careful Of
One of the most important things to mention when it comes to microfrontends is state management, and more precisely, how data is exchanged between components. And for that, we already have some solutions:
In the case of the ReactJs application, we can use the easiest method, namely: Prop Drilling, where variables and functions can be passed via props;
Having a datastore in the application container and distributing the data to the other modules. Here we will expose hooks to consume and update the datastore, which will be used in all microfrontends.It would be ideal to use libraries like Redux-Toolkit or Zustand to ensure the application’s agnosticism.
Having the datastore in another microfrontend, thus decouples the functionalities, and has a single point of truth.
Another important aspect is routing because, in addition to the routing we will do in the container application, we also need to take care of the other microfrontends. Of course, we can handle this aspect from the container by adding paths for each new route from the child microfrontends, but this will result in a loss of independence for the applications, as a new build of the container will be necessary each time a new route is added.
Another thing we need to pay attention to is the CSS. Many times, some styles will override each other because you work in separate teams, and it's easy for one class or element in one application to override another. Among the easiest solutions are:
Setting an id for each individual application and all changes are to be made within the scope of that id.
The use of JS-in-CSS that inserts the CSS code directly on each element in the page.
The use of unique class generators (CSS Modules).
A significant improvement we can make relies on the dependencies that are shared between modules. As we noticed in the 'webpack.config' file, there is a 'shared' key whose value is a vector with the names of all the libraries that are used in that microfrontend. When the Container app imports and uses this module, it will check if those dependencies are already loaded inside the app and reuse them.
Because each application grows at its own pace, it will be difficult challenging to keep track of all the used libraries and manually update them everywhere, so we can automate the dependencies that will be brought in, so that they are not duplicated like this: 'shared:packageJson.dependencies'.
Every Good Thing Has a Bad Side
Unfortunately, nothing can only bring advantages, and in this case, the biggest disadvantage is the complexity of configuring the applications. Because it is a new type of integration, the resources and solutions to cover all the issues that arise during development and business demands can be limited or complicated to implement.
Whether we are talking about the technology chosen for each microfrontend, about creating application routes, or how we can store and transfer data from one microfrontend to another, in the beginning, most things will have to be customised for the needs of each application, but once the project has been set-up, the benefits will be multiple.
On the other hand, there is no guarantee that by choosing a different type of architecture, some other complicated issues will not be encountered.
Another problem can be errors generated by microfrontends. Although we said that these microfrontends make applications easier to debug, there are also situations when some bugs can only be discovered when integrating them into parent components.
Other counter-arguments are hard to find because if we manage to get past the configuration, the benefits, in most cases, will outweigh the risk brought.
Where Is the Trend Taking Us?
Whether we like it or not, more and more projects will be structured this way, whether they are solutions implemented at Build or Run-Time level. The fact that projects are more and more mixed, and the need to separate code into smaller components is increasing will lead us to look at this option as the best way forward.
In our example, we chose the concept of Run-Time to implement these microfrontends, but this can be done in multiple ways and in multiple configurations.
Whether we have a single monorepo or many repositories in different workspaces, whether we import the microfrontends during application creation or use dynamic solutions with Module Federation, the advantages will be more and more obvious as the productivity and autonomy of the teams will increase, and the complexity will decrease with the emergence of new solutions.