In simple words, micro-frontends are for the frontend as microservices are for the backend. This is achieved by converting a monolith web application into distributed, small individual applications. In some cases, these individual applications can run as independent applications, which can be developed and deployed independently. Furthermore, when combined with other applications, they can run as a single application.
At Harness, we wanted to adopt micro-frontends architecture to split our monolithic UI application, to achieve the following goals:
Performance: Not all parts of the application are used by all users. The ability to load certain parts of a complex app on-demand is beneficial for initial application load times.
Independent Deployments: Harness is a complex application. Therefore, it consists of various modules. If micro-frontends architecture is adopted, then individual modules can be divided as different child applications, and they can be deployed independently in place of the current approach of deploying every module in one go.
Development velocity: It can be increased significantly, since modules can be run independently. This removes the overhead of running the full application to fix/implement a feature within one module.
Cognitive Load on Developers: We could reduce it to understand the complete application, even though they might be working on a single module.
Challenges With Micro-Frontends
When we started to look for different solutions that were available on the market for implementing micro-frontends, we required that the following points should be covered:
Dependency Sharing: We wanted a solution that could handle dependency sharing with ease. Application dependent resources, such as common libraries, React, Lodash, and React Router. These are a few that will be used by both child and parent applications. The chosen architecture shouldn’t reload the common resources when a child is loaded dynamically.
Data Sharing: When data must be shared between the parent and child, the preferred solution should be able to handle two-way data transfer in the most efficient way.
Resource Sharing: There is a need to share some common UI components across child and parent applications. This should look consistent across all of the modules, and they shouldn’t be reloaded in the child when the same is already available within the parent application.
Independent Deployment: We wanted a solution that could help us deploy the child modules independently and load the latest available child modules whenever the parent application is loaded - without the need to redeploy the parent application.
Independent Development: The preferred solution should help developers run the child application independently in local.
Micro-Frontends Solution Types
The challenges above can be broadly categorized into two categories:
How the child application will plug in to the parent application.
How the data/resource will be shared to the child application.
Therefore, we came up with the following solutions to solve these two problems.
Plugging in Child Application
Webpack Module Federation: We use the Webpack bundling tool to run the child application on a particular path from where the parent dynamically loads the child application.
Import Module As an App: The child application is loaded in the same way as a third-party library. Prior to this, the child application would be published as a library, too.
Iframes: We load the child application, which runs on a separate location using iframes.
Data/Resource Sharing to Child Application
Managed Wrapper: We load child applications using any one approach from above. However, when loaded in the parent, it will be wrapped in a wrapper, which takes care of transferring the data from parent to child and vice versa.
Webpack Module Federation
This is the most popular approach, where a bundling tool named webpack helps expose the child application as a service at a particular path or port. The parent is preconfigured to hit the child path when it wants to load the child application dynamically.
In the following diagram, the Harness application is the shell app or parent application, and CI, CV, etc. are various modules within the Harness application.
Pros:
Handles the bundling/loading of modules and dependency sharing out of the box.
The full app is combined into one only at runtime.
Cons:
Doesn’t solve data sharing and resource sharing.
Locked in to webpack as the build tool.
Import Module As an App:
In this approach, we bundle a child application as an npm package and publish the package. Later on, we download the child package as any other npm package and start using it as any other library in the parent application.
Pros:
Static imports result in type safety as long as the child publishes its types.
Allows modules to be developed independently with certain restrictions.
One single bundle means no network delays in loading micro UI.
Cons:
Lazy loading needs extra effort to implement
Building parent applications (NGUI) has a high cost as it bundles child apps, too.
The child application cannot import code from the parent app, which means resource sharing is difficult.
No independent child deployments.
Managed Wrapper:
In this approach, we run a child application inside of an HTML element, such as a `div` of the parent application code. The method for getting the child code can be either of the two approaches discussed above. The idea behind this approach is to write a wrapper that can wrap the child application inside of a parent application, which also takes the responsibility of passing the data down to the child and vice versa. This wrapper is written so that it can understand the parent data format and child data format, given both of the applications publish data types ahead.
Pros:
The child application is not restricted to any single rendering or build framework.
The child can use resources/components from the parent via dependency injection.
The parent controls what shared data is available to the child.
Cons:
This only solves data and resource sharing. It still must be combined with one of the earlier two approaches for bundling and dependency sharing.
Iframes:
This is one of the most common approaches that many organizations adopt to implement micro-frontends. In this approach, a child application runs at a specified predefined path as an independent application. When a parent needs to load this child application, it simply loads the child application in an iframe using the predefined path. The main disadvantage with this approach was its many security issues. Furthermore, we found that even sharing the resources wasn’t seamless when compared to the above approaches.
Pros:
Easiest to implement for a naive solution.
Complete isolation of child applications.
Cons:
Potential security concerns (possibly solved using a correct CSP configuration).
Dependency sharing is very difficult.
Data sharing is very costly (requires (de)serialization).
Our Micro-Frontends Approach
We run the child module as a separate service using Webpack module federation, a separate path/port.
When we load the child application dynamically in the parent app, we run that child app inside of a wrapper. This takes care of the sharing of the data/resources to and fro between the parent and child applications.
To understand the parent data format by child, the parent app publishes a package containing data types of the resources/data which it wants to share with the child.
As long as the wrapper running in the child application has the ability to convert the parent data to child-understandable data, then the child is free to run in any framework of its choice and not necessarily to run the parent framework (such as React).
Pros:
Data and resource sharing is easy.
Lazy loading.
Parent bundle is small.
Two-way data sharing.
Child application deployment and development is independent.
Not dependent on any single third-party library.
Parent control on resource sharing.
Minimal changes to NGUI.
Free to experiment in the future as it is custom to Harness.
Helps with open source as it isn’t dependent on any third-party libraries.
Cons:
Maintenance overhead.
Micro-Frontends Implementation Steps
Publish types/interfaces as a package from the parent application.
Parent exposes interface on how the child should be rendered in the parent.
Run the child as a separate application/service.
Lazy-load the child module.
Pass common components/context data from the parent to the child application at runtime.
The following shows the steps in our approach.
Data Types Sharing
Data Flow
Conclusion
By using our micro-frontends solution, we dynamically loaded the child application within a wrapper inside of the parent application. This behaves like a proxy to send and receive the data between the child and parent applications. We were able to reuse the common components in the child application from the parent without reloading them. This approach also helped us deploy the child application separately whenever required, without being dependent on the parent application.
This also helped developers independently run the child application without running the parent application while they work locally. As we pre-published the parent application types, developers who work with child application had the knowledge of the structure of the data. This is used by the parent application, and it passes it to the child application, which helped the developers immensely when working with the child application.