Transforming a Huge Monolith into Micro Frontends

Started with a monolith front-end application? That’s what we did at Adobe when the team size was small in the early stage of our application (Adobe Campaign). But as the team expanded, it became more challenging to maintain a single codebase. Multiple teams were contributing to the same codebase, with each team having different commitments toward their goals and feature ownerships. That’s when we identified potential improvement areas in our existing architecture.
We tried to document all the challenges, and a few of them are:

- Large build/deploy size and time.
- Longer time to run unit/integration test cases.
- Even if a tiny change needs to be pushed, the entire codebase has to be built, tested, and deployed, which is very time and resource-consuming.
- Pipeline jobs (because of multiple parallel pull requests) may get stacked and decrease developer productivity.
- High-memory Jenkin machines are required to build such applications.
- Understanding a large codebase is extremely difficult for designated owners/engineers, especially the new joiners.
So, the objective was to split the monolith into multiple smaller applications that can be developed and deployed independently. We call this strategy micro frontends, which Thoughtworks technology radar defines as:
An architectural style where independently deliverable frontend applications are composed into a greater whole.
But, why micro-frontends?
That’s the first question asked by most of the team members. Based on the offerings of micro-frontends, we listed a few reasons:
- Smaller isolated codebases.
- Faster and independent deployments. Teams have the flexibility to follow any release cycle(weekly, biweekly, monthly, etc.).
- Lead to smaller impact areas that do not require testing the whole application after deployment.
- Each team can fully “own” the codebase/feature in their respective territory. It also enables subject area expertise, which is helpful in on-call groups/rotations.
- Jenkins machines consume lesser time and resources.
Now, the next step is to explore different ways of implementing micro-frontends. So, we evaluated
Ways of Implementing Micro Frontends
- Build Time Integration- Each micro frontend app is published as a package, and the core app pulls in these packages as dependencies at build time.
- Run time Integration- These are the following three ways to do runtime integration:
- Multi-SPA/Iframes: This method offers complete isolation in which each part is loaded in an Iframe. We need to split our application only based on the routing. Handling common libraries among various isolated apps is very tricky. Communication between different parts of the application is very cumbersome.
- Web Components: Use native events and web components. Web components can isolate their styles.
- Javascript: apps are loaded dynamically, and a function call handles mounting the app.
Javascript micro frontend alternatives
- Webpack module federation.
- Bit
- Single SPA
- SystemJS
- Open Components
- and many more…
After evaluating based on various factors we decided to go ahead with the Webpack module federation. Now let’s dig deeper into the implementation details.
Module federations using Webpack 5
We used Webpack 5’s Module Federation to split the application into multiple modules/repositories. Let’s understand the implementation with a very simple example.
Exposing modules
Webpack 5 uses ModuleFederationPlugin to expose components to be used in remote repositories.
Here’s the snippet which allows library components to be exposed as library.js:
new ModuleFederationPlugin({
name: 'mf-library',
filename: 'library.js',
exposes: {
'./library': './src/index', // files to be exposed
},
shared: {
react: {
singleton: true,
requiredVersion: '16.14.0',
},
'react-dom': {
singleton: true,
requiredVersion: '16.14.0',
},
'react-router-dom': {
singleton: true,
requiredVersion: '5.3.3',
},
'<other-dependencies>': {
singleton: true,
requiredVersion: '5.3.3',
},
},
}),
it also allows you to use a single version of libraries like React, React DOM etc. across all micro apps.
Importing modules
MF Apps can import remote modules using remotes configuration. Here’s an example:
new ModuleFederationPlugin({
name: 'main-app',
remotes: {
'mf-library': 'mf-app-library@https://url-of-library.js',
'mf-dashboard': 'dashboard@https://url-of-dashboard.js',
},
shared: {
react: {
singleton: true,
requiredVersion: '16.14.0',
},
'react-dom': {
singleton: true,
requiredVersion: '16.14.0',
},
'react-router-dom': {
singleton: true,
requiredVersion: '5.3.3',
},
'<other-dependencies>': {
singleton: true,
requiredVersion: '5.3.3',
},
},
}),
Rendering Remote Modules
After importing, the next and most important step is to render the imported remote modules.
const RemoteDashboard = React.lazy(() =>
import('mf-dashboard/dashboard').then((module) => {
return { default: module.default };
}),
);const Dashboard = () => {
return <Suspense fallback={<Spinner />}>
<RemoteDashboard />
</Suspense>
};
But, what about typescript type definition?
As you just saw in the above example that using ModuleFederationPlugin webpack exposed Dashboard and Library components in a .js file which doesn’t have the corresponding type definitions.
So to import type definitions we used the @module-federation/typescript
plugin using a very small configuration change
new ModuleFederationPlugin({
name: 'main-app',
remotes: {
'mf-library': 'mf-app-library@https://url-of-library.js',
'mf-dashboard': 'dashboard@https://url-of-dashboard.js',
},
shared: {
react: {
singleton: true,
requiredVersion: '16.14.0',
},
'react-dom': {
singleton: true,
requiredVersion: '16.14.0',
},
'react-router-dom': {
singleton: true,
requiredVersion: '5.3.3',
},
'<other-dependencies>': {
singleton: true,
requiredVersion: '5.3.3',
},
},
new FederatedTypesPlugin()
})
You need to register this plugin in both remote and host apps. The plugin will automatically create a directory named @mf-typescript
in the host app - which contains all the types exported by the remote apps.
To have the type definitions automatically found for imports, add paths
in tsconfig.json
:
{
"compilerOptions": {
"paths": {
"*": ["./@mf-typescript/*"]
}
},
}
Application Architecture
Since we started with a huge monolith, all of the following components used to be a part of the application block initially which was later migrated to separate repositories. Each block in the diagram of the following components is a Micro frontend which can be developed and deployed independently.

1. Registry Config
This will hold the configuration for each remote build. Using this configuration each Micro App will load the required remote dependency at runtime.
2. Application
This is the main container of the application which renders the SPA and its associated routes. This also collates and hosts other MF Apps.
3. Common Libraries
This will hold the components which can be shared among all Micro Apps.
4. MF Apps
This will be a part of the whole application that can be developed and deployed independently. For example Homepage, Delivery UI, and Control Panels are the best suitable Micro-Apps for the ACC Web UI.
Conclusion
If you have already read the whole story, you must be interested to know some of the statistical improvements we achieved by adopting micro frontends. Here are some of them:
- The average time taken by each build and deployment pipeline job got reduced from ~12 minutes to ~4 minutes.
- The average number of unit test cases run on every deployment got decreased from ~1500 to ~250–300.
- The average memory consumption in Jenkins machine by each deployment job got reduced from ~35GB to ~6GB.
Lastly, I hope the numbers themselves are a huge motivation to transform your monolith into micro frontends.
Happy Transforming.
Cheers!