Role of Module Federation in Micro Frontends
What are Micro Frontends?
Micro Frontends mimic the idea behind microservices, but for frontend instead of backend.
It's about splitting a single monolithic app into multiple, smaller apps, where each of the apps are responsible for a distinct feature of a product.
This makes it possible for businesses to scale better, when there's a team for every smaller app, which they can work on in isolation with their own technical decisions.
By keeping the apps smaller and separated, they are also more understandable and easier to apply changes to.
This should also make it possible for separate independent deployments.
How does Module Federation help with Micro Frontends?
Module Federation is a feature from Webpack 5.
The idea is that the micro frontend apps have outputs called exposes
and inputs called entries
.
Exposes
define what the micro frontend app exposes to other apps.
It is not limited to just exposing the whole micro frontend app itself, it can also be just parts of it like functions, components, full-page-contents, routing, etc.
The only limitation is that it has to be compilable to JavaScript.
Entries
define what the micro frontend app wants to use from other micro frontend apps.
These two enable runtime-integration, where micro frontend apps' source code will be loaded on the client afterwards based on entries
and exposes
- opposed to build-time-integration, where all the source code will be already available at build time.
The benefits of the runtime-integration approach are that things are no longer tightly coupled, the micro frontends can be deployed independently at any time without requiring every app that use that micro frontend to be redeployed to get the latest changes.
Module Federation also makes sure, with the help of shared dependencies, that the same dependency will only be loaded once.
Loading different micro frontend apps during runtime that use the same dependency that has already been loaded, will not try to load the same dependency again, but instead use the one that has already been loaded.
How Module Federation differs from other ways of splitting frontend monoliths
Let's imagine we have an example (picture above) of two apps, where Landing App has Header
and Footer
-components that Clothes App wants to reuse.
There are several ways how this can be achieved with their own pros and cons.
One way (picture above) is by using the npm libraries, and relies on build-time-integration.
It is about separating components (Header
, Footer
) to external libraries and releasing them with bumped up versions whenever there's an update.
The pros here are full control of the shared components, knowing what to expect as inputs and outputs for example.
The cons however are that there's a need to publish the newer version of npm library, update and redeploy all the apps that use the npm library.
Additionally, there are risks that some apps are not updated if forgotten and that some might be stuck with older version of the shared components.
The other way (picture above) is by using assets store.
It is about creating a separate project for component and storing it somewhere as artifact (static html, to Amazon S3 for example).
This would then be injected into Landing app or Clothes app.
The pros of this approach is that Header
/Footer
can update any time, and Landing and Clothes -apps will get the latest version from Shim without redeployment needed for them.
The cons of this approach is that it's mostly a big company only option, because there's a lot of context switching involved unless there's a dedicated team for Header
/Footer
-project.
There's also the risk that breaking changes can occur, because Clothes app has no control or knowledge on them.
The final way listed here is by using Module Federation (picture above).
It's similar to the previous approach, except there's no dedicated asset store or project needed for shared components, because the Landing app is also acting as package that holds the latest version of the Header
and Footer
.
The pros of this approach are similar to the previous approach, but no context switching is required, because Header
and Footer
-components are part of the Landing app.
There's also no extra asset store required, when the shared assets (Header
and Footer
-components) are stored inside the Landing App itself.
This is not without risk, similarly to the assets store approach, the Clothes app would still have no control or knowledge on breaking changes.
Syntax
There's a Create MF App -tool that generates the boilerplate code for various frontend frameworks with Module Federation.
This is also the tool that I used for creating a set of micro frontend apps with Module Federation: a clothes store that has landing page, login functionality, clothes details page and cart, and TailwindCSS for styling.
You can find the whole repository on GitHub.
I'll be using the files from this repository as reference for syntax examples in this section.
// landing-app's webpack.config.js
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
const deps = require("./package.json").dependencies;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "landing",
filename: "remoteEntry.js",
remotes: {
...
},
exposes: {
"./Header": "./src/Header.jsx",
"./Footer": "./src/Footer.jsx",
...
},
shared: {
...deps,
react: {
singleton: true,
requiredVersion: deps.react,
},
"react-dom": {
singleton: true,
requiredVersion: deps["react-dom"],
},
},
}),
...,
],
};
In the code snippet above, you can see the Landing app's webpack.config.js
.
There the name
has to be unique, because it's used to distinguish between micro frontends.
I've named it here as landing
because that's the micro frontend app's name.
filename
should be named as remoteEntry.js
based on community standards.
In exposes
-array we define all the components that we want to expose for others to use, in this case Header
and Footer
-components.
There's also the shared
-property where we write all the dependencies that we want to reuse if possible.
If there are different required versions of that same dependency, all different required versions will be loaded as fallback.
This is not always good, for example different react
and react-dom
-versions don't always play well together.
Additional singleton: true
is there to prevent this fallback logic, and forcing to not load different versions afterwards.
// clothes-app's webpack.config.js
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
const deps = require("./package.json").dependencies;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "clothes",
filename: "remoteEntry.js",
remotes: {
landing: "landing@http://localhost:3000/remoteEntry.js",
...
},
exposes: {
...
},
shared: {
...deps,
react: {
singleton: true,
requiredVersion: deps.react,
},
"react-dom": {
singleton: true,
requiredVersion: deps["react-dom"],
},
},
}),
...,
],
};
In this code snippet you can see the webpack.config.js
from Clothes app.
Here the name
is clothes
, and remotes
uses landing
as key.
That key's value landing@http://localhost:3000/remoteEntry.js
refers to the name
of the Module Federation Plugin defined in the Landing App's webpack.config.js
, and the url where the Landing App's remoteEntry.js
is hosted (in this example in http://localhost:3000/remoteEntry.js
).
shared
-property is same, which means between Clothes and Landing -apps all the dependencies that are compatible will be loaded once, and for react
and react-dom
-dependencies always only once, even if they're incompatible.
import Header from "landing/Header";
import Footer from "landing/Footer";
The Clothes app can now simply import Header
and Footer
-components with syntax above for its' own use.
Common pitfalls and techniques to counter them
Because of runtime integration, and the exposed code compiling to JavaScript via Module Federation, the type information from TypeScript is lost, especially in the case of having separate repositories for each micro frontend instead of using one shared monorepo for all micro frontends.
The other risk is that if shared components would have breaking changes, for example Header
-component's input would suddenly require a different type, the problems would suddenly occur on Clothes app after breaking change for Landing app has been deployed, which can already be too late.
These two could be solved via contracts for micro frontends, but it would mean defining types twice, once for real implementation, and once for contracts.
The other problem is basic global functionalities such as authentication and routing. By centralizing them all to one main micro frontend, you will have less repetition or clashing of routing and authentication between micro frontends.
For E2E-tests it also makes sense to have one main micro frontend which will test the whole feature.
For unit-tests it's more simple, everything that doesn't belong to the micro frontend that you're writing unit tests for can be completely mocked.
Summary
In this blog post, we had a quick look on the role of Module Federation with Micro Frontends, and how it works and differs from other ways of sharing components. We also compared pros and cons of these approaches. All in all Module Federation is a powerful tool that should be considered when thinking about Micro Frontends. It's an extremely flexible way of sharing components or apps as a whole, but depending on business requirements, it shouldn't be used alone without some extra mechanisms, such as contracts. In the end it's the business requirements that drive your architecture.