Replatforming A Web App With Microfrontends

Alp Gökçek
Trendyol Tech

--

In Trendyol, the Quality Center team primarily focuses on ensuring that products meet the standards required for our platform, but we also ensure that banners, videos, and other resources adhere to these standards. We are working with 3rd party agents for manual actions. As part of this team, we encountered significant challenges with our old web application, which had become difficult to maintain and scale. To address these issues, we decided to replatform our application using a microfrontend architecture. This decision has greatly improved our workflow and the overall quality of our output. In this article, I will explain how we handled these challenges and sharing the strategies we used.

Replatforming Decision Process

First of all, replatforming an application is a hard decision since it is highly complex, and I know that many teams hesitate because of this. During the decision process, I prepared an RFC to gather comments from my teammates and peers. In the RFC, we highlighted the following problems:

  1. Quality Center Team’s Domain: Our domain is vast, encompassing seven different modules within a monolithic architecture, each with complex business logic. This complexity results in a large and intricate codebase, consisting of nearly 30,000 lines of code. This makes development challenging and onboarding new team members difficult.
  2. Outdated Technology Stack: The initial project was built with Vue v2.6.11, which was released over four years ago. This version does not support the latest Node versions, including the current LTS version (v18.x), leaving our project vulnerable to potential security issues.
  3. Outdated Third-Party Packages: Many third-party packages in the project are outdated. For example, we use the Vuetify UI library at version 2.6.x, which no longer looks modern. Additionally, Vuetify has released three major versions since then, and migrating directly to these versions is not possible due to significant changes in the component props.

Because of these reasons, we decided to replatform our application.

Where Do We Use Microfrontends, and What Benefits Do They Offer?

In Trendyol, we use microfrontend architecture across various applications. This approach has allowed different teams to work on separate features independently, leading to faster development cycles and more robust applications. Here are some key areas where we implemented microfrontends and the benefits we observed:

  1. Modularization: Breaking down the application into smaller, manageable pieces made it easier to develop, test, and deploy.
  2. Team Autonomy: Different teams could work on different parts of the application without stepping on each other’s toes, enhancing productivity.
  3. Scalability: Each microfrontend can be scaled independently, making the overall application more scalable.
  4. Technology Diversity: Teams could choose the best technologies for their specific needs without being constrained by a monolithic architecture.

Starting to Replatforming

Before starting the replatforming, we developed a few Proof-of-Concept (PoC) applications to experiment with the new architecture. The PoC approach is widely used within Trendyol, as it allows us to identify potential challenges and explore new solutions.

From the results of these PoCs, we decided to use Webpack’s Module Federation Plugin. Module Federation, introduced in Webpack 5, enables JavaScript applications to share code and dependencies dynamically. This allows different teams to work on separate parts of an application and deploy them independently, facilitating a micro frontend architecture.

Our Microfrontend System Architecture

With this approach, we enabled the gradual replacement of modules instead of overhauling the entire application at once. Our process was as follows:

  1. Create Shell (Root) Application: This would serve as the parent for all applications.
  2. Add Module Federation to Initial Application: Incorporate Module Federation into the existing application.
  3. Connect Root and Initial Application: Link the root and initial applications to ensure a seamless transition during deployment.
  4. Replace Initial Application with New Shell Application: Gradually phase out the initial application as the new shell application takes over.

For developing a new module:

  1. Deploy to CDN: Deploy the new module to the Content Delivery Network (CDN).
  2. Add New Application to Shell Application: Integrate the new module into the shell application.
  3. Update Navigation: Replace navigation from the old application to the new application.

This approach allowed us to create a seamless transition for our agents.

Challenges We Faced

During the replatforming, we have faced with various challenges. Most challenging issues were:

  1. Routing: Syncing the legacy app’s router with the new app’s router.
  2. CSS Scope Isolation: Ensuring that styles from different microfrontends did not clash with each other.
  3. Dynamic Releases: Managing child app releases dynamically to avoid refreshes.

Routing

One of the major challenges was syncing the legacy app’s router with the new app’s router. Ensuring a smooth transition between the old and new systems was crucial for maintaining a seamless user experience. We had to create a strategy that allowed both routers to coexist and communicate effectively, which involved extensive testing and adjustments to handle edge cases where routing logic between the two systems could conflict.

Our Approach

First, we conducted comprehensive research on routing in module federation applications. While we found that routing could be managed using window events, we sought a more robust solution. Ultimately, we developed a new approach by creating a view component that mounts the old Vue application within the new framework.

