DEV Community

Cover image for Creating a web application using micro-frontends and Module Federation
u4aew
u4aew

Posted on • Edited on

Creating a web application using micro-frontends and Module Federation

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.

Image description

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.

Image description

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/*"
  ],
Enter fullscreen mode Exit fullscreen mode

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
  // ...
};
Enter fullscreen mode Exit fullscreen mode

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...
};
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Next, let's set up Redux Toolkit for the "Bank Cards" team.

Image description

// 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>;
Enter fullscreen mode Exit fullscreen mode
// 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;
Enter fullscreen mode Exit fullscreen mode

The application should have a role-based access control system.

Image description

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;

Enter fullscreen mode Exit fullscreen mode

Let's move the constants to the shared module.

Image description

export const USER_ROLE = () => {
  return window.host.common.user.role;
};
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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>
    </>
  );
};

Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

Image description

Image description

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!

Top comments (1)

Collapse
 
silverium profile image
Soldeplata Saketos • Edited

It does not feel good to me to store the state in the window object.

The Single-SPA team has good reasons against a shared Store between micro-frontends: single-spa.js.org/docs/recommended...

Is it me or are we missing the webpack config file for the transactions module?

By the way, thanks for the interesting article