Creating a web application using micro-frontends and Module Federation
u4aew
Posted on February 4, 2024
Hello! In this article, we will explore the process of developing a web application based on the micro-frontends approach using the Module Federation technology.
Micro-frontends are an approach in web development where the frontend is divided into multiple small, autonomous parts. These parts are developed by different teams, possibly using different technologies, but ultimately they function together as a single unit. This approach helps address issues related to large applications, simplifies the development and testing process, promotes the use of various technologies, and enhances code reusability.
The goal of our project is to create a banking application with functionality for viewing and editing bank cards and transactions.
For implementation, we will choose Ant Design, React.js in combination with Module Federation.
The diagram illustrates the architecture of a web application using micro-frontends integrated through Module Federation. At the top of the image is the Host, which serves as the main application (Main app) and acts as a container for the other micro-applications.
There are two micro-frontends: Cards and Transactions, each developed by a separate team and performing specific functions within the banking application.
The diagram also includes the Shared component, which contains shared resources such as data types, utilities, components, and more. These resources are imported into both the Host and the micro-applications Cards and Transactions, ensuring consistency and code reuse throughout the application ecosystem.
Additionally, the diagram shows the Event Bus, which serves as a mechanism for message and event exchange between system components. This facilitates communication between the Host and micro-applications, as well as between the micro-applications themselves, enabling them to react to state changes.
This diagram demonstrates a modular and extensible structure of a web application, which is one of the key advantages of the micro-frontends approach. It allows for the development of applications that are easier to maintain, update, and scale.
We organize our applications inside the packages directory and set up Yarn Workspaces, which will allow us to efficiently use shared components from the shared module among different packages.
"workspaces": [
"packages/*"
],
Module Federation, introduced in Webpack 5, enables different parts of the application to dynamically load each other's code. With this feature, we ensure asynchronous loading of components.
Webpack configuration for the host application
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
const deps = require('./package.json').dependencies;
const isProduction = process.env.NODE_ENV === 'production';
module.exports = {
// Other Webpack configuration not directly related to Module Federation
// ...
plugins: [
// Module Federation plugin for integrating micro-frontends
new ModuleFederationPlugin({
remotes: {
// Defining remote micro-frontends available to this micro-frontend
'remote-modules-transactions': isProduction
? 'remoteModulesTransactions@https://microfrontend.fancy-app.site/apps/transactions/remoteEntry.js'
: 'remoteModulesTransactions@http://localhost:3003/remoteEntry.js',
'remote-modules-cards': isProduction
? 'remoteModulesCards@https://microfrontend.fancy-app.site/apps/cards/remoteEntry.js'
: 'remoteModulesCards@http://localhost:3001/remoteEntry.js',
},
shared: {
// Defining shared dependencies between different micro-frontends
react: { singleton: true, requiredVersion: deps.react },
antd: { singleton: true, requiredVersion: deps['antd'] },
'react-dom': { singleton: true, requiredVersion: deps['react-dom'] },
'react-redux': { singleton: true, requiredVersion: deps['react-redux'] },
axios: { singleton: true, requiredVersion: deps['axios'] },
},
}),
new HtmlWebpackPlugin({
template: path.join(__dirname, 'src', 'index.html'), // HTML template for Webpack
}),
],
// Other Webpack settings
// ...
};
Webpack configuration for the "Bank Cards" application
const path = require('path');
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const deps = require('./package.json').dependencies;
module.exports = {
// Other Webpack configuration...
plugins: [
new HtmlWebpackPlugin({
template: path.join(__dirname, 'src', 'index.html'), // HTML template for Webpack
}),
// Module Federation Plugin configuration
new ModuleFederationPlugin({
name: 'remoteModulesCards', // Microfrontend name
filename: 'remoteEntry.js', // File name that will serve as the entry point for the microfrontend
exposes: {
'./Cards': './src/root', // Defines which modules and components will be available to other microfrontends
},
shared: {
// Defining dependencies to be shared across different microfrontends
react: { requiredVersion: deps.react, singleton: true },
antd: { singleton: true, requiredVersion: deps['antd'] },
'react-dom': { requiredVersion: deps['react-dom'], singleton: true },
'react-redux': { singleton: true, requiredVersion: deps['react-redux'] },
axios: { singleton: true, requiredVersion: deps['axios'] },
},
}),
],
// Other Webpack settings...
};
Now we can easily import our applications into the host application.
import React, { Suspense, useEffect } from 'react';
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import { Main } from '../pages/Main';
import { MainLayout } from '@host/layouts/MainLayout';
// Lazy loading components Cards and Transactions from remote modules
const Cards = React.lazy(() => import('remote-modules-cards/Cards'));
const Transactions = React.lazy(() => import('remote-modules-transactions/Transactions'));
const Pages = () => {
return (
<Router>
<MainLayout>
{/* Using Suspense to manage the loading state of asynchronous components */}
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path={'/'} element={<Main />} />
<Route path={'/cards/*'} element={<Cards />} />
<Route path={'/transactions/*'} element={<Transactions />} />
</Routes>
</Suspense>
</MainLayout>
</Router>
);
};
export default Pages;
Next, let's set up Redux Toolkit for the "Bank Cards" team.
// Import the configureStore function from the Redux Toolkit library
import { configureStore } from '@reduxjs/toolkit';
// Import the root reducer
import rootReducer from './features';
// Create a store using the configureStore function
const store = configureStore({
// Set the root reducer
reducer: rootReducer,
// Set default middleware
middleware: (getDefaultMiddleware) => getDefaultMiddleware(),
});
// Export the store
export default store;
// Define types for the dispatcher and application state
export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;
// Import React
import React from 'react';
// Import the main application component
import App from '../app/App';
// Import Provider from react-redux to connect React and Redux
import { Provider } from 'react-redux';
// Import our Redux store
import store from '@modules/cards/store/store';
// Create the main Index component
const Index = (): JSX.Element => {
return (
// Wrap our application in Provider, passing our store to it
<Provider store={store}>
<App />
</Provider>
);
};
// Export the main component
export default Index;
The application should have a role-based access control system.
USER - can view pages, MANAGER - has editing rights, ADMIN - can edit and delete data.
The host application sends a request to the server to retrieve user information and stores this data in its own storage. It is necessary to securely retrieve this data in the "Bank Cards" application.
To achieve this, a middleware needs to be written for the Redux store of the host application to store the data in the global window object
// Import the configureStore function and Middleware type from the Redux Toolkit library
import { configureStore, Middleware } from '@reduxjs/toolkit';
// Import the root reducer and RootState type.
import rootReducer, { RootState } from './features';
// Create middleware that saves the application state in the global window object.
const windowStateMiddleware: Middleware<{}, RootState> =
(store) => (next) => (action) => {
const result = next(action);
(window as any).host = store.getState();
return result;
};
// Function to load state from the global window object
const loadFromWindow = (): RootState | undefined => {
try {
const hostState = (window as any).host;
if (hostState === null) return undefined;
return hostState;
} catch (e) {
console.warn('Error loading state from window:', e);
return undefined;
}
};
// Create a store using the configureStore function
const store = configureStore({
// Set the root reducer
reducer: rootReducer,
// Add middleware that saves the state in the window
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(windowStateMiddleware),
// Load the preloaded state from the window
preloadedState: loadFromWindow(),
});
// Export the store
export default store;
// Define the type for the dispatcher
export type AppDispatch = typeof store.dispatch;
Let's move the constants to the shared module.
export const USER_ROLE = () => {
return window.host.common.user.role;
};
To synchronize the user role changes across all micro-frontends, we will utilize an event bus. In the shared module, we will implement handlers for sending and receiving events.
// Importing event channels and role types
import { Channels } from '@/events/const/channels';
import { EnumRole } from '@/types';
// Declaring a variable for the event handler
let eventHandler: ((event: Event) => void) | null = null;
// Function for handling user role change
export const onChangeUserRole = (cb: (role: EnumRole) => void): void => {
// Creating an event handler
eventHandler = (event: Event) => {
// Casting the event to CustomEvent type
const customEvent = event as CustomEvent<{ role: EnumRole }>;
// If the event has details, log them to the console and call the callback function
if (customEvent.detail) {
console.log(`On ${Channels.changeUserRole} - ${customEvent.detail.role}`);
cb(customEvent.detail.role);
}
};
// Adding the event handler to the global window object
window.addEventListener(Channels.changeUserRole, eventHandler);
};
// Function to stop listening for user role changes
export const stopListeningToUserRoleChange = (): void => {
// If the event handler exists, remove it and reset the variable
if (eventHandler) {
window.removeEventListener(Channels.changeUserRole, eventHandler);
eventHandler = null;
}
};
// Function to emit an event for user role change
export const emitChangeUserRole = (newRole: EnumRole): void => {
// Logging event information to the console
console.log(`Emit ${Channels.changeUserRole} - ${newRole}`);
// Creating a new event
const event = new CustomEvent(Channels.changeUserRole, {
detail: { role: newRole },
});
// Dispatching the event
window.dispatchEvent(event);
};
For the implementation of a bank card editing page that accounts for user roles, we will start by establishing a mechanism for subscribing to role update events. This will enable the page to respond to changes and adapt the available editing features according to the current user role.
import React, { useEffect, useState } from 'react';
import { Button, Card, List, Modal, notification } from 'antd';
import { useDispatch, useSelector } from 'react-redux';
import { getCardDetails } from '@modules/cards/store/features/cards/slice';
import { AppDispatch } from '@modules/cards/store/store';
import { userCardsDetailsSelector } from '@modules/cards/store/features/cards/selectors';
import { Transaction } from '@modules/cards/types';
import { events, variables, types } from 'shared';
const { EnumRole } = types;
const { USER_ROLE } = variables;
const { onChangeUserRole, stopListeningToUserRoleChange } = events;
export const CardDetail = () => {
// Using Redux for dispatching and retrieving state
const dispatch: AppDispatch = useDispatch();
const cardDetails = useSelector(userCardsDetailsSelector);
// Local state for user role and modal visibility
const [role, setRole] = useState(USER_ROLE);
const [isModalVisible, setIsModalVisible] = useState(false);
// Effect for loading card details on component mount
useEffect(() => {
const load = async () => {
await dispatch(getCardDetails('1'));
};
load();
}, []);
// Functions for managing the edit modal
const showEditModal = () => {
setIsModalVisible(true);
};
const handleEdit = () => {
setIsModalVisible(false);
};
const handleDelete = () => {
// Display notification about deletion
notification.open({
message: 'Card delete',
description: 'Card delete success.',
onClick: () => {
console.log('Notification Clicked!');
},
});
};
// Effect for subscribing to and unsubscribing from user role change events
useEffect(() => {
onChangeUserRole(setRole);
return stopListeningToUserRoleChange;
}, []);
// Conditional rendering if card details are not loaded
if (!cardDetails) {
return <div>Loading...</div>;
}
// Function to determine actions based on user role
const getActions = () => {
switch (role) {
case EnumRole.admin:
return [
<Button key="edit" type="primary" onClick={showEditModal}>
Edit
</Button>,
<Button key="delete" type="dashed" onClick={handleDelete}>
Delete
</Button>,
];
case EnumRole.manager:
return [
<Button key="edit" type="primary" onClick={showEditModal}>
Edit
</Button>,
];
default:
return [];
}
};
// Rendering the Card component with card details and actions
return (
<>
<Card
actions={getActions()}
title={`Card Details - ${cardDetails.cardHolderName}`}
>
{/* Displaying various card attributes */}
<p>PAN: {cardDetails.pan}</p>
<p>Expiry: {cardDetails.expiry}</p>
<p>Card Type: {cardDetails.cardType}</p>
<p>Issuing Bank: {cardDetails.issuingBank}</p>
<p>Credit Limit: {cardDetails.creditLimit}</p>
<p>Available Balance: {cardDetails.availableBalance}</p>
{/* List of recent transactions */}
<List
header={<div>Recent Transactions</div>}
bordered
dataSource={cardDetails.recentTransactions}
renderItem={(item: Transaction) => (
<List.Item>
{item.date} - {item.amount} {item.currency} - {item.description}
</List.Item>
)}
/>
<p>
<b>*For demonstration events from the host, change the user role.</b>
</p>
</Card>
{/* Edit modal */}
<Modal
title="Edit transactions"
open={isModalVisible}
onOk={handleEdit}
onCancel={() => setIsModalVisible(false)}
>
<p>Form edit card</p>
</Modal>
</>
);
};
To set up the deployment of the application through GitHub Actions, we will create a .yml configuration file that defines the CI/CD workflow. Here's an example of a simple configuration:
name: Build and Deploy Cards Project
on:
push:
paths:
- 'packages/cards/**'
pull_request:
paths:
- 'packages/cards/**'
install-dependencies:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Node.js
uses: actions/setup-node@v2
with:
node-version: '16'
- name: Cache Node modules
uses: actions/cache@v2
with:
path: '**/node_modules'
key: ${{ runner.os }}-node-${{ hashFiles('**/yarn.lock') }}
- name: Install Dependencies
run: yarn install
test-and-build:
needs: install-dependencies
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Use Node.js
uses: actions/setup-node@v2
with:
node-version: '16'
- name: Cache Node modules
uses: actions/cache@v2
with:
path: '**/node_modules'
key: ${{ runner.os }}-node-${{ hashFiles('**/yarn.lock') }}
- name: Build Shared Modules
run: yarn workspace shared build
- name: Test and Build Cards
run: |
yarn workspace cards test
yarn workspace cards build
- name: Archive Build Artifacts
uses: actions/upload-artifact@v2
with:
name: shared-artifacts
path: packages/cards/dist
deploy-cards:
needs: test-and-build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Use Node.js
uses: actions/setup-node@v2
with:
node-version: '16'
- name: Cache Node modules
uses: actions/cache@v2
with:
path: '**/node_modules'
key: ${{ runner.os }}-node-${{ hashFiles('**/yarn.lock') }}
- name: Download Build Artifacts
uses: actions/download-artifact@v2
with:
name: shared-artifacts
path: ./cards
- name: Deploy to Server
uses: appleboy/scp-action@master
with:
host: ${{ secrets.HOST }}
username: root
key: ${{ secrets.SSH_PRIVATE_KEY }}
source: 'cards/*'
target: '/usr/share/nginx/html/microfrontend/apps'
The screenshot shows the distribution of collected bundles. Here we can add functions such as versioning and A/B testing, managing them through Nginx.
As a result, we have a system where each team working on different modules has its own application in the microfrontend structure.
This approach speeds up the build process, as it is no longer necessary to wait for the entire application to be checked. Code can be updated in parts and regression testing can be conducted for each individual component.
It also significantly reduces the problem of merge conflicts, as teams work on different parts of the project independently of each other. This increases the efficiency of team work and simplifies the development process as a whole.
A test environment for demonstrating functionality and the source code is available in the GitHub repository.
Thank you for your attention!
Posted on February 4, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.