import React, { useRef, useLayoutEffect, useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';

const LegacyPage = () => {
const ref = useRef(null);
const mountedApp = useRef(null);
const navigate = useNavigate();
const location = useLocation();

useLayoutEffect(() => {
const mountApp = async () => {
const { mount } = await import('LegacyApp/Legacy');
mountedApp.current = mount(ref.current, navigate);
};
mountApp();
return () => {
mountedApp.current?.$destroy();
};
}, []);

useEffect(() => {
mountedApp.current?.$router?.replace(location.pathname);
}, [location.pathname]);

return (
<div className="w-full h-full" id="quality-center-legacy">
<div ref={ref} />
</div>
);
};

export default LegacyPage;

In this example, we pass the navigate function to the child Vue application.

// vue application (child app)
const mount = (el, navigate) => {
router.beforeResolve((to, _, next) => {
navigate(to.fullPath, { replace: true });
next();
});
router.onError(() => ({}));
const instance = new Vue({
router,
store,
vuetify,
render: (h) => h(App),
}).$mount(el);
return instance;
};


export { mount };

With this approach, we resolved all routing issues with our legacy Vue application. For React-to-React routing cases, we did not encounter any problems, thanks to the react-router-dom library. We exposed our App.tsx file to the shell application as shown below:

    <Routes>
<Route Component={MainLayout}>
{mainRoutes.map((routeConfig) =>
routeConfig.children?.length ? (
routeConfig.children.map((cfg) => <Route key={cfg.id} {...cfg} />)
) : (
<Route key={routeConfig.id} {...routeConfig} />
)
)}
</Route>
<Route path="*" element={<Navigate replace to="/" />} />
</Routes>

By using this method, we achieved seamless routing integration between our legacy Vue application and our new React components, ensuring a smooth user experience and easy maintainability.

CSS Scope Isolation

Managing CSS to prevent style clashes between different microfrontends was a significant challenge. In a microfrontend architecture, it’s essential to ensure that styles from various components do not interfere with each other. This required us to develop a robust solution to isolate CSS for each microfrontend.

Our Approach

To address this issue, we explored various techniques to encapsulate styles and prevent conflicts. The most effective solution was to develop a custom PostCSS plugin that automatically wrapped all CSS selectors with a unique parent selector, ensuring style encapsulation. Here’s how we implemented it:

// postcss.config.js
const tailwindcss = require('tailwindcss');
const wrapSelector = (opts = {}) => ({
postcssPlugin: 'wrap-selector',
Once(root) {
root.walkRules((rule) => {
if (!rule.selectors) return rule;
rule.selectors = rule.selectors.map((selector) => `${opts.wrapper} ${selector}`);
});
},
});
wrapSelector.postcss = true;

module.exports = {
plugins: ['postcss-preset-env', tailwindcss, wrapSelector({ wrapper: '#app1-id' })],
};

This PostCSS plugin ensured that all CSS selectors were scoped to a specific parent selector, which prevented any style conflicts between different microfrontends.

By encapsulating the styles within a specific parent selector, we ensured that the CSS for each microfrontend was isolated, preventing any conflicts and maintaining a clean and modular architecture.

For more details, you can checkout the following article that I have written:

Dynamic Releases

Managing dynamic releases of child applications was another complex issue we faced. We needed a way to release updates to individual microfrontends without requiring a full application refresh. Besides that,

Our Approach

To solve this challenge, we used the ExternalTemplateRemotesPlugin to manage dynamic releases effectively. This plugin allowed us to dynamically load microfrontend modules based on external configurations, enabling seamless updates without the need for redeploying the entire application. Here’s how we configured Module Federation for dynamic releases:

// webpack.config.js
{
...
plugins: [
new ModuleFederationPlugin({
name: 'ShellApp',
filename: 'remoteEntry.js',
remotes: Object.keys(config.microapps).reduce((cfg, app) => {
const address = `window.__CONFIG__.microapps.${app}`;
cfg[app] = isLocal ? `${app}@${config.microapps[app]}/remoteEntry.js` : `${app}@[${address}]/remoteEntry.js`;
return cfg;
}, {}),
...
}),
new ExternalTemplateRemotesPlugin(),
...
]
}

The remotes configuration dynamically assigns the remote entry points for each microfrontend. This is based on an external configuration object (window.__CONFIG__.microapps) which is injected by server, allowing us to update the addresses of microfrontends without changing the code. To dynamically update the window object, we have added a socket connection between clients and server to notify clients when a new release is happened.

Benefits

This approach provided several key benefits:

  1. Seamless Updates: We could deploy updates to individual microfrontends dynamically, without requiring a full application restart or redeployment.
  2. Flexibility: The use of an external configuration for microfrontends allowed us to make changes on-the-fly, adapting quickly to new requirements or fixes.

By leveraging the ExternalTemplateRemotesPlugin and configuring Module Federation accordingly, we achieved a robust and flexible system for managing dynamic releases. This approach ensured that our application could be updated seamlessly, providing a better experience for both developers and users.

Conclusion

Replatforming our old web application to a microfrontend architecture was a complex but rewarding journey. By addressing challenges such as CSS scope isolation, dynamic configuration management, and sync routing, we were able to create a more modular, scalable, and maintainable system.

One of the key benefits we aimed for was increased autonomy between projects. Indeed, the microfrontend architecture has enabled individual teams to work more independently on different parts of the application, reducing dependencies and speeding up development cycles. This autonomy has also facilitated parallel development, where multiple features can be developed and deployed simultaneously without major integration issues.

Moreover, our development experience has significantly improved. Teams now have the flexibility to choose the most appropriate technologies for their specific microfrontend, which has led to a boost in developer satisfaction and productivity. The streamlined deployment process has also reduced the time required to bring new features to market.

Additionally, we achieved a 50% decrease in First Contentful Paint (FCP) and a 30% decrease in Largest Contentful Paint (LCP), significantly enhancing the user experience.

Our experience has shown that while technical challenges are inevitable, they can be overcome with innovative solutions and a collaborative team effort. We hope our journey provides valuable insights for others looking to undertake a similar transformation.

Join Us

Be a part of something great! Trendyol is currently hiring. Visit the pages below for more information and to apply.

--

--