Following the first article on the series, which describes the architectural choices in detail, this one will focus on implementing that architecture based on Micro-Frontends (MFEs) using Module Federation. By the end of the article, we’ll have created and discussed an open sourced solution that keeps the UX feeling of a Single Page Application to the customer while letting independent teams work on different products.
The business we’re going to simulate is a clothes marketplace. It has what you can usually find in an ecommerce store: a list of items, item details, checkout and a blog (the blog is not that common, but it fits the theme).
This is what our clothes shop looks like:
We are going to follow along with this GitHub repository and also a few gists, so we have small snippets of code representing the implementation steps.
The project does not have a CI/CD configured and it’s not deployed anywhere. We are going to cover deployment and changes needed for that in another article on the series.
For now, we can run the solution locally, both in development
and production
environments.
The project also doesn’t have a proper state management solution. This will be covered in a future article.
The architectural choices and discussion for this solution are present in the first article of the series, so be sure to check it out. In summary, here is the diagram we’re going to implement throughout this article:
If this diagram brings up any doubts or questions, please read the first article on the series, which dives in great detail on the architecture of the solution.
In the root folder, we have our main package.json
file, which will be responsible for running our orchestration commands and hold the configuration for yarn workspaces
.
{
"name": "clothes-store-micro-frontends",
"version": "1.0.0",
"private": true,
"repository": "git@github.com:comoser/clothes-store-micro-frontends.git",
"author": "David Alecrim <david.alecrim1@gmail.com>",
"license": "MIT",
"workspaces": {
"packages": [
"products/*"
]
},
"scripts": {
"start": "concurrently \"wsrun --parallel start\"",
"start:live": "concurrently \"wsrun --parallel start:live\"",
"build:all": "concurrently \"wsrun --parallel build\"",
"serve:all": "concurrently \"wsrun --parallel serve -s\"",
"build:serve:all": "yarn run build:all && yarn run serve:all"
},
"devDependencies": {
"concurrently": "^5.3.0",
"lerna": "^3.22.1",
"rimraf": "^3.0.2",
"wsrun": "^5.2.4"
}
}
Basically, we’re telling yarn that our workspaces will be all the folders inside the folder products
, which in this case are:
Note: yarn workspaces will require you to have a private: true property on the root level package.json, in order to manage the different MFEs dependencies correctly.
Pro Tip: yarn workspaces will try to hoist the dependencies of the MFEs to the root dependencies if possible. This works for most npm packages, but there may be a case where this doesn’t hold true. In those cases, you can specifically tell yarn to not hoist them, e.g.:
"workspaces": {
"packages": [
"products/*"
],
"nohoist": [
"**/full-icu"
]
},
This way, the full-icu
npm package will remain in each MFE node_modules
folder.
We also leverage the concurrently
and wsrun
packages so that we can run in parallel our MFEs. The start:live
script will be the goto command when developing, and to test the solution in production, the build:serve:all
will be our choice.
Then, in every MFE root folder, we will have a package.json
with commands that will respond to the commands in the root level package.json
.
{
"name": "app_shell",
"version": "1.0.0",
"license": "MIT",
"scripts": {
"build": "webpack --mode production",
"build:dev": "webpack --mode development",
"serve": "cd dist && PORT=3000 npx serve",
"start": "webpack-dev-server --mode development",
"start:live": "webpack-dev-server --mode development --liveReload"
},
(...)
}
Note that the ports defined are different for each MFE, starting in 3000 and ending in 3004
. You can find these ports for development purposes in the respective webpack.config.js
files and for production in the respective serve
commands.
Now that the initial setup is covered, let’s check the different MFE configurations.
The webpack configuration file for every MFE has a section with a plugin called ModuleFederationPlugin. This is the configuration that will let us take advantage of this new webpack feature.
// (...)
new ModuleFederationPlugin({
name: 'app_shell',
remotes: {
items: 'items@http://localhost:3001/remoteEntry.js',
checkout: 'checkout@http://localhost:3002/remoteEntry.js',
blog: 'blog@http://localhost:3003/remoteEntry.js',
shared: 'shared@http://localhost:3004/remoteEntry.js',
},
shared: {
...deps,
react: {
singleton: true,
requiredVersion: deps.react,
},
'react-dom': {
singleton: true,
requiredVersion: deps['react-dom'],
},
'react-router-dom': {
singleton: true,
requiredVersion: deps['react-router-dom'],
},
},
}),
// (...)
Starting with line 3
, the name
property is the unique identifier of the federated module (in our context, the federated module is always a MFE). When another MFE needs to reference this one, it will need to specify this unique name
.
remotes: {
items: 'items@http://localhost:3001/remoteEntry.js',
checkout: 'checkout@http://localhost:3002/remoteEntry.js',
blog: 'blog@http://localhost:3003/remoteEntry.js',
shared: 'shared@http://localhost:3004/remoteEntry.js',
},
On the above snippet (regarding the webpack.config.js), the remotes
property is defined. This property decides what MFEs the current MFE requires. All the dependencies that the current MFE needs, will be imported at run-time from the defined remotes. In this case, like in the architectural diagram presented in the architecture section, we can verify that the App Shell is importing code from items, checkout, shared and blog.
Note: If we take the example at line 5, the key in the remotes object is reference in this MFE to the remote. The string value of that key is what will enable the connection with another MFE.
In this case, the “items” before the “@” is the name property of that MFE. The URL after the “@” matches the location where that MFE is deployed.
shared: {
...deps,
react: {
singleton: true,
requiredVersion: deps.react,
},
'react-dom': {
singleton: true,
requiredVersion: deps['react-dom'],
},
'react-router-dom': {
singleton: true,
requiredVersion: deps['react-router-dom'],
},
},
On the code above, the shared
property is defined. This configuration is really important and is the cause for a lot of problems when not configured properly. Let’s explore more on this topic.
The configuration presented in the gist contains a few interesting options.
The ...deps
object spread pulls all of the dependencies
in package.json
, which will automatically share all libraries defined in it. This is done in order to better manage dependencies.
Webpack will be able to verify if the application already has a dependency, and if it has, it won’t load it again. These verifications happen when importing code at run-time. Pay attention to the version of the dependency, because if it doesn’t match, webpack will load both versions.
The singleton
property definition is also important to notice. It is used every time a dependency has internal state. Since, e.g. React and React-dom have internal state, by defining them as singletons, we are telling webpack that we never want to load two versions of this dependency.
This shared object configuration is the same in every MFE of the solution. Depending on the projects this object may differ a lot, so always make an effort to fine tune the shared
property.
The module federation configuration changes a bit in this MFE. It features two new properties not present in the App Shell MFE.
// (...)
new ModuleFederationPlugin({
name: 'items',
filename: 'remoteEntry.js',
remotes: {
shared: 'shared@http://localhost:3004/remoteEntry.js',
},
exposes: {
'./Routes': './src/components/routes',
},
shared: {
...deps,
react: {
singleton: true,
requiredVersion: deps.react,
},
'react-dom': {
singleton: true,
requiredVersion: deps['react-dom'],
},
'react-router-dom': {
singleton: true,
requiredVersion: deps['react-router-dom'],
},
},
}),
// (...)
The filename
defines the name of the file that serves as a manifest for other MFEs. It has information describing the location of code that is exposed and a few other configurations. When a MFE exposes code for other MFEs to consume, the filename
property is mandatory.
The exposes
key defines the components or general code that is exposed by the current MFE. Everything that can be parsed by webpack can also be exposed in Module Federation (primitives, functions, objects, react components, etc).
The main mechanism to import MFEs at run-time is to import the routes of that MFE. This allows for the App Shell to serve as a single entry point to the platform and importing what the user needs from other MFEs on the fly. There are also cases where a component from a remote is needed and is imported directly, instead of using routing. The checkout is an example of this. It exposes its routes while also exposing a CheckoutCart
, allowing the navbar
in the App Shell to have a small checkout component.
This single entry point pattern allows the application to feel as a regular SPA to our customers, since when the user navigates to a route belonging to a specific MFE (e.g. items), it asynchronously loads those routes at run-time. While they are being fetched, it displays a loading message. After being loaded, they are cached by webpack. From that point onward, the user interactions under those routes (e.g. /items), are handled by the remotely imported MFE.
In the code snippet below, we can see how we are safely importing react components at run-time.
import React from 'react';
import ReactDOM from 'react-dom';
import {
BrowserRouter as Router,
Switch,
Route,
Redirect,
} from 'react-router-dom';
import Navbar from './components/navbar';
import AsyncLoader from './components/async_loader';
import GlobalState from './components/global_state';
const ItemRoutes = React.lazy(() => import('items/Routes'));
const CheckoutRoutes = React.lazy(() => import('checkout/Routes'));
const BlogRoutes = React.lazy(() => import('blog/Routes'));
ReactDOM.render(
<Router>
<GlobalState>
{(itemsInCart, setItemsInCart, setNotification) => (
<>
<Navbar itemsInCart={itemsInCart} />
<Switch>
<Route path="/blog">
<AsyncLoader>
<BlogRoutes />
</AsyncLoader>
</Route>
<Route path="/checkout">
<AsyncLoader>
<CheckoutRoutes
itemsInCart={itemsInCart}
setItemsInCart={setItemsInCart}
setNotification={setNotification}
/>
</AsyncLoader>
</Route>
<Route path="/items">
<AsyncLoader>
<ItemRoutes
itemsInCart={itemsInCart}
setItemsInCart={setItemsInCart}
setNotification={setNotification}
/>
</AsyncLoader>
</Route>
<Redirect to="/items" from="/" />
</Switch>
</>
)}
</GlobalState>
</Router>
, document.getElementById('app'),
);
In this case we are analysing the App.jsx
file for the App Shell MFE and there are two main things required to safely import the routes:
Both of these things are components from the React Suspense API.
The import of the remote components is done as follows:
const ItemRoutes = React.lazy(() => import('items/Routes'));
const CheckoutRoutes = React.lazy(() => import('checkout/Routes'));
const BlogRoutes = React.lazy(() => import('blog/Routes'));
And to call those components, we use AsyncLoader
. This is a component that simply wraps the children in a React.Suspense
and ErrorBoundary
components. By using it, we guarantee that we have a fallback for when the components are still being imported and that any error triggered while loading the code will be caught by the error boundary, allowing the application to remain functional, despite any critical javascript error.
The AsyncLoader
is as follows:
import React from 'react';
const AsyncLoader = ({ children, noLoading }) => {
return (
<ErrorBoundary>
<React.Suspense fallback={noLoading ? '' : <span>loading...</span>}>
{children}
</React.Suspense>
</ErrorBoundary>
)
};
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
console.error(error, errorInfo);
}
render() {
if (this.state.hasError) {
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
export default AsyncLoader;
This can of course be better suited for a production application. When failing we would want to display a good looking page, with some helpful information to our customers. We would probably want to log this error to Sentry or any other tool like it. This is the perfect place to do it.
Then we simply use the AsyncLoader
to render our MFE specific routes:
<Switch>
<Route path="/blog">
<AsyncLoader>
<BlogRoutes />
</AsyncLoader>
</Route>
<Route path="/checkout">
<AsyncLoader>
<CheckoutRoutes
itemsInCart={itemsInCart}
setItemsInCart={setItemsInCart}
setNotification={setNotification}
/>
</AsyncLoader>
</Route>
<Route path="/items">
<AsyncLoader>
<ItemRoutes
itemsInCart={itemsInCart}
setItemsInCart={setItemsInCart}
setNotification={setNotification}
/>
</AsyncLoader>
</Route>
<Redirect to="/items" from="/" />
</Switch>
Notice that the remote imports of the MFE routes are inside a base Route with the URL for each MFE (e.g. /items). By doing it this way, we make sure that the MFE related contents are only imported when the user actually needs them.
For development purposes, it’s essential to be able to run a MFE in standalone. It cuts the time to launch the application and overall improves the development experience when making small adjustments. Of course that at some point in development, you’ll have to run the whole orchestration with all the MFEs running, to test integration (these tests should be both manual and automated).
To achieve this we basically have two entry points to our MFE:
App.jsx
will be entry point in standalone (normal react application entry point)routes.js
will be the entry point when the MFE is run in the whole orchestrationThe routes.js
component has quite literally the routes that the MFE has. In case of the Items MFE:
import React from 'react';
import { Route, useRouteMatch } from 'react-router-dom';
import ItemList from '../item_list';
import ItemDetails from '../item_details';
const Routes = ({ itemsInCart, setItemsInCart, setNotification }) => {
const { path } = useRouteMatch();
return (
<>
<Route exact path={path}>
<ItemList
itemsInCart={itemsInCart}
setItemsInCart={setItemsInCart}
setNotification={setNotification}
/>
</Route>
<Route path={`${path}/details/:itemId`}>
<ItemDetails
itemsInCart={itemsInCart}
setItemsInCart={setItemsInCart}
setNotification={setNotification}
/>
</Route>
</>
);
};
export default Routes;
For the App.jsx
that serves as the standalone entry point there is an extra step:
import React from 'react';
import ReactDOM from 'react-dom';
import {
BrowserRouter as Router,
Switch,
Route,
Redirect,
} from 'react-router-dom';
import Routes from './components/routes';
ReactDOM.render(
<Router>
<Switch>
<Route path="/items">
<Routes
itemsInCart={[]}
setItemsInCart={() => {}}
setNotification={() => {}}
/>
</Route>
<Redirect to="/items" from="/" />
</Switch>
</Router>
, document.getElementById('app'),
);
Since in the App Shell MFE we have a base route for each MFE, when running in standalone mode, it’s easier to have this base route setup as well. This will allow us to reuse the routes.js
component directly.
With this setup you can now run any MFE in the solution as standalone.
The final result can be checked in this GitHub repository 👈.
In order to preview the clothes store and test it out, you have a couple of commands available under package.json
:
start:live
which will start all the MFEs in development mode and with hot reload onbuild:serve:all
which will build all the MFEs in production mode and serve
with the serve npm packageIf you want to open the respective browser tabs for each MFE automatically, then you can also add the --open
argument in the start:live
script.
In this situation you most likely got the exposes syntax wrong:
exposes: {
'Routes': './src/components/routes', // wrong
'./Routes': './src/components/routes', // correct
},
This error can happen due to a couple of reasons:
filename
in the remotes reference:remotes: {
shared: 'shared@http://localhost:3004/remoteEntry.js', // this filename is defined in the imported MFE in the filename property
},
chunks
. This configuration is usually everything you need by default, so if you changed it for some reason, double check to see if this is the cause.Basically the key reference for the MFE in the config is different from the key being used in the import statement:
// in webpack.config.js
remotes: {
wrongName: 'shared@http://localhost:3004/remoteEntry.js'
}
// in react component
const Component = React.lazy(() => import('shared/Component'));
This error can happen due to a number of reasons and it’s usually the hardest to debug:
output.publicPath
is not configured to the URL of the MFE or auto
. Module federation needs these values well defined to correctly import code.output.chunkFilename
is overridden and this may cause problems with module federation.By now I hope you got a good grasp on webpacks’ module federation feature and that you found the clothes store project a good starting point for your next enterprise project or to simply improve an existing one.
This is only the beginning, with this code base it’s possible to expand more on topics like communication between MFEs, data storage and so much more. I’ll cover more on these topics in the future.
Module federation is still very recent, and there are suggestions and pseudo standards defined by its creators, but it’s still early to define really solid standards. The content here may become outdated soon, but I’ll try to keep the repo up to date with the latest changes from webpack.
I hope you enjoyed! 👐 Please leave your comments down below to stir up the discussion!
If you find this article interesting, please share it, because you know — Sharing is caring!
Let me keep you posted on new projects, articles or talks that I do!